diff --git a/apps/cli/src/ui/runtime/session-events.ts b/apps/cli/src/ui/runtime/session-events.ts index 74dcb6d..49a248e 100644 --- a/apps/cli/src/ui/runtime/session-events.ts +++ b/apps/cli/src/ui/runtime/session-events.ts @@ -302,10 +302,12 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio keepLatestTaskUpdateResult(ctx.chatContainer, event.toolCallId, component, event.isError); ctx.pendingTools.delete(event.toolCallId); ctx.stepSpinner?.stop(event.toolCallId); - // 工具结束不立刻降级动词:瞬时工具(read ~300ms)的动词如果 - // 立刻换回 Working... 肉眼不可感知。保持最后动作,直到下一 - // 个工具开始 / 模型思考 / 4s tick 自然过渡——刚完成的动作 - // 停留显示不算谎言,看不见才是问题。 + if (ctx.presentation === "step") { + ctx.workingOutputTracker.notifyToolEnded(); + if (ctx.activeStatusIndicator instanceof WorkingStatusIndicator) { + ctx.activeStatusIndicator.refreshVerb(); + } + } ctx.redraw.requestRender(); } break; diff --git a/apps/cli/src/ui/view/chrome/status-indicator.ts b/apps/cli/src/ui/view/chrome/status-indicator.ts index 995324f..bb8f626 100644 --- a/apps/cli/src/ui/view/chrome/status-indicator.ts +++ b/apps/cli/src/ui/view/chrome/status-indicator.ts @@ -14,12 +14,7 @@ export interface WorkingOutputSnapshot { elapsedSeconds: number; outputTokens: number; phase: "thinking" | undefined; - /** - * False from the moment a tool starts until the model emits new prose - * (text delta) or thinking. While false the rotation must NOT demote the - * verb to "Working..." — a short tool's verb would otherwise be visible - * for a random 0-4s slice of the tick phase, i.e. not at all. - */ + /** Whether the working status may fall back from its last tool action. */ idleVerbAllowed: boolean; } @@ -38,11 +33,17 @@ export class WorkingOutputTracker { this.idleVerbAllowed = true; } - /** A tool started: hold its verb until the model produces new output. */ + /** Keep the tool action visible while execution is active. */ notifyToolStarted(): void { this.idleVerbAllowed = false; } + /** Release the completed action without waiting for the next model response. */ + notifyToolEnded(): void { + this.idleVerbAllowed = true; + this.phase = undefined; + } + update(event: AssistantMessageEvent): void { switch (event.type) { case "thinking_delta": @@ -261,9 +262,7 @@ export class WorkingStatusIndicator extends StatusIndicator { this.setMessage(toolVerb); return; } - // 工具已结束但模型还没有新输出:保持当前动词(工具动词黏性), - // 否则瞬时工具的动词只在一个随机 0-4s 的 tick 相位切片里可见。 - // 黏性期间残留的 thinking 相位也不得覆盖(read 常紧跟 thinking 发起)。 + // Tool completion releases this hold even if the next model request is slow. if (!snapshot.idleVerbAllowed) return; if (snapshot.phase === "thinking") { this.setMessage("Thinking..."); diff --git a/apps/cli/test/tool-completion-status.test.ts b/apps/cli/test/tool-completion-status.test.ts new file mode 100644 index 0000000..b3e4b4c --- /dev/null +++ b/apps/cli/test/tool-completion-status.test.ts @@ -0,0 +1,121 @@ +import { initTheme } from "@step-harness/coding-agent"; +import { Container, stripTerminalSequences, type TUI } from "@step-harness/pi-tui"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeContext } from "../src/ui/runtime/context.ts"; +import { handleSessionEvent } from "../src/ui/runtime/session-events.ts"; +import { WorkingOutputTracker, WorkingStatusIndicator } from "../src/ui/view/chrome/status-indicator.ts"; +import { StepToolSpinnerClock } from "../src/ui/view/transcript/step-spinner.ts"; +import { ToolExecutionComponent } from "../src/ui/view/transcript/tool-execution.ts"; + +function createView() { + const ui = { requestRender: vi.fn() } as unknown as TUI; + const spinner = new StepToolSpinnerClock(() => ui.requestRender()); + const tracker = new WorkingOutputTracker(); + const indicator = new WorkingStatusIndicator(ui, "Working...", undefined, "step", tracker, () => + spinner.currentToolName(), + ); + const pendingTools = new Map(); + const chatContainer = new Container(); + const ctx = { + isInitialized: true, + presentation: "step", + footer: { invalidate: vi.fn() }, + pendingTools, + chatContainer, + stepSpinner: spinner, + workingOutputTracker: tracker, + activeStatusIndicator: indicator, + workingVisible: true, + redraw: { requestRender: () => ui.requestRender() }, + } as unknown as RuntimeContext; + return { + pendingTools, + spinner, + tracker, + text: () => stripTerminalSequences(indicator.render(100).join("\n")), + async start(toolName: string, toolCallId: string) { + const args = { path: "fixture.txt", command: "printf done" }; + const component = new ToolExecutionComponent( + toolName, + toolCallId, + args, + { presentation: "step", spinner, showImages: false }, + undefined, + ui, + process.cwd(), + ); + pendingTools.set(toolCallId, component); + chatContainer.addChild(component); + await handleSessionEvent(ctx, { type: "tool_execution_start", toolName, toolCallId, args }); + }, + end(toolName: string, toolCallId: string, isError = false) { + return handleSessionEvent(ctx, { + type: "tool_execution_end", + toolName, + toolCallId, + result: { content: [{ type: "text", text: isError ? "failed" : "done" }] }, + isError, + }); + }, + dispose() { + indicator.dispose(); + spinner.dispose(); + }, + }; +} + +describe("working status after tool completion", () => { + beforeEach(() => { + initTheme("step-blue"); + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + afterEach(() => vi.useRealTimers()); + + it.each([ + ["run_command", "Running...", false], + ["read_file", "Reading...", false], + ["edit_file", "Editing...", false], + ["run_command", "Running...", true], + ["read_file", "Reading...", true], + ["edit_file", "Editing...", true], + ] as const)("stops naming completed %s (%s, error=%s)", async (toolName, verb, isError) => { + const view = createView(); + try { + await view.start(toolName, "call-1"); + expect(view.text()).toContain(verb); + vi.advanceTimersByTime(10); + await view.end(toolName, "call-1", isError); + + expect(view.pendingTools.size).toBe(0); + expect(view.spinner.currentToolName()).toBeUndefined(); + expect(view.text()).toContain("Working..."); + expect(view.text()).not.toContain(verb); + // No further model output arrives during this simulated minute. + vi.advanceTimersByTime(60_000); + expect(view.text()).toContain("Working... (1m"); + expect(view.text()).not.toContain(verb); + } finally { + view.dispose(); + } + }); + + it("names the remaining parallel tool as soon as the newest one completes", async () => { + const view = createView(); + try { + await view.start("run_command", "long-command"); + await view.start("read_file", "short-read"); + expect(view.text()).toContain("Reading..."); + await view.end("read_file", "short-read"); + expect(view.pendingTools.size).toBe(1); + expect(view.text()).toContain("Running..."); + + vi.advanceTimersByTime(60_000); + expect(view.text()).toContain("Running..."); + await view.end("run_command", "long-command"); + expect(view.text()).toContain("Working..."); + } finally { + view.dispose(); + } + }); +});