From 7de3cbd4dacd1036c6d5b92dd7dfe0f0b16169fb Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:10:48 -0300 Subject: [PATCH] feat: add /undo to remove the latest exchange --- docs/slash-commands.md | 1 + src/commands/catalog.ts | 1 + src/commands/execute.ts | 4 ++++ src/session/chat-session.ts | 8 ++++++++ tests/session.test.ts | 21 +++++++++++++++++++++ 5 files changed, 35 insertions(+) diff --git a/docs/slash-commands.md b/docs/slash-commands.md index c7d60e0..b2ac4db 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -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 ` | Change the terminal palette | | `/stats` | Show message and token estimates | | `/quit` | Close the interface | diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 414a202..613cf70 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -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 ', description: 'Change the terminal palette.'}, {name: 'stats', usage: '/stats', description: 'Show message and token estimates.'}, {name: 'quit', usage: '/quit', description: 'Close Relay Chat.'}, diff --git a/src/commands/execute.ts b/src/commands/execute.ts index 8f2dbb5..06a5184 100644 --- a/src/commands/execute.ts +++ b/src/commands/execute.ts @@ -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)) diff --git a/src/session/chat-session.ts b/src/session/chat-session.ts index bc4a164..434232f 100644 --- a/src/session/chat-session.ts +++ b/src/session/chat-session.ts @@ -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 { if (this.isStreaming) throw new Error('A reply is already streaming.'); const message = this.#store diff --git a/tests/session.test.ts b/tests/session.test.ts index 6b00f09..b4bf7b5 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -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'}));