diff --git a/.changeset/thinking-spinner-label.md b/.changeset/thinking-spinner-label.md new file mode 100644 index 0000000..d6a0a9e --- /dev/null +++ b/.changeset/thinking-spinner-label.md @@ -0,0 +1,20 @@ +--- +"@ai-sdk-tool/tui": patch +"plugsuits": patch +--- + +Route all agent pending states (`Thinking...` / `Working...` / `Executing...`) through the same foreground status spinner slot above the prompt editor, unify their visual language via a shared primitive, and lock every surfaced regression behind fixture tests. + +- TUI: new shared primitive `pending-spinner.ts` exposing `PENDING_SPINNER_FRAMES` (braille `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`), `PENDING_SPINNER_INTERVAL_MS` (80ms), `stylePendingIndicator(frame, message)` (cyan frame + dim message), and `createSpinnerTicker(onFrame, options?)`. `StatusSpinner`, `FooterStatusBar`, and CEA's internal `pi-tui-stream-renderer.ts` tool view all route through it so any future palette / cadence / glyph change lands in one place. Previously tool-pending blocks rendered plain ASCII `- \ | /` frames with no color. +- TUI: `PiTuiStreamState` gains optional `onReasoningStart` / `onReasoningEnd` callbacks. `handleReasoningStart` fires `onReasoningStart`; a new `handleReasoningEnd` handler fires `onReasoningEnd` (previously `reasoning-end` was silently dropped via `IGNORE_PART_TYPES`). `isVisibleStreamPart` now treats `reasoning-start` / `reasoning-delta` / `reasoning-end` as non-visible, so the first-visible-part spinner clear only fires on real text / tool output. +- TUI: the foreground spinner label swaps to `Thinking...` for reasoning spans and restores the caller-provided base label (`Working...`) on `reasoning-end`. When a visible part arrives before the second reasoning span of a turn (post tool-call), `foregroundStatus` has already been cleared, so `onReasoningStart` revives the spinner via `showLoader("Thinking...")` and `onReasoningEnd` tears down only the spinner it revived — ordinary flows that kept the base loader alive restore that label unchanged. +- TUI: `PiTuiStreamState` gains optional `onToolPendingStart` / `onToolPendingEnd` callbacks. `onToolPendingStart` fires from `handleToolCall`; `onToolPendingEnd` fires from `handleToolResult`, `handleToolError`, `handleToolOutputDenied`, and `handleToolApprovalRequest` (approval pauses execution so the spinner must release). `onToolPendingEnd` runs unconditionally — independent of `showToolResults` — so the spinner is always restored even when the tool result itself is not rendered visually. `renderAgentStream` wires these into the same foreground spinner slot (`showLoader("Executing...")` with revive-if-null semantics and a counter so parallel tool calls only restore the base label once every pending call resolves). `Executing...` sits directly above the prompt, identical in placement to `Thinking...`. +- TUI: overlapping reasoning / tool lifecycles are handled safely. `onToolPendingEnd` skips restoring the base label when reasoning is currently active (`reasoningRevivedSpinner === true`), so a tool result arriving mid-reasoning does not overwrite the live `Thinking...` label. +- TUI: `BaseToolCallView` no longer renders any inline pending indicator — the foreground spinner owns the pending affordance. `setPrettyBlock(header, body, options?)` now writes the body text through unchanged regardless of `options.isPending`, so consumers can pass a non-empty body alongside `isPending: true` (e.g. `edit_file`'s live diff preview) and have it remain visible while the tool runs. All of the old pretty-block spinner plumbing (`startPendingSpinner`, `stopPendingSpinner`, `paintPendingBodyFrame`, `pendingSpinnerTicker`, `pendingTemplate`, `lastPendingFrame`, `PENDING_MESSAGE`, `PENDING_MARKER`) is removed. +- TUI: fix the 2-blank-line gap above the foreground spinner during pretty-block pending state. `ensurePrettyBlockComponents` used to add a standalone `new Spacer(1)` between `readHeader` and `readBody`, which always emitted `[""]` even when `readBody` rendered to `[]` (empty-text short-circuit). Combined with `StatusSpinner.render()`'s own leading `""`, callers saw two blank rows above `Executing...`. The explicit `Spacer(1)` is removed; `BackgroundBody.render()` now prepends its own leading `""` only when the body has content, which preserves the one-blank separator between header and body in non-pending mode and eliminates the stray blank in pending mode. `Executing...` now sits with a single leading blank line, identical to `Thinking...` / `Working...`. +- CEA: the internal `pi-tui-stream-renderer.ts` pending spinner is kept in sync via the shared primitive. `setPrettyBlock` is simplified to always write the body text through (pending branch deleted); the dead `startPendingSpinner` / `stopPendingSpinner` / `paintPendingFrame` / `TOOL_PENDING_SPINNER_FRAMES` / `TOOL_PENDING_MESSAGE` / `TOOL_PENDING_MARKER` / `SpinnerTicker` state is removed. `renderPendingOutput()` now returns `""` so pretty-rendered tools show the header only while pending (no leftover marker text). The companion `preserves requestRender this-context for pending spinner updates` test is removed (its machinery no longer exists); five other tests that locked in the old in-block `Executing...` painting are inverted to assert the absence of that text. +- TUI: comprehensive regression fixture tests lock every bug surfaced during this PR: + - `pending-spinner.test.ts` (new) — `PENDING_SPINNER_FRAMES`, `PENDING_SPINNER_INTERVAL_MS`, the exact `stylePendingIndicator` ANSI byte sequence, `createSpinnerTicker` lifecycle (initial frame emission, 80ms cadence, frame wraparound, `stop()` idempotency, `emitInitialFrame: false`, custom `intervalMs`). + - `stream-handlers.test.ts` (extended) — parametric proof that all three reasoning parts are invisible under any flag combination (the invariant that keeps `Thinking...` alive); `IGNORE_PART_TYPES` does not contain `reasoning-start` / `reasoning-end`; `STREAM_HANDLERS` has a handler for every known part type; reasoning / tool lifecycle callbacks dispatch correctly; parallel-tool-call counter semantics; `onToolPendingEnd` fires on approval-gate transition. + - `tool-call-view.test.ts` (extended) — inline `toMatchInlineSnapshot` fixtures for pretty-block pending (header-only, no trailing blank) and non-pending (header / blank / body) render shapes. Assertions that `BaseToolCallView` never emits `Executing` or any braille spinner glyph. Raw fallback trailing-blank-free lock. + - `spinner-layout.test.ts` (new) — end-to-end layout invariant: tool block + foreground spinner has exactly one blank line between them across raw fallback, pretty-block pending, and pretty-block non-pending modes. Parametric trailing-blank-free assertions across all four rendering modes. diff --git a/packages/cea/src/interaction/pi-tui-stream-renderer.test.ts b/packages/cea/src/interaction/pi-tui-stream-renderer.test.ts index 68f05bd..e10784b 100644 --- a/packages/cea/src/interaction/pi-tui-stream-renderer.test.ts +++ b/packages/cea/src/interaction/pi-tui-stream-renderer.test.ts @@ -10,7 +10,7 @@ import { type TestStreamPart = TextStreamPart; const LARGE_BLANK_GAP_REGEX = /\n[ \t]*\n[ \t]*\n[ \t]*\n/; -const EXECUTING_SPINNER_TEXT_REGEX = /[-\\|/] Executing\.\./; +const EXECUTING_SPINNER_TEXT_REGEX = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏].*Executing\.\.\./; const tagGrepLine = ( path: string, lineNumber: number, @@ -686,8 +686,8 @@ describe("renderFullStreamWithPiTui", () => { ]); expect(output).toContain("Read src/streamed.ts"); - expect(output).toContain("Executing.."); - expect(EXECUTING_SPINNER_TEXT_REGEX.test(output)).toBe(true); + expect(output).not.toContain("Executing..."); + expect(EXECUTING_SPINNER_TEXT_REGEX.test(output)).toBe(false); expect(output).not.toContain("\x1b[100m"); expect(output).not.toContain("Tool read_file"); }); @@ -712,56 +712,11 @@ describe("renderFullStreamWithPiTui", () => { ]); expect(output).toContain("Read src/no-flicker.ts"); - expect(output).toContain("Executing.."); + expect(output).not.toContain("Executing..."); expect(output).not.toContain("Tool read_file"); expect(snapshots.some((frame) => frame.includes("{}"))).toBe(false); }); - it("preserves requestRender this-context for pending spinner updates", async () => { - const chatContainer = new Container(); - const renderTracker = { - renderCalls: 0, - doRender() { - this.renderCalls += 1; - }, - requestRender() { - this.doRender(); - }, - }; - - async function* stream(): AsyncIterable { - yield { - type: "tool-input-start", - id: "call_bound_request_render", - toolName: "read_file", - }; - yield { - type: "tool-input-delta", - id: "call_bound_request_render", - delta: '{"path":"src/bound-context.ts"', - }; - await new Promise((resolve) => setTimeout(resolve, 180)); - } - - await renderFullStreamWithPiTui(stream(), { - chatContainer, - markdownTheme, - ui: renderTracker, - showReasoning: true, - showSteps: false, - showFinishReason: false, - showToolResults: true, - showRawToolIo: false, - showSources: false, - showFiles: false, - }); - - const output = chatContainer.render(120).join("\n"); - expect(output).toContain("Read src/bound-context.ts"); - expect(output).toContain("Executing.."); - expect(renderTracker.renderCalls).toBeGreaterThan(2); - }); - it("streams shell_execute pretty header from partial tool-input-delta", async () => { const { output } = await renderParts([ { @@ -777,8 +732,8 @@ describe("renderFullStreamWithPiTui", () => { ]); expect(output).toContain("Shell echo streamed"); - expect(output).toContain("Executing.."); - expect(EXECUTING_SPINNER_TEXT_REGEX.test(output)).toBe(true); + expect(output).not.toContain("Executing..."); + expect(EXECUTING_SPINNER_TEXT_REGEX.test(output)).toBe(false); expect(output).not.toContain("Tool shell_execute"); }); @@ -797,7 +752,7 @@ describe("renderFullStreamWithPiTui", () => { ]); expect(output).toContain("Read src/late-name.ts"); - expect(output).toContain("Executing.."); + expect(output).not.toContain("Executing..."); expect(output).not.toContain("Tool tool"); }); diff --git a/packages/cea/src/interaction/pi-tui-stream-renderer.ts b/packages/cea/src/interaction/pi-tui-stream-renderer.ts index 58ef4a5..93b7fa4 100644 --- a/packages/cea/src/interaction/pi-tui-stream-renderer.ts +++ b/packages/cea/src/interaction/pi-tui-stream-renderer.ts @@ -106,9 +106,6 @@ const TAB_PATTERN = /\t/g; const MAX_READ_PREVIEW_LINES = 10; const MAX_WRITE_FILE_PREVIEW_LINES = 200; const MAX_WRITE_FILE_PREVIEW_CHARS = 100_000; -const TOOL_PENDING_MESSAGE = "Executing.."; -const TOOL_PENDING_MARKER = "__tool_pending_status__"; -const TOOL_PENDING_SPINNER_FRAMES = ["-", "\\", "|", "/"] as const; const HASHLINE_TAG_ONLY_PATTERN = /^(.*\d+#[ZPMQVRWSNKTXJBYH]{2})\s*$/; const HASHLINE_PIPE_ONLY_PATTERN = /^\|\s*(.*)$/; const HASHLINE_TAG_PIPE_ONLY_PATTERN = @@ -617,13 +614,7 @@ const renderGrepOutput = (output: string): GrepRenderPayload | null => { }; }; -const renderPendingOutput = (): string => TOOL_PENDING_MARKER; - -const buildPendingSpinnerText = (frame: string): string => - `${frame} ${TOOL_PENDING_MESSAGE}`; - -const renderToolOutput = (_toolName: string, output: unknown): string => - renderCodeBlock("text", output); +const renderPendingOutput = (): string => ""; const ANSI_RESET = "\x1b[0m"; const ANSI_DIM = "\x1b[2m"; @@ -631,6 +622,9 @@ const ANSI_ITALIC = "\x1b[3m"; const ANSI_GRAY = "\x1b[90m"; const ANSI_BG_GRAY = "\x1b[100m"; const ANSI_BG_DARK_RED = "\x1b[48;5;88m"; + +const renderToolOutput = (_toolName: string, output: unknown): string => + renderCodeBlock("text", output); const LEADING_NEWLINES = /^\n+/; const TRAILING_NEWLINES = /\n+$/; @@ -984,16 +978,12 @@ class ToolCallView extends Container { private readonly readBlock: Container; private readonly readBody: TruncatedReadBody; private readonly readHeader: TrimmedMarkdown; - private readonly requestRender: () => void; private readonly showRawToolIo: boolean; private error: unknown; private finalInput: unknown; private inputBuffer = ""; private output: unknown; private outputDenied = false; - private pendingSpinnerFrameIndex = 0; - private pendingSpinnerInterval: ReturnType | null = null; - private pendingTemplate: string | null = null; private parsedInput: unknown; private readMode = false; private toolName: string; @@ -1002,13 +992,12 @@ class ToolCallView extends Container { callId: string, toolName: string, markdownTheme: MarkdownTheme, - requestRender: () => void, + _requestRender: () => void, showRawToolIo: boolean ) { super(); this.callId = callId; this.toolName = toolName; - this.requestRender = requestRender; this.showRawToolIo = showRawToolIo; this.content = new TrimmedMarkdown("", 1, 0, markdownTheme); this.readHeader = new TrimmedMarkdown("", 1, 0, markdownTheme); @@ -1022,7 +1011,7 @@ class ToolCallView extends Container { } dispose(): void { - this.stopPendingSpinner(); + return; } private setReadMode(enabled: boolean): void { @@ -1035,47 +1024,6 @@ class ToolCallView extends Container { this.addChild(enabled ? this.readBlock : this.content); } - private stopPendingSpinner(): void { - this.pendingTemplate = null; - if (!this.pendingSpinnerInterval) { - return; - } - clearInterval(this.pendingSpinnerInterval); - this.pendingSpinnerInterval = null; - } - - private applyPendingSpinnerFrame(): void { - if (!this.pendingTemplate) { - return; - } - - const frame = TOOL_PENDING_SPINNER_FRAMES[this.pendingSpinnerFrameIndex]; - this.readBody.setText( - this.pendingTemplate.replaceAll( - TOOL_PENDING_MARKER, - buildPendingSpinnerText(frame) - ) - ); - } - - private startPendingSpinner(template: string): void { - this.pendingTemplate = template; - this.pendingSpinnerFrameIndex = 0; - this.applyPendingSpinnerFrame(); - - if (this.pendingSpinnerInterval) { - return; - } - - this.pendingSpinnerInterval = setInterval(() => { - this.pendingSpinnerFrameIndex = - (this.pendingSpinnerFrameIndex + 1) % - TOOL_PENDING_SPINNER_FRAMES.length; - this.applyPendingSpinnerFrame(); - this.requestRender(); - }, 80); - } - async appendInputChunk(chunk: string): Promise { this.inputBuffer += chunk; @@ -1168,13 +1116,6 @@ class ToolCallView extends Container { this.setReadMode(true); this.readBody.setBackgroundEnabled(options?.useBackground ?? true); this.readHeader.setText(header); - - if (options?.isPending) { - this.startPendingSpinner(body); - return; - } - - this.stopPendingSpinner(); this.readBody.setText(body); } @@ -1678,8 +1619,6 @@ class ToolCallView extends Container { return; } - this.stopPendingSpinner(); - if (this.shouldSuppressRawFallback()) { return; } diff --git a/packages/tui/src/agent-tui.ts b/packages/tui/src/agent-tui.ts index 97dd094..6628ea5 100644 --- a/packages/tui/src/agent-tui.ts +++ b/packages/tui/src/agent-tui.ts @@ -43,6 +43,8 @@ import { } from "@mariozechner/pi-tui"; import { createAliasAwareAutocompleteProvider } from "./autocomplete"; import { buildTuiCommandSet } from "./command-set"; +import { createSpinnerTicker, type SpinnerTicker } from "./pending-spinner"; +import { createSpinnerOrchestrator } from "./spinner-orchestrator"; import { addChatComponent, createInfoMessage, @@ -110,12 +112,11 @@ const ignore = (): void => { }; class StatusSpinner extends Text { - private readonly frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - private currentFrame = 0; - private intervalId: NodeJS.Timeout | null = null; private readonly tui: TUI; private readonly spinnerColorFn: (text: string) => string; private readonly messageColorFn: (text: string) => string; + private readonly ticker: SpinnerTicker; + private currentFrame = ""; private message: string; constructor( @@ -129,22 +130,14 @@ class StatusSpinner extends Text { this.spinnerColorFn = spinnerColorFn; this.messageColorFn = messageColorFn; this.message = message; - this.start(); - } - - start(): void { - this.updateDisplay(); - this.intervalId = setInterval(() => { - this.currentFrame = (this.currentFrame + 1) % this.frames.length; + this.ticker = createSpinnerTicker((frame) => { + this.currentFrame = frame; this.updateDisplay(); - }, 80); + }); } stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } + this.ticker.stop(); } setMessage(message: string): void { @@ -157,9 +150,8 @@ class StatusSpinner extends Text { } private updateDisplay(): void { - const frame = this.frames[this.currentFrame]; this.setText( - `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}` + `${this.spinnerColorFn(this.currentFrame)} ${this.messageColorFn(this.message)}` ); this.tui.requestRender(); } @@ -213,9 +205,8 @@ interface FooterStatusEntry { } class FooterStatusBar extends Text { - private readonly frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - private currentFrame = 0; - private intervalId: NodeJS.Timeout | null = null; + private readonly ticker: SpinnerTicker; + private currentFrame = ""; private entries: FooterStatusEntry[] = []; private rightText: string | undefined; private rightTextPressure: @@ -229,7 +220,11 @@ class FooterStatusBar extends Text { constructor(tui: TUI) { super("", 1, 0); this.tui = tui; - this.start(); + this.ticker = createSpinnerTicker((frame) => { + this.currentFrame = frame; + this.invalidate(); + this.tui.requestRender(); + }); } setEntries(entries: FooterStatusEntry[]): void { @@ -249,10 +244,7 @@ class FooterStatusBar extends Text { } stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } + this.ticker.stop(); } render(width: number): string[] { @@ -274,8 +266,7 @@ class FooterStatusBar extends Text { entry: FooterStatusEntry, maxWidth: number ): { plain: string; styled: string } => { - const prefix = - entry.state === "running" ? this.frames[this.currentFrame] : ""; + const prefix = entry.state === "running" ? this.currentFrame : ""; const prefixStyle = entry.state === "running" ? style(ANSI_CYAN, prefix) : ""; const messageStylePrefix = this.resolveEntryStylePrefix(entry.level); @@ -316,14 +307,6 @@ class FooterStatusBar extends Text { return lines; } - private start(): void { - this.intervalId = setInterval(() => { - this.currentFrame = (this.currentFrame + 1) % this.frames.length; - this.invalidate(); - this.tui.requestRender(); - }, 80); - } - private resolvePressureStylePrefix( pressure: "critical" | "elevated" | "normal" | "warning" | undefined ): string { @@ -1192,10 +1175,12 @@ export async function createAgentTUI(config: AgentTUIConfig): Promise { const renderAgentStream = async ( stream: AsyncIterable, flags: PiTuiRenderFlags, - onFirstVisiblePart?: () => void + onFirstVisiblePart?: () => void, + loaderMessage?: string ): Promise => { const activeToolInputs = new Map(); const streamedToolCallIds = new Set(); + const pendingToolCallIds = new Set(); const toolViews = new Map(); let assistantView: AssistantStreamView | null = null; let suppressAssistantLeadingSpacer = false; @@ -1243,15 +1228,34 @@ export async function createAgentTUI(config: AgentTUIConfig): Promise { return view; }; + const baseLoaderMessage = loaderMessage ?? foregroundStatusMessage; + + const orchestrator = createSpinnerOrchestrator( + { + clearStatus, + hasSpinner: () => foregroundStatus !== null, + setMessage: (message) => { + foregroundStatus?.setMessage(message); + }, + showLoader, + }, + baseLoaderMessage + ); + const state: PiTuiStreamState = { flags, activeToolInputs, streamedToolCallIds, + pendingToolCallIds, resetAssistantView, ensureAssistantView, ensureToolView, getToolView: (toolCallId: string) => toolViews.get(toolCallId), chatContainer, + onReasoningStart: orchestrator.onReasoningStart, + onReasoningEnd: orchestrator.onReasoningEnd, + onToolPendingStart: orchestrator.onToolPendingStart, + onToolPendingEnd: orchestrator.onToolPendingEnd, }; try { @@ -1587,7 +1591,8 @@ export async function createAgentTUI(config: AgentTUIConfig): Promise { showSources: false, showFiles: false, }, - clearStreamingLoader + clearStreamingLoader, + "Working..." ); clearStreamingLoader(); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index de4bfc2..d32ff15 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -1,6 +1,7 @@ export * from "./agent-tui"; export * from "./autocomplete"; export * from "./colors"; +export * from "./pending-spinner"; export * from "./spinner"; export * from "./stream-handlers"; export * from "./stream-views"; diff --git a/packages/tui/src/pending-spinner.test.ts b/packages/tui/src/pending-spinner.test.ts new file mode 100644 index 0000000..7e7dc21 --- /dev/null +++ b/packages/tui/src/pending-spinner.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createSpinnerTicker, + PENDING_SPINNER_FRAMES, + PENDING_SPINNER_INTERVAL_MS, + stylePendingIndicator, +} from "./pending-spinner"; + +describe("pending-spinner fixtures", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("locks the canonical braille frame set", () => { + expect([...PENDING_SPINNER_FRAMES]).toEqual([ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", + ]); + }); + + it("locks the frame interval at 80ms", () => { + expect(PENDING_SPINNER_INTERVAL_MS).toBe(80); + }); + + it("locks the indicator ANSI byte sequence (cyan frame + dim message)", () => { + expect(stylePendingIndicator("⠋", "Executing...")).toBe( + "\x1b[36m⠋\x1b[0m \x1b[2mExecuting...\x1b[0m" + ); + expect(stylePendingIndicator("⠙", "Thinking...")).toBe( + "\x1b[36m⠙\x1b[0m \x1b[2mThinking...\x1b[0m" + ); + expect(stylePendingIndicator("⠋", "Working...")).toBe( + "\x1b[36m⠋\x1b[0m \x1b[2mWorking...\x1b[0m" + ); + }); + + it("emits the initial frame synchronously by default", () => { + const frames: string[] = []; + const ticker = createSpinnerTicker((frame) => frames.push(frame)); + + expect(frames).toEqual(["⠋"]); + ticker.stop(); + }); + + it("advances frames on the canonical interval", () => { + const frames: string[] = []; + const ticker = createSpinnerTicker((frame) => frames.push(frame)); + + vi.advanceTimersByTime(PENDING_SPINNER_INTERVAL_MS); + vi.advanceTimersByTime(PENDING_SPINNER_INTERVAL_MS); + vi.advanceTimersByTime(PENDING_SPINNER_INTERVAL_MS); + + expect(frames).toEqual(["⠋", "⠙", "⠹", "⠸"]); + ticker.stop(); + }); + + it("wraps around after the final frame", () => { + const frames: string[] = []; + const ticker = createSpinnerTicker((frame) => frames.push(frame)); + + for (const _ of PENDING_SPINNER_FRAMES) { + vi.advanceTimersByTime(PENDING_SPINNER_INTERVAL_MS); + } + + expect(frames).toEqual([...PENDING_SPINNER_FRAMES, "⠋"]); + ticker.stop(); + }); + + it("stop() halts further frame emission", () => { + const frames: string[] = []; + const ticker = createSpinnerTicker((frame) => frames.push(frame)); + + vi.advanceTimersByTime(PENDING_SPINNER_INTERVAL_MS); + ticker.stop(); + vi.advanceTimersByTime(PENDING_SPINNER_INTERVAL_MS * 5); + + expect(frames).toEqual(["⠋", "⠙"]); + }); + + it("stop() is idempotent", () => { + const frames: string[] = []; + const ticker = createSpinnerTicker((frame) => frames.push(frame)); + ticker.stop(); + expect(() => ticker.stop()).not.toThrow(); + vi.advanceTimersByTime(PENDING_SPINNER_INTERVAL_MS * 3); + expect(frames).toEqual(["⠋"]); + }); + + it("emitInitialFrame: false skips the synchronous first call", () => { + const frames: string[] = []; + const ticker = createSpinnerTicker((frame) => frames.push(frame), { + emitInitialFrame: false, + }); + + expect(frames).toEqual([]); + vi.advanceTimersByTime(PENDING_SPINNER_INTERVAL_MS); + expect(frames).toEqual(["⠙"]); + ticker.stop(); + }); + + it("respects a custom intervalMs", () => { + const frames: string[] = []; + const ticker = createSpinnerTicker((frame) => frames.push(frame), { + intervalMs: 200, + }); + + vi.advanceTimersByTime(80); + expect(frames).toEqual(["⠋"]); + vi.advanceTimersByTime(120); + expect(frames).toEqual(["⠋", "⠙"]); + ticker.stop(); + }); +}); diff --git a/packages/tui/src/pending-spinner.ts b/packages/tui/src/pending-spinner.ts new file mode 100644 index 0000000..3eefc95 --- /dev/null +++ b/packages/tui/src/pending-spinner.ts @@ -0,0 +1,64 @@ +const ANSI_RESET = "\x1b[0m"; +const ANSI_DIM = "\x1b[2m"; +const ANSI_CYAN = "\x1b[36m"; + +export const PENDING_SPINNER_FRAMES = [ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", +] as const; + +export const PENDING_SPINNER_INTERVAL_MS = 80; + +export const stylePendingIndicator = (frame: string, message: string): string => + `${ANSI_CYAN}${frame}${ANSI_RESET} ${ANSI_DIM}${message}${ANSI_RESET}`; + +export interface SpinnerTicker { + stop(): void; +} + +export interface SpinnerTickerOptions { + emitInitialFrame?: boolean; + intervalMs?: number; +} + +export const createSpinnerTicker = ( + onFrame: (frame: string) => void, + options: SpinnerTickerOptions = {} +): SpinnerTicker => { + const intervalMs = options.intervalMs ?? PENDING_SPINNER_INTERVAL_MS; + const emitInitialFrame = options.emitInitialFrame ?? true; + let frameIndex = 0; + + const emit = (): void => { + onFrame(PENDING_SPINNER_FRAMES[frameIndex]); + }; + + if (emitInitialFrame) { + emit(); + } + + let intervalHandle: ReturnType | null = setInterval( + () => { + frameIndex = (frameIndex + 1) % PENDING_SPINNER_FRAMES.length; + emit(); + }, + intervalMs + ); + + return { + stop(): void { + if (intervalHandle !== null) { + clearInterval(intervalHandle); + intervalHandle = null; + } + }, + }; +}; diff --git a/packages/tui/src/spinner-layout.test.ts b/packages/tui/src/spinner-layout.test.ts new file mode 100644 index 0000000..c4933a6 --- /dev/null +++ b/packages/tui/src/spinner-layout.test.ts @@ -0,0 +1,197 @@ +import { Container } from "@mariozechner/pi-tui"; +import { describe, expect, it } from "vitest"; +import { stylePendingIndicator } from "./pending-spinner"; +import { BaseToolCallView } from "./tool-call-view"; + +const markdownTheme = { + heading: (t: string) => t, + link: (t: string) => t, + linkUrl: (t: string) => t, + code: (t: string) => t, + codeBlock: (t: string) => t, + codeBlockBorder: (t: string) => t, + quote: (t: string) => t, + quoteBorder: (t: string) => t, + hr: (t: string) => t, + listBullet: (t: string) => t, + bold: (t: string) => t, + italic: (t: string) => t, + strikethrough: (t: string) => t, + underline: (t: string) => t, +}; + +const SPINNER_PREPENDED_BLANK = 1; + +const renderSpinnerLayout = (label: string, width: number): string[] => [ + "", + ` ${stylePendingIndicator("⠋", label)} `.padEnd(width, " "), +]; + +const countTrailingBlanks = (lines: string[]): number => { + let n = 0; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim().length === 0) { + n++; + } else { + break; + } + } + return n; +}; + +const countLeadingBlanksBefore = ( + lines: string[], + predicate: (line: string) => boolean +): number => { + const idx = lines.findIndex(predicate); + if (idx <= 0) { + return 0; + } + let n = 0; + for (let i = idx - 1; i >= 0; i--) { + if (lines[i].trim().length === 0) { + n++; + } else { + break; + } + } + return n; +}; + +describe("Screen layout: blank line count between tool block and foreground spinner", () => { + // Regression: user reported "공백 2개" between tool block and Executing... + // The foreground spinner prepends exactly 1 blank line via + // StatusSpinner.render() → ["", ...super.render(width)]. Any additional + // blank coming from chatContainer's last child pushes the spinner down. + it("pretty-block pending: exactly 1 blank line above the spinner", () => { + const view = new BaseToolCallView( + "call_layout_pending", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + view.setPrettyBlock("**Shell** `ls`", "", { + isPending: true, + useBackground: false, + }); + + const chat = new Container(); + chat.addChild(view); + + const chatLines = chat.render(80); + const spinnerLines = renderSpinnerLayout("Executing...", 80); + const combined = [...chatLines, ...spinnerLines]; + + const blanksAboveSpinner = countLeadingBlanksBefore(combined, (line) => + line.includes("Executing") + ); + expect(blanksAboveSpinner).toBe(SPINNER_PREPENDED_BLANK); + + view.dispose(); + }); + + it("pretty-block non-pending (with output): exactly 1 blank above the spinner", () => { + const view = new BaseToolCallView( + "call_layout_result", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + view.setOutput("a\nb"); + view.setPrettyBlock("**Shell** `ls`", "a\nb", { useBackground: false }); + + const chat = new Container(); + chat.addChild(view); + + const chatLines = chat.render(80); + const spinnerLines = renderSpinnerLayout("Working...", 80); + const combined = [...chatLines, ...spinnerLines]; + + const blanksAboveSpinner = countLeadingBlanksBefore(combined, (line) => + line.includes("Working") + ); + expect(blanksAboveSpinner).toBe(SPINNER_PREPENDED_BLANK); + + view.dispose(); + }); + + it("raw fallback tool block: exactly 1 blank above the spinner", () => { + const view = new BaseToolCallView( + "call_layout_raw", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + + const chat = new Container(); + chat.addChild(view); + + const chatLines = chat.render(120); + const spinnerLines = renderSpinnerLayout("Executing...", 120); + const combined = [...chatLines, ...spinnerLines]; + + const blanksAboveSpinner = countLeadingBlanksBefore(combined, (line) => + line.includes("Executing") + ); + expect(blanksAboveSpinner).toBe(SPINNER_PREPENDED_BLANK); + + view.dispose(); + }); +}); + +describe("Chat container trailing shape (so the spinner only adds its own blank)", () => { + // Regression: BaseToolCallView.render() must NEVER emit a trailing blank + // line. Any trailing blank would combine with StatusSpinner's own leading + // blank and show as 2+ blank lines above the spinner. + it.each([ + { + name: "raw fallback with input only", + build: (view: BaseToolCallView) => { + view.setFinalInput({ command: "ls" }); + }, + }, + { + name: "raw fallback with input and output", + build: (view: BaseToolCallView) => { + view.setFinalInput({ command: "ls" }); + view.setOutput("a\nb"); + }, + }, + { + name: "pretty-block pending (empty body)", + build: (view: BaseToolCallView) => { + view.setFinalInput({ command: "ls" }); + view.setPrettyBlock("**Shell** `ls`", "", { + isPending: true, + useBackground: false, + }); + }, + }, + { + name: "pretty-block non-pending (with body)", + build: (view: BaseToolCallView) => { + view.setFinalInput({ command: "ls" }); + view.setOutput("a\nb"); + view.setPrettyBlock("**Shell** `ls`", "a\nb", { + useBackground: false, + }); + }, + }, + ])("$name leaves no trailing blank", ({ build }) => { + const view = new BaseToolCallView( + "call", + "shell_execute", + markdownTheme, + () => undefined + ); + build(view); + + const lines = view.render(120); + expect(countTrailingBlanks(lines)).toBe(0); + + view.dispose(); + }); +}); diff --git a/packages/tui/src/spinner-orchestrator.test.ts b/packages/tui/src/spinner-orchestrator.test.ts new file mode 100644 index 0000000..8e5ea0e --- /dev/null +++ b/packages/tui/src/spinner-orchestrator.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSpinnerOrchestrator, + type SpinnerOrchestratorAdapter, +} from "./spinner-orchestrator"; + +const createAdapter = (initialHasSpinner = true) => { + let hasSpinner = initialHasSpinner; + let currentMessage: string | null = initialHasSpinner ? "Working..." : null; + const events: Array< + | { type: "clearStatus" } + | { type: "setMessage"; message: string } + | { type: "showLoader"; message: string } + > = []; + + const adapter: SpinnerOrchestratorAdapter = { + clearStatus: () => { + hasSpinner = false; + currentMessage = null; + events.push({ type: "clearStatus" }); + }, + hasSpinner: () => hasSpinner, + setMessage: (message) => { + currentMessage = message; + events.push({ type: "setMessage", message }); + }, + showLoader: (message) => { + hasSpinner = true; + currentMessage = message; + events.push({ type: "showLoader", message }); + }, + }; + + return { + adapter, + events, + state: () => ({ hasSpinner, currentMessage }), + }; +}; + +describe("createSpinnerOrchestrator", () => { + describe("reasoning lifecycle", () => { + it("swaps label to Thinking... when spinner already exists", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onReasoningStart(); + expect(h.state()).toEqual({ + hasSpinner: true, + currentMessage: "Thinking...", + }); + + orch.onReasoningEnd(); + expect(h.state()).toEqual({ + hasSpinner: true, + currentMessage: "Working...", + }); + }); + + it("revives spinner via showLoader when spinner was cleared", () => { + const h = createAdapter(false); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onReasoningStart(); + expect(h.events).toContainEqual({ + type: "showLoader", + message: "Thinking...", + }); + + orch.onReasoningEnd(); + expect(h.events).toContainEqual({ type: "clearStatus" }); + expect(h.state().hasSpinner).toBe(false); + }); + }); + + describe("tool lifecycle", () => { + it("swaps label to Executing... when spinner already exists", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onToolPendingStart(); + expect(h.state().currentMessage).toBe("Executing..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Working..."); + }); + + it("revives spinner via showLoader when spinner was cleared", () => { + const h = createAdapter(false); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onToolPendingStart(); + expect(h.events).toContainEqual({ + type: "showLoader", + message: "Executing...", + }); + + orch.onToolPendingEnd(); + expect(h.state().hasSpinner).toBe(false); + }); + + it("keeps Executing... until all parallel tool calls finish", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onToolPendingStart(); + orch.onToolPendingStart(); + expect(h.state().currentMessage).toBe("Executing..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Executing..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Working..."); + }); + + it("counter never goes negative", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onToolPendingEnd(); + orch.onToolPendingEnd(); + orch.onToolPendingStart(); + expect(h.state().currentMessage).toBe("Executing..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Working..."); + }); + }); + + describe("reasoning + tool overlap (regression lock)", () => { + // Regression: onToolPendingStart used to overwrite Thinking... with + // Executing... when a tool started during an active reasoning span. + it("tool starting during reasoning does not overwrite Thinking...", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onReasoningStart(); + expect(h.state().currentMessage).toBe("Thinking..."); + + orch.onToolPendingStart(); + expect(h.state().currentMessage).toBe("Thinking..."); + }); + + // Regression: onToolPendingEnd used to restore Working... while + // reasoning was still active, clobbering the Thinking... label. + it("tool ending during reasoning does not overwrite Thinking...", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onToolPendingStart(); + orch.onReasoningStart(); + expect(h.state().currentMessage).toBe("Thinking..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Thinking..."); + + orch.onReasoningEnd(); + expect(h.state().currentMessage).toBe("Working..."); + }); + + // Regression: onReasoningEnd restored Working... even when tool calls + // were still pending, hiding active tool execution. + it("reasoning ending while tool is still pending transitions to Executing...", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onReasoningStart(); + orch.onToolPendingStart(); + expect(h.state().currentMessage).toBe("Thinking..."); + + orch.onReasoningEnd(); + expect(h.state().currentMessage).toBe("Executing..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Working..."); + }); + + it("reasoning ending with multiple pending tools keeps Executing... until all resolve", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onReasoningStart(); + orch.onToolPendingStart(); + orch.onToolPendingStart(); + orch.onReasoningEnd(); + expect(h.state().currentMessage).toBe("Executing..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Executing..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Working..."); + }); + + // Regression: spinner ownership transfer when reasoning revived a + // cleared spinner and then ended while a tool was still pending. + it("reasoning-revived spinner becomes tool-owned when reasoning ends mid-tool", () => { + const h = createAdapter(false); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onReasoningStart(); + expect(h.events).toContainEqual({ + type: "showLoader", + message: "Thinking...", + }); + + orch.onToolPendingStart(); + expect(h.state().currentMessage).toBe("Thinking..."); + + orch.onReasoningEnd(); + expect(h.state().currentMessage).toBe("Executing..."); + + orch.onToolPendingEnd(); + expect(h.events).toContainEqual({ type: "clearStatus" }); + expect(h.state().hasSpinner).toBe(false); + }); + + // Regression: tool-revived spinner ownership must transfer to reasoning + // when the tool completes mid-reasoning. Otherwise onReasoningEnd leaves + // the spinner stuck on Working... with no pending work. + it("tool-revived spinner becomes reasoning-owned when tool ends mid-reasoning", () => { + const h = createAdapter(false); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + + orch.onToolPendingStart(); + expect(h.events).toContainEqual({ + type: "showLoader", + message: "Executing...", + }); + + orch.onReasoningStart(); + expect(h.state().currentMessage).toBe("Thinking..."); + + orch.onToolPendingEnd(); + expect(h.state().currentMessage).toBe("Thinking..."); + expect(h.state().hasSpinner).toBe(true); + + orch.onReasoningEnd(); + expect(h.events).toContainEqual({ type: "clearStatus" }); + expect(h.state().hasSpinner).toBe(false); + }); + }); + + describe("missing baseLoaderMessage", () => { + it("leaves the spinner alone when no base label is configured", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, undefined); + + orch.onReasoningStart(); + expect(h.state().currentMessage).toBe("Thinking..."); + + orch.onReasoningEnd(); + expect(h.state().currentMessage).toBe("Thinking..."); + }); + + it("accepts null as well as undefined", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, null); + + orch.onReasoningStart(); + orch.onReasoningEnd(); + expect(h.state().currentMessage).toBe("Thinking..."); + }); + }); + + describe("adapter is called precisely", () => { + it("does not fire any adapter method for counter-only transitions during reasoning", () => { + const h = createAdapter(true); + const orch = createSpinnerOrchestrator(h.adapter, "Working..."); + const setMessage = vi.spyOn(h.adapter, "setMessage"); + const showLoader = vi.spyOn(h.adapter, "showLoader"); + const clearStatus = vi.spyOn(h.adapter, "clearStatus"); + + orch.onReasoningStart(); + setMessage.mockClear(); + showLoader.mockClear(); + clearStatus.mockClear(); + + orch.onToolPendingStart(); + orch.onToolPendingEnd(); + + expect(setMessage).not.toHaveBeenCalled(); + expect(showLoader).not.toHaveBeenCalled(); + expect(clearStatus).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/tui/src/spinner-orchestrator.ts b/packages/tui/src/spinner-orchestrator.ts new file mode 100644 index 0000000..634024d --- /dev/null +++ b/packages/tui/src/spinner-orchestrator.ts @@ -0,0 +1,93 @@ +export interface SpinnerOrchestratorAdapter { + clearStatus: () => void; + hasSpinner: () => boolean; + setMessage: (message: string) => void; + showLoader: (message: string) => void; +} + +export interface SpinnerOrchestrator { + onReasoningEnd: () => void; + onReasoningStart: () => void; + onToolPendingEnd: () => void; + onToolPendingStart: () => void; +} + +export const createSpinnerOrchestrator = ( + adapter: SpinnerOrchestratorAdapter, + baseLoaderMessage: string | null | undefined +): SpinnerOrchestrator => { + let reasoningActive = false; + let reasoningRevivedSpinner = false; + let toolPendingCount = 0; + let toolRevivedSpinner = false; + + const restoreBase = (): void => { + if (baseLoaderMessage) { + adapter.setMessage(baseLoaderMessage); + } + }; + + return { + onReasoningStart: () => { + reasoningActive = true; + if (adapter.hasSpinner()) { + adapter.setMessage("Thinking..."); + } else { + adapter.showLoader("Thinking..."); + reasoningRevivedSpinner = true; + } + }, + onReasoningEnd: () => { + reasoningActive = false; + if (toolPendingCount > 0) { + if (adapter.hasSpinner()) { + adapter.setMessage("Executing..."); + } else { + adapter.showLoader("Executing..."); + } + if (reasoningRevivedSpinner) { + toolRevivedSpinner = true; + reasoningRevivedSpinner = false; + } + return; + } + if (reasoningRevivedSpinner) { + adapter.clearStatus(); + reasoningRevivedSpinner = false; + return; + } + restoreBase(); + }, + onToolPendingStart: () => { + toolPendingCount += 1; + if (reasoningActive) { + return; + } + if (adapter.hasSpinner()) { + adapter.setMessage("Executing..."); + } else { + adapter.showLoader("Executing..."); + toolRevivedSpinner = true; + } + }, + onToolPendingEnd: () => { + toolPendingCount = Math.max(0, toolPendingCount - 1); + if (toolPendingCount > 0) { + return; + } + if (reasoningActive) { + if (toolRevivedSpinner) { + reasoningRevivedSpinner = true; + toolRevivedSpinner = false; + } + return; + } + if (toolRevivedSpinner) { + adapter.clearStatus(); + toolRevivedSpinner = false; + return; + } + restoreBase(); + }, + }; +}; diff --git a/packages/tui/src/stream-handlers.test.ts b/packages/tui/src/stream-handlers.test.ts index 57fe233..8dde512 100644 --- a/packages/tui/src/stream-handlers.test.ts +++ b/packages/tui/src/stream-handlers.test.ts @@ -1,9 +1,16 @@ import { Container } from "@mariozechner/pi-tui"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { handleToolApprovalRequest, + handleToolCall, + handleToolError, + handleToolOutputDenied, + handleToolResult, + IGNORE_PART_TYPES, isVisibleStreamPart, + type PiTuiRenderFlags, type PiTuiStreamState, + STREAM_HANDLERS, } from "./stream-handlers"; import { BaseToolCallView } from "./tool-call-view"; @@ -24,7 +31,10 @@ const markdownTheme = { underline: (text: string) => text, }; -function createState() { +function createState(overrides: Partial = {}): { + chatContainer: Container; + state: PiTuiStreamState; +} { const chatContainer = new Container(); const toolViews = new Map(); @@ -55,8 +65,10 @@ function createState() { showToolResults: true, }, getToolView: (toolCallId) => toolViews.get(toolCallId), + pendingToolCallIds: new Set(), resetAssistantView: () => undefined, streamedToolCallIds: new Set(), + ...overrides, }; return { chatContainer, state }; @@ -96,4 +108,513 @@ describe("stream-handlers", () => { }) ).toBe(true); }); + + // Regression: approval-gated flows (tool-call → tool-approval-request) used + // to strand the pending counter at 1 and leave the foreground spinner stuck + // on "Executing..." while execution was actually paused awaiting approval. + it("fires onToolPendingEnd when a tool enters the approval gate for a tracked call", () => { + const onToolPendingEnd = vi.fn(); + const { state } = createState({ onToolPendingEnd }); + + handleToolCall( + { + type: "tool-call", + toolCallId: "call_approval", + toolName: "shell_execute", + input: { command: "rm -rf /" }, + } as never, + state + ); + handleToolApprovalRequest( + { + type: "tool-approval-request", + toolCallId: "call_approval", + toolName: "shell_execute", + reason: "Needs user approval.", + providerExecuted: false, + } as never, + state + ); + + expect(onToolPendingEnd).toHaveBeenCalledTimes(1); + }); + + // Regression: approval-request events can appear without a matching + // tool-call (e.g. early in streamed tests). An unmatched approval must + // NOT decrement the pending counter, otherwise it can clear the + // Executing... indicator for other tools still executing in parallel. + it("does not fire onToolPendingEnd for an approval-request with no matching tool-call", () => { + const onToolPendingEnd = vi.fn(); + const { state } = createState({ onToolPendingEnd }); + + handleToolApprovalRequest( + { + type: "tool-approval-request", + toolCallId: "untracked", + toolName: "shell_execute", + reason: "ghost", + providerExecuted: false, + } as never, + state + ); + + expect(onToolPendingEnd).not.toHaveBeenCalled(); + }); + + it("approval for unmatched id does not affect another tool's pending counter", () => { + let pending = 0; + const { state } = createState({ + onToolPendingStart: () => { + pending += 1; + }, + onToolPendingEnd: () => { + pending -= 1; + }, + }); + + handleToolCall( + { + type: "tool-call", + toolCallId: "call_A", + toolName: "shell_execute", + input: { command: "ls" }, + } as never, + state + ); + expect(pending).toBe(1); + + handleToolApprovalRequest( + { + type: "tool-approval-request", + toolCallId: "call_UNRELATED", + toolName: "shell_execute", + reason: "ghost approval", + providerExecuted: false, + } as never, + state + ); + expect(pending).toBe(1); + + handleToolResult( + { + type: "tool-result", + toolCallId: "call_A", + toolName: "shell_execute", + output: "ok", + } as never, + state + ); + expect(pending).toBe(0); + }); + + it("tool-call → tool-approval-request leaves the pending counter at zero", () => { + let pending = 0; + const { state } = createState({ + onToolPendingStart: () => { + pending += 1; + }, + onToolPendingEnd: () => { + pending -= 1; + }, + }); + + handleToolCall( + { + type: "tool-call", + toolCallId: "call_1", + toolName: "shell_execute", + input: { command: "rm -rf /" }, + } as never, + state + ); + expect(pending).toBe(1); + + handleToolApprovalRequest( + { + type: "tool-approval-request", + toolCallId: "call_1", + toolName: "shell_execute", + reason: "Destructive command.", + providerExecuted: false, + } as never, + state + ); + expect(pending).toBe(0); + }); + + it("fires onToolPendingStart when a tool-call is dispatched", () => { + const onToolPendingStart = vi.fn(); + const { state } = createState({ onToolPendingStart }); + + handleToolCall( + { + type: "tool-call", + toolCallId: "call_1", + toolName: "shell_execute", + input: { command: "ls" }, + } as never, + state + ); + + expect(onToolPendingStart).toHaveBeenCalledTimes(1); + }); + + const dispatchToolCall = ( + state: PiTuiStreamState, + toolCallId: string + ): void => { + handleToolCall( + { + type: "tool-call", + toolCallId, + toolName: "shell_execute", + input: { command: "ls" }, + } as never, + state + ); + }; + + it("fires onToolPendingEnd when a tool-result arrives for a tracked call", () => { + const onToolPendingEnd = vi.fn(); + const { state } = createState({ onToolPendingEnd }); + + dispatchToolCall(state, "call_1"); + handleToolResult( + { + type: "tool-result", + toolCallId: "call_1", + toolName: "shell_execute", + output: "files", + } as never, + state + ); + + expect(onToolPendingEnd).toHaveBeenCalledTimes(1); + }); + + it("fires onToolPendingEnd when a tool-error arrives for a tracked call", () => { + const onToolPendingEnd = vi.fn(); + const { state } = createState({ onToolPendingEnd }); + + dispatchToolCall(state, "call_1"); + handleToolError( + { + type: "tool-error", + toolCallId: "call_1", + toolName: "shell_execute", + error: new Error("boom"), + } as never, + state + ); + + expect(onToolPendingEnd).toHaveBeenCalledTimes(1); + }); + + it("fires onToolPendingEnd when tool output is denied for a tracked call", () => { + const onToolPendingEnd = vi.fn(); + const { state } = createState({ onToolPendingEnd }); + + dispatchToolCall(state, "call_1"); + handleToolOutputDenied( + { + type: "tool-output-denied", + toolCallId: "call_1", + toolName: "shell_execute", + } as never, + state + ); + + expect(onToolPendingEnd).toHaveBeenCalledTimes(1); + }); + + it("fires onToolPendingEnd even when showToolResults flag is disabled", () => { + const onToolPendingEnd = vi.fn(); + const { state } = createState({ + onToolPendingEnd, + flags: { + showFiles: false, + showFinishReason: false, + showRawToolIo: false, + showReasoning: true, + showSources: false, + showSteps: false, + showToolResults: false, + }, + }); + + dispatchToolCall(state, "call_1"); + handleToolResult( + { + type: "tool-result", + toolCallId: "call_1", + toolName: "shell_execute", + output: "files", + } as never, + state + ); + + expect(onToolPendingEnd).toHaveBeenCalledTimes(1); + }); + + // Regression: a second terminal event for the same tool call (e.g. stream + // replay or buggy provider) must NOT double-decrement the pending counter. + it("does not fire onToolPendingEnd twice for the same tool call", () => { + const onToolPendingEnd = vi.fn(); + const { state } = createState({ onToolPendingEnd }); + + dispatchToolCall(state, "call_1"); + handleToolResult( + { + type: "tool-result", + toolCallId: "call_1", + toolName: "shell_execute", + output: "files", + } as never, + state + ); + handleToolResult( + { + type: "tool-result", + toolCallId: "call_1", + toolName: "shell_execute", + output: "files", + } as never, + state + ); + + expect(onToolPendingEnd).toHaveBeenCalledTimes(1); + }); + + // Regression: a second tool-call for the same id must NOT double-increment + // the pending counter (dispatch is idempotent on the id). + it("does not fire onToolPendingStart twice for the same tool call id", () => { + const onToolPendingStart = vi.fn(); + const { state } = createState({ onToolPendingStart }); + + dispatchToolCall(state, "call_1"); + dispatchToolCall(state, "call_1"); + + expect(onToolPendingStart).toHaveBeenCalledTimes(1); + }); +}); + +const FLAGS_ALL_ON: PiTuiRenderFlags = { + showFiles: true, + showFinishReason: true, + showRawToolIo: true, + showReasoning: true, + showSources: true, + showSteps: true, + showToolResults: true, +}; + +const FLAGS_ALL_OFF: PiTuiRenderFlags = { + showFiles: false, + showFinishReason: false, + showRawToolIo: false, + showReasoning: false, + showSources: false, + showSteps: false, + showToolResults: false, +}; + +describe("isVisibleStreamPart — reasoning parts must never trigger first-visible", () => { + // Regression: if reasoning parts become visible, clearStreamingLoader fires + // on reasoning-start and the "Thinking..." spinner label is lost. + it.each([ + "reasoning-start", + "reasoning-delta", + "reasoning-end", + ] as const)("%s is invisible regardless of flags", (type) => { + expect(isVisibleStreamPart({ type } as never, FLAGS_ALL_ON)).toBe(false); + expect(isVisibleStreamPart({ type } as never, FLAGS_ALL_OFF)).toBe(false); + }); + + it("tool-input-end is always invisible", () => { + expect( + isVisibleStreamPart({ type: "tool-input-end" } as never, FLAGS_ALL_ON) + ).toBe(false); + }); + + it("tool-result visibility follows showToolResults flag", () => { + expect( + isVisibleStreamPart({ type: "tool-result" } as never, FLAGS_ALL_ON) + ).toBe(true); + expect( + isVisibleStreamPart({ type: "tool-result" } as never, FLAGS_ALL_OFF) + ).toBe(false); + }); + + it("text-start is always visible (triggers spinner clear)", () => { + expect( + isVisibleStreamPart({ type: "text-start" } as never, FLAGS_ALL_OFF) + ).toBe(true); + }); +}); + +describe("STREAM_HANDLERS dispatch table", () => { + // Regression: reasoning-end previously lived in IGNORE_PART_TYPES, which + // silently dropped onReasoningEnd and left spinner label stuck. + it.each([ + "text-start", + "text-delta", + "reasoning-start", + "reasoning-delta", + "reasoning-end", + "tool-input-start", + "tool-input-delta", + "tool-input-end", + "tool-call", + "tool-result", + "tool-error", + "tool-output-denied", + "tool-approval-request", + "finish-step", + "finish", + "file", + "source", + ] as const)("%s has a dispatch handler", (type) => { + expect(STREAM_HANDLERS[type]).toBeDefined(); + expect(typeof STREAM_HANDLERS[type]).toBe("function"); + }); + + it("reasoning-end is NOT in the ignore set", () => { + expect(IGNORE_PART_TYPES.has("reasoning-end")).toBe(false); + }); + + it("reasoning-start is NOT in the ignore set", () => { + expect(IGNORE_PART_TYPES.has("reasoning-start")).toBe(false); + }); +}); + +describe("Reasoning lifecycle callbacks", () => { + const stubAssistantView = () => { + const view = { appendText: vi.fn(), appendReasoning: vi.fn() }; + return view as never; + }; + + it("handleReasoningStart calls state.onReasoningStart", () => { + const onReasoningStart = vi.fn(); + const onReasoningEnd = vi.fn(); + const { state } = createState({ + onReasoningStart, + onReasoningEnd, + ensureAssistantView: stubAssistantView, + }); + + STREAM_HANDLERS["reasoning-start"]( + { type: "reasoning-start" } as never, + state + ); + + expect(onReasoningStart).toHaveBeenCalledTimes(1); + expect(onReasoningEnd).not.toHaveBeenCalled(); + }); + + it("handleReasoningEnd calls state.onReasoningEnd", () => { + const onReasoningStart = vi.fn(); + const onReasoningEnd = vi.fn(); + const { state } = createState({ + onReasoningStart, + onReasoningEnd, + ensureAssistantView: stubAssistantView, + }); + + STREAM_HANDLERS["reasoning-end"]({ type: "reasoning-end" } as never, state); + + expect(onReasoningEnd).toHaveBeenCalledTimes(1); + expect(onReasoningStart).not.toHaveBeenCalled(); + }); + + it("reasoning-start fires callback even when showReasoning flag is off", () => { + const onReasoningStart = vi.fn(); + const { state } = createState({ + onReasoningStart, + ensureAssistantView: stubAssistantView, + flags: { ...FLAGS_ALL_OFF, showReasoning: false }, + }); + + STREAM_HANDLERS["reasoning-start"]( + { type: "reasoning-start" } as never, + state + ); + + expect(onReasoningStart).toHaveBeenCalledTimes(1); + }); +}); + +describe("Tool pending counter invariants (parallel tool calls)", () => { + // Regression: parallel tool calls must keep the foreground "Executing..." + // spinner alive until ALL pending calls resolve (counter floor at 0). + it("each tool-call fires exactly one onToolPendingStart", () => { + const onToolPendingStart = vi.fn(); + const { state } = createState({ onToolPendingStart }); + + STREAM_HANDLERS["tool-call"]( + { + type: "tool-call", + toolCallId: "p1", + toolName: "a", + input: {}, + } as never, + state + ); + STREAM_HANDLERS["tool-call"]( + { + type: "tool-call", + toolCallId: "p2", + toolName: "b", + input: {}, + } as never, + state + ); + + expect(onToolPendingStart).toHaveBeenCalledTimes(2); + }); + + it("each tracked terminal tool part fires exactly one onToolPendingEnd", () => { + const onToolPendingEnd = vi.fn(); + const { state } = createState({ onToolPendingEnd }); + + for (const id of ["p1", "p2", "p3"]) { + handleToolCall( + { + type: "tool-call", + toolCallId: id, + toolName: "x", + input: {}, + } as never, + state + ); + } + + handleToolResult( + { + type: "tool-result", + toolCallId: "p1", + toolName: "a", + output: "ok", + } as never, + state + ); + handleToolError( + { + type: "tool-error", + toolCallId: "p2", + toolName: "b", + error: new Error("boom"), + } as never, + state + ); + handleToolOutputDenied( + { + type: "tool-output-denied", + toolCallId: "p3", + toolName: "c", + } as never, + state + ); + + expect(onToolPendingEnd).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/tui/src/stream-handlers.ts b/packages/tui/src/stream-handlers.ts index e9e5ce1..3484527 100644 --- a/packages/tui/src/stream-handlers.ts +++ b/packages/tui/src/stream-handlers.ts @@ -103,6 +103,11 @@ export interface PiTuiStreamState { ensureToolView: (toolCallId: string, toolName: string) => ToolCallView; flags: PiTuiRenderFlags; getToolView: (toolCallId: string) => ToolCallView | undefined; + onReasoningEnd?: () => void; + onReasoningStart?: () => void; + onToolPendingEnd?: () => void; + onToolPendingStart?: () => void; + pendingToolCallIds: Set; resetAssistantView: (suppressLeadingSpacer?: boolean) => void; streamedToolCallIds: Set; } @@ -158,6 +163,7 @@ export const handleReasoningStart: StreamPartHandler = (_part, state) => { if (state.flags.showReasoning) { state.ensureAssistantView(); } + state.onReasoningStart?.(); }; export const handleReasoningDelta: StreamPartHandler = (part, state) => { @@ -172,6 +178,10 @@ export const handleReasoningDelta: StreamPartHandler = (part, state) => { state.ensureAssistantView().appendReasoning(reasoningPart.text); }; +export const handleReasoningEnd: StreamPartHandler = (_part, state) => { + state.onReasoningEnd?.(); +}; + export const handleToolInputStart: StreamPartHandler = async (part, state) => { const toolInputStartPart = part as Extract< StreamPart, @@ -232,6 +242,16 @@ export const handleToolInputEnd: StreamPartHandler = (part, state) => { } }; +const firePendingEndIfTracked = ( + state: PiTuiStreamState, + toolCallId: string +): void => { + if (!state.pendingToolCallIds.delete(toolCallId)) { + return; + } + state.onToolPendingEnd?.(); +}; + export const handleToolCall: StreamPartHandler = (part, state) => { const toolCallPart = part as Extract; const inputState = state.activeToolInputs.get(toolCallPart.toolCallId); @@ -252,14 +272,21 @@ export const handleToolCall: StreamPartHandler = (part, state) => { if (!shouldSkipToolCallRender) { view.setToolName(toolCallPart.toolName); } + + if (!state.pendingToolCallIds.has(toolCallPart.toolCallId)) { + state.pendingToolCallIds.add(toolCallPart.toolCallId); + state.onToolPendingStart?.(); + } }; export const handleToolResult: StreamPartHandler = (part, state) => { + const toolResultPart = part as Extract; + firePendingEndIfTracked(state, toolResultPart.toolCallId); + if (!state.flags.showToolResults) { return; } - const toolResultPart = part as Extract; state.resetAssistantView(true); const view = state.ensureToolView( toolResultPart.toolCallId, @@ -270,6 +297,7 @@ export const handleToolResult: StreamPartHandler = (part, state) => { export const handleToolError: StreamPartHandler = (part, state) => { const toolErrorPart = part as Extract; + firePendingEndIfTracked(state, toolErrorPart.toolCallId); state.resetAssistantView(true); const view = state.ensureToolView( toolErrorPart.toolCallId, @@ -283,6 +311,7 @@ export const handleToolOutputDenied: StreamPartHandler = (part, state) => { StreamPart, { type: "tool-output-denied" } >; + firePendingEndIfTracked(state, deniedPart.toolCallId); state.resetAssistantView(true); const view = state.ensureToolView(deniedPart.toolCallId, deniedPart.toolName); view.setOutputDenied(); @@ -296,6 +325,7 @@ export const handleToolApprovalRequest: StreamPartHandler = (part, state) => { toolName: string; }; + firePendingEndIfTracked(state, approvalPart.toolCallId); state.resetAssistantView(true); const view = state.ensureToolView( approvalPart.toolCallId, @@ -386,6 +416,7 @@ export const STREAM_HANDLERS: Record = { "text-delta": handleTextDelta, "reasoning-start": handleReasoningStart, "reasoning-delta": handleReasoningDelta, + "reasoning-end": handleReasoningEnd, "tool-input-start": handleToolInputStart, "tool-input-delta": handleToolInputDelta, "tool-input-end": handleToolInputEnd, @@ -401,12 +432,7 @@ export const STREAM_HANDLERS: Record = { finish: handleFinish, }; -export const IGNORE_PART_TYPES = new Set([ - "abort", - "text-end", - "reasoning-end", - "start", -]); +export const IGNORE_PART_TYPES = new Set(["abort", "text-end", "start"]); export const isVisibleStreamPart = ( part: StreamPart, @@ -415,16 +441,14 @@ export const isVisibleStreamPart = ( switch (part.type) { case "abort": case "text-end": + case "reasoning-start": + case "reasoning-delta": case "reasoning-end": case "start": case "tool-input-end": return false; case "text-start": return true; - case "reasoning-start": - return flags.showReasoning; - case "reasoning-delta": - return flags.showReasoning; case "tool-result": return flags.showToolResults; case "start-step": diff --git a/packages/tui/src/tool-call-view.test.ts b/packages/tui/src/tool-call-view.test.ts new file mode 100644 index 0000000..075f0fe --- /dev/null +++ b/packages/tui/src/tool-call-view.test.ts @@ -0,0 +1,214 @@ +import type { MarkdownTheme } from "@mariozechner/pi-tui"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BaseToolCallView } from "./tool-call-view"; + +const markdownTheme: MarkdownTheme = { + heading: (t) => t, + link: (t) => t, + linkUrl: (t) => t, + code: (t) => t, + codeBlock: (t) => t, + codeBlockBorder: (t) => t, + quote: (t) => t, + quoteBorder: (t) => t, + hr: (t) => t, + listBullet: (t) => t, + bold: (t) => t, + italic: (t) => t, + strikethrough: (t) => t, + underline: (t) => t, +}; + +const BRAILLE_SPINNER_GLYPHS = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/; + +describe("BaseToolCallView rendering", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const renderView = (view: BaseToolCallView): string => + view.render(120).join("\n"); + + it("does not render an inline Executing indicator (moved to the foreground spinner)", () => { + const view = new BaseToolCallView( + "call_1", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls -la" }); + + expect(renderView(view)).not.toContain("Executing..."); + + view.dispose(); + }); + + it("renders tool input without leaving trailing blank lines", () => { + const view = new BaseToolCallView( + "call_2", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + + const lines = view.render(120); + expect(lines.length).toBeGreaterThan(0); + const lastLine = lines.at(-1) ?? ""; + expect(lastLine.trim().length).toBeGreaterThan(0); + + view.dispose(); + }); + + it("renders tool output after it lands", () => { + const view = new BaseToolCallView( + "call_3", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + view.setOutput("file-a\nfile-b\n"); + + const output = renderView(view); + expect(output).toContain("file-a"); + expect(output).toContain("file-b"); + + view.dispose(); + }); + + it("does not append a trailing blank line in pretty-block pending mode", () => { + const view = new BaseToolCallView( + "call_pretty_pending", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + view.setPrettyBlock("**Shell** `ls`", "", { + isPending: true, + useBackground: false, + }); + + const lines = view.render(80); + expect(lines.length).toBeGreaterThan(0); + const lastLine = lines.at(-1) ?? ""; + expect(lastLine.trim().length).toBeGreaterThan(0); + + view.dispose(); + }); + + it("keeps one blank line between header and body in pretty-block non-pending mode", () => { + const view = new BaseToolCallView( + "call_pretty_full", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + view.setOutput("a\nb"); + view.setPrettyBlock("**Shell** `ls`", "a\nb", { useBackground: false }); + + const lines = view.render(80); + const bodyFirstIdx = lines.findIndex((line) => line.includes("a")); + expect(bodyFirstIdx).toBeGreaterThan(0); + const lineBeforeBody = lines[bodyFirstIdx - 1] ?? ""; + expect(lineBeforeBody.trim().length).toBe(0); + + view.dispose(); + }); +}); + +const collapseBlankLines = (lines: string[]): string[] => + lines.map((line) => (line.trim().length === 0 ? "" : line.trimEnd())); + +describe("BaseToolCallView render shape fixtures", () => { + // Regression: pretty-block pending used to paint an internal "Executing..." + // spinner into readBody, which competed with the foreground spinner. + it("pretty-block pending mode renders only the header — no body, no trailing blank, no Executing", () => { + const view = new BaseToolCallView( + "call_px_pending", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + view.setPrettyBlock("**Shell** `ls`", "", { + isPending: true, + useBackground: false, + }); + + const lines = collapseBlankLines(view.render(80)); + + expect(lines).toMatchInlineSnapshot(` + [ + " Shell ls", + ] + `); + expect(lines.join("\n")).not.toContain("Executing"); + }); + + // Regression: ensurePrettyBlockComponents used to keep a standalone Spacer(1) + // between header and body, which emitted [""] even when the body was empty, + // producing a ghost blank line above the foreground spinner. + it("pretty-block non-pending mode renders [header, blank, body] — no trailing blank", () => { + const view = new BaseToolCallView( + "call_px_full", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + view.setOutput("a\nb\nc"); + view.setPrettyBlock("**Shell** `ls`", "a\nb\nc", { useBackground: false }); + + const lines = collapseBlankLines(view.render(80)); + + expect(lines).toMatchInlineSnapshot(` + [ + " Shell ls", + "", + " a", + " b", + " c", + ] + `); + }); + + // Regression: BaseToolCallView used to render an inline "Executing..." Text + // child, which sat inside chatContainer far from the editor and clashed + // with the idle placeholder's two blank lines. + it("raw fallback path never emits 'Executing' — pending affordance lives in foreground spinner", () => { + const view = new BaseToolCallView( + "call_raw_pending", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls -la" }); + + const output = view.render(120).join("\n"); + expect(output).not.toContain("Executing"); + expect(output).not.toMatch(BRAILLE_SPINNER_GLYPHS); + }); + + // Regression: raw fallback used to carry a trailing blank line from + // Markdown's `space` token, making the next container start with a gap. + it("raw fallback path has no trailing blank line", () => { + const view = new BaseToolCallView( + "call_raw_noblank", + "shell_execute", + markdownTheme, + () => undefined + ); + view.setFinalInput({ command: "ls" }); + + const lines = view.render(120); + const lastLine = lines.at(-1) ?? ""; + expect(lastLine.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/tui/src/tool-call-view.ts b/packages/tui/src/tool-call-view.ts index 6ab9839..9070b31 100644 --- a/packages/tui/src/tool-call-view.ts +++ b/packages/tui/src/tool-call-view.ts @@ -2,7 +2,6 @@ import { Container, Markdown, type MarkdownTheme, - Spacer, truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui"; @@ -17,10 +16,6 @@ const ANSI_RESET = "\x1b[0m"; const ANSI_BG_GRAY = "\x1b[100m"; const ANSI_BG_DARK_RED = "\x1b[48;5;88m"; -const PENDING_SPINNER_FRAMES = ["-", "\\", "|", "/"] as const; -const PENDING_MESSAGE = "Executing.."; -const PENDING_MARKER = "__tool_pending_status__"; - const safeStringify = (value: unknown): string => { if (typeof value === "string") { return value; @@ -143,10 +138,11 @@ class BackgroundBody { : paddedLine; }); + const result = ["", ...renderedLines]; this.cachedText = this.text; this.cachedWidth = width; - this.cachedLines = renderedLines; - return renderedLines; + this.cachedLines = result; + return result; } } @@ -160,10 +156,9 @@ export interface ToolRendererMap { export class BaseToolCallView extends Container { private readonly callId: string; - private readonly content: Markdown; + private readonly content: TrimmedMarkdown; private readonly markdownTheme: MarkdownTheme; private readonly renderers?: ToolRendererMap; - private readonly requestRender: (() => void) | undefined; private readonly showRawToolIo: boolean; private displayMode: "content" | "pretty" = "content"; private error: unknown; @@ -172,9 +167,6 @@ export class BaseToolCallView extends Container { private output: unknown; private outputDenied = false; private parsedInput: unknown; - private pendingSpinnerFrameIndex = 0; - private pendingSpinnerInterval: ReturnType | null = null; - private pendingTemplate: string | null = null; private prettyBlockActive = false; private readBlock: Container | null = null; private readBody: BackgroundBody | null = null; @@ -186,7 +178,7 @@ export class BaseToolCallView extends Container { callId: string, toolName: string, markdownTheme: MarkdownTheme, - requestRender?: () => void, + _requestRender?: () => void, showRawToolIo?: boolean, renderers?: ToolRendererMap ) { @@ -194,16 +186,15 @@ export class BaseToolCallView extends Container { this.callId = callId; this.toolName = toolName; this.markdownTheme = markdownTheme; - this.requestRender = requestRender; this.showRawToolIo = showRawToolIo ?? false; this.renderers = renderers; - this.content = new Markdown("", 1, 0, markdownTheme); + this.content = new TrimmedMarkdown("", 1, 0, markdownTheme); this.addChild(this.content); this.refresh(); } dispose(): void { - this.stopPendingSpinner(); + return; } async appendInputChunk(chunk: string): Promise { @@ -285,13 +276,6 @@ export class BaseToolCallView extends Container { this.readBody.setBackgroundEnabled(options?.useBackground ?? true); this.readHeader.setText(header); - - if (options?.isPending) { - this.startPendingSpinner(body); - return; - } - - this.stopPendingSpinner(); this.readBody.setText(body); } @@ -304,7 +288,6 @@ export class BaseToolCallView extends Container { const body = new BackgroundBody("", 1, applyGrayBackground); const block = new Container(); block.addChild(header); - block.addChild(new Spacer(1)); block.addChild(body as unknown as InstanceType); this.readHeader = header; @@ -325,46 +308,6 @@ export class BaseToolCallView extends Container { } } - private startPendingSpinner(template: string): void { - this.pendingTemplate = template.length > 0 ? template : PENDING_MARKER; - this.pendingSpinnerFrameIndex = 0; - this.applyPendingSpinnerFrame(); - - if (this.pendingSpinnerInterval) { - return; - } - - this.pendingSpinnerInterval = setInterval(() => { - this.pendingSpinnerFrameIndex = - (this.pendingSpinnerFrameIndex + 1) % PENDING_SPINNER_FRAMES.length; - this.applyPendingSpinnerFrame(); - this.requestRender?.(); - }, 80); - } - - private stopPendingSpinner(): void { - this.pendingTemplate = null; - if (!this.pendingSpinnerInterval) { - return; - } - clearInterval(this.pendingSpinnerInterval); - this.pendingSpinnerInterval = null; - } - - private applyPendingSpinnerFrame(): void { - if (!(this.pendingTemplate && this.readBody)) { - return; - } - - const frame = PENDING_SPINNER_FRAMES[this.pendingSpinnerFrameIndex]; - const spinnerText = `${frame} ${PENDING_MESSAGE}`; - this.readBody.setText( - this.pendingTemplate.includes(PENDING_MARKER) - ? this.pendingTemplate.replaceAll(PENDING_MARKER, spinnerText) - : spinnerText - ); - } - private resolveBestInput(): unknown { if (this.finalInput !== undefined) { return this.finalInput; @@ -425,7 +368,6 @@ export class BaseToolCallView extends Container { } } - this.stopPendingSpinner(); this.setDisplayMode("content"); if (this.shouldSuppressRawFallback()) {