diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index abd1b851..e058b1ff 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -254,6 +254,7 @@ export class TuiChatController { getMessages(current.sessionId), ]); if (projectionSequence !== this.sessionProjectionSequence) return; + this.turnProjection.clearTodos(); this.transcript.replaceDurableProjection(() => this.turnProjection.hydrateHistory(messages)); this.durableMessageAnchor = latestHistoryMessageId(messages); if (projectionSequence !== this.sessionProjectionSequence) return; diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 43b93fdc..601e2997 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -281,7 +281,13 @@ export class TuiCommandFlow { } : options; const command = input.trim(); - if (this.options.sessionMutationFlow?.isEditing?.()) { + // Local commands remain commands while editing a historical message. + // Skills and explicit message submissions still belong to the edited prompt. + const localCommand = !submitOptions.forceMessage && this.catalog.resolve(command); + if ( + this.options.sessionMutationFlow?.isEditing?.() && + (!localCommand || localCommand.invocationKind === 'skill') + ) { const editDisposition = await this.options.sessionMutationFlow.submitEdit( input, seed?.resources.attachments, diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index fac9a117..9461c9d6 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -3557,6 +3557,93 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each(["rewind", "edit"] as const)( + "removes discarded todos after /history %s", + async (action) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessionInputSummaries).mockResolvedValue([ + { userMessageId: "before-todo", timestamp: 1, fileChangeCount: 0 }, + ]); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ + messages: [{ id: "before-todo", role: "user", content: "Original prompt" }], + hasMore: false, + }); + const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + await app.submit("Seed todo session"); + app.controller.applyRuntimeTurnEvent("discarded-turn", { + type: "generic", + eventType: "todo_updated", + turnId: "discarded-turn", + data: { todos: [{ content: "Discarded todo", status: "in_progress" }] }, + }); + expect(stripAnsi(app.tui.render(100).join("\n"))).toContain("Discarded todo"); + await app.submit("/history"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe("session-history:explorer"), + ); + terminal.input?.("\r"); + terminal.input?.("\x1b[B"); + if (action === "rewind") terminal.input?.("\x1b[B"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe( + action === "rewind" + ? "session-mutation:rewind-confirm" + : "session-mutation:rewind-preview", + ), + ); + terminal.input?.("\r"); + if (action === "edit") { + await vi.waitFor(() => expect(app.editor.getText()).toBe("Original prompt")); + await app.submit("Revised prompt"); + expect(runtime.editSessionMessage).toHaveBeenCalledOnce(); + } else { + await vi.waitFor(() => expect(runtime.rewindSession).toHaveBeenCalledOnce()); + } + await vi.waitFor(() => expect(app.surfaceHost.getActiveSurface().kind).toBe("chat")); + expect(stripAnsi(app.tui.render(100).join("\n"))).not.toContain("Discarded todo"); + } finally { + await app.stop(); + } + }, + ); + + it.each([false, true])("handles exact /rewind locally while editing=%s", async (editing) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.listSessionInputSummaries).mockResolvedValue([ + { userMessageId: "before-todo", timestamp: 1, fileChangeCount: 0 }, + ]); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ + messages: [{ id: "before-todo", role: "user", content: "Original prompt" }], + hasMore: false, + }); + const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + await app.submit("Seed session"); + if (editing) { + await app.submit("/edit"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Original prompt")); + } + const sends = vi.mocked(runtime.sendMessage).mock.calls.length; + app.editor.setText("/rewind"); + app.editor.handleInput("\r"); + await vi.waitFor(() => + expect(app.surfaceHost.getActiveSurface().id).toBe("session-mutation:history"), + ); + expect(runtime.editSessionMessage).not.toHaveBeenCalled(); + expect(runtime.sendMessage).toHaveBeenCalledTimes(sends); + } finally { + await app.stop(); + } + }); + it("uses Ctrl+T for tasks while Ctrl+O remains scoped to transcript details", async () => { const terminal = new FakeTerminal(); const app = createTuiApp({ diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index 922c4e5f..a0102b16 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -2745,6 +2745,49 @@ describe('TuiChatController', () => { ); }); + it.each([false, true])( + 'rebuilds todos from rewound history with earlier todos=%s', + async (keepEarlier) => { + const onTodoChange = vi.fn(); + const earlierTodo = { content: 'Earlier task', status: 'pending' }; + const getMessages = vi.fn(async () => + keepEarlier + ? [ + { + id: 'earlier-todo', + turnId: 'earlier-turn', + role: 'system' as const, + content: JSON.stringify({ eventType: 'todo_updated', todos: [earlierTodo] }), + }, + ] + : [], + ); + const controller = new ProductionTuiChatController({ + runtime: { + getSession: vi.fn(async () => ({ sessionId: 'rewound-session' })), + getMessages, + } as never, + transcript: new TranscriptStore(), + workspaceDir: '/workspace', + onTodoChange, + }); + await controller.loadSessionProjection('rewound-session'); + controller.applyRuntimeTurnEvent('discarded-turn', { + type: 'generic', + eventType: 'todo_updated', + turnId: 'discarded-turn', + data: { todos: [{ content: 'Discarded task', status: 'in_progress' }] }, + }); + expect(onTodoChange).toHaveBeenLastCalledWith([ + { content: 'Discarded task', status: 'in_progress' }, + ]); + + await controller.reconcileOwnerHistory(true); + + expect(onTodoChange).toHaveBeenLastCalledWith(keepEarlier ? [earlierTodo] : []); + }, + ); + it('clears input-adjacent tasks when starting a new session', () => { const onTodoChange = vi.fn(); const controller = new ProductionTuiChatController({