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 packages/tui/src/tui/controller/chat-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion packages/tui/src/tui/controller/product/command-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions packages/tui/test/unit/tui-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
43 changes: 43 additions & 0 deletions packages/tui/test/unit/tui-chat-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading