Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
| `/export [path]` | Write a Markdown transcript |
| `/retry` | Retry a failed or cancelled reply |
| `/regenerate` | Replace the latest assistant reply |
| `/undo` | Remove the latest exchange |
| `/theme <relay\|mono>` | Change the terminal palette |
| `/stats` | Show message and token estimates |
| `/quit` | Close the interface |
Expand Down
1 change: 1 addition & 0 deletions src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const commandCatalog: readonly CommandDescription[] = [
{name: 'export', usage: '/export [path]', description: 'Export the conversation as Markdown.'},
{name: 'retry', usage: '/retry', description: 'Retry the last failed or cancelled reply.'},
{name: 'regenerate', usage: '/regenerate', description: 'Replace the latest assistant reply.'},
{name: 'undo', usage: '/undo', description: 'Remove the latest message and its reply.'},
{name: 'theme', usage: '/theme <relay|mono>', description: 'Change the terminal palette.'},
{name: 'stats', usage: '/stats', description: 'Show message and token estimates.'},
{name: 'quit', usage: '/quit', description: 'Close Relay Chat.'},
Expand Down
4 changes: 4 additions & 0 deletions src/commands/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export const executeSlashCommand = async (
case 'regenerate':
await session.regenerate();
return;
case 'undo':
session.undo();
store.setNotice('Removed the latest message and its reply.');
return;
case 'theme': {
const name = chosenPath(command);
if (name === undefined || !chatThemes.has(name))
Expand Down
8 changes: 8 additions & 0 deletions src/session/chat-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export class ChatSession {
await this.regenerate();
}

undo(): string {
if (this.isStreaming) throw new Error('Cancel the active reply before undoing a turn.');
const message = lastUserMessage(this.#store.getSnapshot().conversation);
if (message === undefined) throw new Error('There is no user message to undo.');
this.#store.truncateFrom(message.id);
return message.content;
}

async editAndResend(userMessageId: string, content: string): Promise<void> {
if (this.isStreaming) throw new Error('A reply is already streaming.');
const message = this.#store
Expand Down
21 changes: 21 additions & 0 deletions tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ describe('ChatSession', () => {
expect(store.getSnapshot().conversation.title).toBe('question');
});

it('undoes the latest user turn and its reply', async () => {
const store = new ChatStore(createConversation({id: 'c1'}));
const session = new ChatSession(
store,
new EchoTransport({
delayMs: 0,
response: (request) => `re: ${String(request.messages.length)}`,
}),
);
await session.send('first');
await session.send('second');
expect(session.undo()).toBe('second');
expect(store.getSnapshot().conversation.messages.map(({content}) => content)).toEqual([
'first',
're: 1',
]);
session.undo();
expect(store.getSnapshot().conversation.messages).toEqual([]);
expect(() => session.undo()).toThrow('There is no user message to undo.');
});

it('regenerates without duplicating the user turn', async () => {
let count = 0;
const store = new ChatStore(createConversation({id: 'c1'}));
Expand Down
Loading