From 63ef54da98dc1208d987370db9857e07e4ae4f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=97=AD?= Date: Wed, 30 Sep 2026 14:02:42 +0800 Subject: [PATCH] fix(tui): single plan snapshot and editor history navigation - editor: single-line Up recalls the previous prompt instead of only moving the cursor to line start; Down restores the draft - keybindings: fullscreen Ctrl+End jumps the transcript to the bottom and restores output follow; regular mode keeps it on the input - transcript: replace the previous successful task_update snapshot so repeated updates no longer stack duplicate full plans (live and resumed sessions) --- apps/cli/src/ui/interactive-mode.ts | 11 ++++ apps/cli/src/ui/runtime/session-events.ts | 13 ++++- .../src/ui/runtime/task-update-transcript.ts | 58 +++++++++++++++++++ apps/cli/test/step-task-plan.test.ts | 6 +- apps/cli/test/task-update-transcript.test.ts | 49 ++++++++++++++++ packages/tui/src/components/editor.ts | 5 +- packages/tui/src/keybindings.ts | 2 +- packages/tui/test/editor.test.ts | 32 +++++----- packages/tui/test/keybindings.test.ts | 2 +- packages/tui/test/tui-alt-screen.test.ts | 21 +++++-- 10 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 apps/cli/src/ui/runtime/task-update-transcript.ts create mode 100644 apps/cli/test/task-update-transcript.test.ts diff --git a/apps/cli/src/ui/interactive-mode.ts b/apps/cli/src/ui/interactive-mode.ts index dd044edb..e0417f80 100644 --- a/apps/cli/src/ui/interactive-mode.ts +++ b/apps/cli/src/ui/interactive-mode.ts @@ -163,6 +163,11 @@ import { handleCtrlC, handleCtrlD } from "./runtime/interrupt.ts"; import { PastedImageRegistry, resolvePastedImages } from "./runtime/pasted-images.ts"; import { createRedraw, type Redraw } from "./runtime/redraw.ts"; import { handleSessionEvent, subscribeToAgent } from "./runtime/session-events.ts"; +import { + keepLatestTaskUpdateResult, + registerTaskUpdateCall, + resetTaskUpdateTranscript, +} from "./runtime/task-update-transcript.ts"; import { FooterComponent, formatTokens } from "./view/chrome/footer.ts"; import { BranchSummaryStatusIndicator, @@ -3582,6 +3587,7 @@ export class InteractiveMode { items: readonly RenderSessionItem[], options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { + resetTaskUpdateTranscript(this.chatContainer); this.pendingTools.clear(); const renderedPendingTools = new Map(); // Cache-miss notices are not persisted; re-derive them from the full entry @@ -3628,6 +3634,9 @@ export class InteractiveMode { ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); + if (content.name === "task_update") { + registerTaskUpdateCall(this.chatContainer, content.id); + } if (message.stopReason === "aborted" || message.stopReason === "error") { let errorMessage: string; @@ -3644,6 +3653,7 @@ export class InteractiveMode { content: [{ type: "text", text: errorMessage }], isError: true, }); + keepLatestTaskUpdateResult(this.chatContainer, content.id, component, true); } else { renderedPendingTools.set(content.id, component); } @@ -3658,6 +3668,7 @@ export class InteractiveMode { const component = renderedPendingTools.get(message.toolCallId); if (component) { component.updateResult(message); + keepLatestTaskUpdateResult(this.chatContainer, message.toolCallId, component, message.isError); renderedPendingTools.delete(message.toolCallId); } } else { diff --git a/apps/cli/src/ui/runtime/session-events.ts b/apps/cli/src/ui/runtime/session-events.ts index a4b7057f..74dcb6d1 100644 --- a/apps/cli/src/ui/runtime/session-events.ts +++ b/apps/cli/src/ui/runtime/session-events.ts @@ -40,6 +40,7 @@ import { WorkingStatusIndicator, } from "../view/index.ts"; import type { RuntimeContext } from "./context.ts"; +import { keepLatestTaskUpdateResult, registerTaskUpdateCall } from "./task-update-transcript.ts"; /** Subscribe to the session's agent events. Returns/records the unsubscribe handle (owned by the composition root). */ export function subscribeToAgent(ctx: RuntimeContext): void { @@ -171,6 +172,9 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio ); component.setExpanded(ctx.toolOutputExpanded); ctx.chatContainer.addChild(component); + if (content.name === "task_update") { + registerTaskUpdateCall(ctx.chatContainer, content.id); + } ctx.pendingTools.set(content.id, component); } else { const component = ctx.pendingTools.get(content.id); @@ -209,11 +213,12 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio if (!errorMessage) { errorMessage = ctx.streamingMessage.errorMessage || "Error"; } - for (const [, component] of ctx.pendingTools.entries()) { + for (const [toolCallId, component] of ctx.pendingTools.entries()) { component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true, }); + keepLatestTaskUpdateResult(ctx.chatContainer, toolCallId, component, true); } for (const toolCallId of ctx.pendingTools.keys()) { ctx.stepSpinner?.stop(toolCallId); @@ -256,7 +261,12 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio ); component.setExpanded(ctx.toolOutputExpanded); ctx.chatContainer.addChild(component); + if (event.toolName === "task_update") { + registerTaskUpdateCall(ctx.chatContainer, event.toolCallId); + } ctx.pendingTools.set(event.toolCallId, component); + } else if (event.toolName === "task_update") { + registerTaskUpdateCall(ctx.chatContainer, event.toolCallId); } component.markExecutionStarted(); ctx.stepSpinner?.start(event.toolCallId, event.toolName); @@ -289,6 +299,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio const component = ctx.pendingTools.get(event.toolCallId); if (component) { component.updateResult({ ...event.result, isError: event.isError }); + keepLatestTaskUpdateResult(ctx.chatContainer, event.toolCallId, component, event.isError); ctx.pendingTools.delete(event.toolCallId); ctx.stepSpinner?.stop(event.toolCallId); // 工具结束不立刻降级动词:瞬时工具(read ~300ms)的动词如果 diff --git a/apps/cli/src/ui/runtime/task-update-transcript.ts b/apps/cli/src/ui/runtime/task-update-transcript.ts new file mode 100644 index 00000000..8494b0dd --- /dev/null +++ b/apps/cli/src/ui/runtime/task-update-transcript.ts @@ -0,0 +1,58 @@ +import type { Component, Container } from "@step-harness/pi-tui"; + +interface TaskUpdateTranscriptState { + nextOrdinal: number; + callOrdinals: Map; + latestSuccessful?: { + ordinal: number; + component: Component; + }; +} + +const transcriptStates = new WeakMap(); + +export function resetTaskUpdateTranscript(container: Container): void { + transcriptStates.set(container, { + nextOrdinal: 0, + callOrdinals: new Map(), + }); +} + +export function registerTaskUpdateCall(container: Container, toolCallId: string): void { + const state = getState(container); + if (state.callOrdinals.has(toolCallId)) return; + state.nextOrdinal += 1; + state.callOrdinals.set(toolCallId, state.nextOrdinal); +} + +export function keepLatestTaskUpdateResult( + container: Container, + toolCallId: string, + component: Component, + isError: boolean, +): void { + const state = getState(container); + const ordinal = state.callOrdinals.get(toolCallId); + if (ordinal === undefined) return; + state.callOrdinals.delete(toolCallId); + if (isError) return; + + if (state.latestSuccessful && ordinal < state.latestSuccessful.ordinal) { + container.removeChild(component); + return; + } + + if (state.latestSuccessful && state.latestSuccessful.component !== component) { + container.removeChild(state.latestSuccessful.component); + } + state.latestSuccessful = { ordinal, component }; +} + +function getState(container: Container): TaskUpdateTranscriptState { + let state = transcriptStates.get(container); + if (!state) { + resetTaskUpdateTranscript(container); + state = transcriptStates.get(container)!; + } + return state; +} diff --git a/apps/cli/test/step-task-plan.test.ts b/apps/cli/test/step-task-plan.test.ts index 1a2d5515..dc8c444e 100644 --- a/apps/cli/test/step-task-plan.test.ts +++ b/apps/cli/test/step-task-plan.test.ts @@ -1,5 +1,5 @@ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@step-harness/coding-agent"; -import { stripTerminalSequences, type TUI, visibleWidth } from "@step-harness/pi-tui"; +import { Container, stripTerminalSequences, type TUI, visibleWidth } from "@step-harness/pi-tui"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStepTasksExtension } from "../../../packages/coding-agent/src/features/step-tasks.ts"; import { initTheme, theme } from "../../../packages/coding-agent/src/theme/theme.ts"; @@ -88,6 +88,7 @@ describe("inline task plan", () => { await execute("task_create", { subject: `Task ${index}`, description: "work" }); } const pendingTools = new Map(); + const chatContainer = new Container(); const runtime = { isInitialized: true, footer: { invalidate: vi.fn() }, @@ -97,7 +98,7 @@ describe("inline task plan", () => { getRegisteredToolDefinition: (name: string) => tools.get(name), ui, sessionManager: { getCwd: () => "/tmp" }, - chatContainer: { addChild: vi.fn() }, + chatContainer, workingOutputTracker: { notifyToolStarted: vi.fn() }, workingVisible: false, redraw: { requestRender: vi.fn() }, @@ -140,6 +141,7 @@ describe("inline task plan", () => { }); expect(first.render(80).map(stripTerminalSequences).join("\n")).toContain("Updated Plan (1/5)"); expect(second.render(80)).toEqual(latestRows); + expect(chatContainer.children).toEqual([second]); expect(pendingTools.size).toBe(0); expect(runtime.redraw.requestRender).toHaveBeenCalledTimes(4); }); diff --git a/apps/cli/test/task-update-transcript.test.ts b/apps/cli/test/task-update-transcript.test.ts new file mode 100644 index 00000000..cc38f487 --- /dev/null +++ b/apps/cli/test/task-update-transcript.test.ts @@ -0,0 +1,49 @@ +import { Container, Text } from "@step-harness/pi-tui"; +import { describe, expect, it } from "vitest"; +import { + keepLatestTaskUpdateResult, + registerTaskUpdateCall, + resetTaskUpdateTranscript, +} from "../src/ui/runtime/task-update-transcript.ts"; + +function add(container: Container, label: string): Text { + const component = new Text(label, 0, 0); + container.addChild(component); + return component; +} + +describe("task update transcript projection", () => { + // Regression for GitHub issue #194. + it("keeps later pending updates mounted during session replay", () => { + const container = new Container(); + const first = add(container, "first"); + const second = add(container, "second"); + resetTaskUpdateTranscript(container); + registerTaskUpdateCall(container, "first"); + registerTaskUpdateCall(container, "second"); + + keepLatestTaskUpdateResult(container, "first", first, false); + expect(container.children).toEqual([first, second]); + + keepLatestTaskUpdateResult(container, "second", second, false); + expect(container.children).toEqual([second]); + }); + + // Regression for GitHub issue #194. + it("keeps failed updates visible when a later update succeeds", () => { + const container = new Container(); + const first = add(container, "first"); + const failed = add(container, "failed"); + const latest = add(container, "latest"); + resetTaskUpdateTranscript(container); + registerTaskUpdateCall(container, "first"); + registerTaskUpdateCall(container, "failed"); + registerTaskUpdateCall(container, "latest"); + + keepLatestTaskUpdateResult(container, "first", first, false); + keepLatestTaskUpdateResult(container, "failed", failed, true); + keepLatestTaskUpdateResult(container, "latest", latest, false); + + expect(container.children).toEqual([failed, latest]); + }); +}); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index ff3fd82a..d1ccdaa9 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -849,7 +849,10 @@ export class Editor implements Component, Focusable { if (kb.matches(data, "tui.editor.cursorUp")) { if ( this.isOnFirstVisualLine() && - (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) + (this.isEditorEmpty() || + this.historyIndex > -1 || + this.state.lines.length === 1 || + this.state.cursorCol === 0) ) { this.navigateHistory(-1); } else if (this.isOnFirstVisualLine()) { diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 0d5b6456..c4c2c534 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -211,7 +211,7 @@ export const TUI_KEYBINDINGS = { description: "Close transcript search", }, "tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" }, - "tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" }, + "tui.altScreen.bottom": { defaultKeys: ["end", "ctrl+end"], description: "Scroll viewport to bottom" }, } as const satisfies KeybindingDefinitions; export interface KeybindingConflict { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 0862e6cc..9fe02c0d 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -60,6 +60,19 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "second prompt"); }); + it("recalls history immediately from a single-line draft", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("previous prompt"); + editor.setText("draft"); + + editor.handleInput("\x1b[A"); + + assert.strictEqual(editor.getText(), "previous prompt"); + editor.handleInput("\x1b[B"); + assert.strictEqual(editor.getText(), "draft"); + }); + it("cycles through history entries on repeated Up arrow", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); @@ -80,24 +93,15 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "first"); }); - it("jumps to start before entering history from a non-empty draft", () => { + it("uses cursor movement instead of history within a multiline draft", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); editor.addToHistory("prompt"); - editor.setText("draft"); - editor.handleInput("\x1b[D"); - editor.handleInput("\x1b[D"); - - editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing - assert.strictEqual(editor.getText(), "draft"); - assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); - - editor.handleInput("\x1b[A"); // Up at start - shows "prompt" - assert.strictEqual(editor.getText(), "prompt"); + editor.setText("line1\nline2"); - editor.handleInput("\x1b[B"); // Down - restores draft - assert.strictEqual(editor.getText(), "draft"); - assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "line1\nline2"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); }); it("navigates forward through history with Down arrow", () => { diff --git a/packages/tui/test/keybindings.test.ts b/packages/tui/test/keybindings.test.ts index b0c827dd..2d6c60ec 100644 --- a/packages/tui/test/keybindings.test.ts +++ b/packages/tui/test/keybindings.test.ts @@ -43,7 +43,7 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]); - assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end", "ctrl+end"]); }); it("does not evict selector confirm when input submit is rebound", () => { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 0be8fc2f..b39362d3 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -77,12 +77,20 @@ describe("TuiAltScreen", () => { assert.strictEqual(tui.viewportTop, 5); assert.strictEqual(tui.isFollowingOutput, false); + terminal.sendInput("\x1b[1;5F"); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 7", "line 8", "line 9", "line 10"], + ); + assert.strictEqual(tui.isFollowingOutput, true); + text.setText(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n")); tui.requestRender(); await terminal.waitForRender(); assert.deepStrictEqual( terminal.getViewport().map((line) => line.trimEnd()), - ["line 6", "line 7", "line 8", "line 9"], + ["line 9", "line 10", "line 11", "line 12"], ); tui.stop(); @@ -561,7 +569,7 @@ describe("TuiAltScreen", () => { } }); - it("routes Ctrl-modified viewport navigation to the focused component", async () => { + it("routes Ctrl-modified viewport navigation to the focused component except Ctrl+End", async () => { const terminal = new VirtualTerminal(20, 6); const tui = new TuiAltScreen(terminal); const transcript = new ScrollView( @@ -590,16 +598,21 @@ describe("TuiAltScreen", () => { assert.strictEqual(transcript.scrollTop, 0); assert.deepStrictEqual(editorInputs, []); - const modifiedInputs = ["\x1b[1;5H", "\x1b[1;5F", "\x1b[5;5~", "\x1b[6;5~", "\x1b[57423;5u"]; + const modifiedInputs = ["\x1b[1;5H", "\x1b[5;5~", "\x1b[6;5~", "\x1b[57423;5u"]; for (const input of modifiedInputs) terminal.sendInput(input); terminal.sendInput("\x1b[57423;5:3u"); await terminal.waitForRender(); assert.strictEqual(transcript.scrollTop, 0); assert.deepStrictEqual(editorInputs, modifiedInputs); + terminal.sendInput("\x1b[1;5F"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 7); + assert.deepStrictEqual(editorInputs, modifiedInputs); + terminal.sendInput("\x1b[6~"); await terminal.waitForRender(); - assert.strictEqual(transcript.scrollTop, 1); + assert.strictEqual(transcript.scrollTop, 7); assert.deepStrictEqual(editorInputs, modifiedInputs); tui.stop();