Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/thinking-spinner-label.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 7 additions & 52 deletions packages/cea/src/interaction/pi-tui-stream-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type TestStreamPart = TextStreamPart<ToolSet>;

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,
Expand Down Expand Up @@ -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");
});
Expand All @@ -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<TestStreamPart> {
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([
{
Expand All @@ -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");
});

Expand All @@ -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");
});

Expand Down
73 changes: 6 additions & 67 deletions packages/cea/src/interaction/pi-tui-stream-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -617,20 +614,17 @@ 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";
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+$/;

Expand Down Expand Up @@ -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<typeof setInterval> | null = null;
private pendingTemplate: string | null = null;
private parsedInput: unknown;
private readMode = false;
private toolName: string;
Expand All @@ -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);
Expand All @@ -1022,7 +1011,7 @@ class ToolCallView extends Container {
}

dispose(): void {
this.stopPendingSpinner();
return;
}

private setReadMode(enabled: boolean): void {
Expand All @@ -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<void> {
this.inputBuffer += chunk;

Expand Down Expand Up @@ -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);
Comment thread
minpeter marked this conversation as resolved.
}

Expand Down Expand Up @@ -1678,8 +1619,6 @@ class ToolCallView extends Container {
return;
}

this.stopPendingSpinner();

if (this.shouldSuppressRawFallback()) {
return;
}
Expand Down
Loading
Loading