diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index f644b7664..ce51d83cd 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -627,7 +627,13 @@ function createMarkdownSectionRenderer(text: string, theme: Theme): AssignmentSe */ export function renderCall(args: TaskParams, options: TaskRenderOptions, theme: Theme): Component { const showIsolated = "isolated" in args && args.isolated === true; - const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme); + // Dispatch glyph from the first frame: spawning is non-blocking, so a + // pending/hourglass icon would misread the call as something the turn + // waits on. + const header = renderStatusLine( + { iconOverride: theme.styledSymbol("tool.task", "accent"), title: "Task", description: args.agent }, + theme, + ); const assignmentSection = createAssignmentSectionRenderer(args, theme); const contextSection = createContextSectionRenderer(args, theme); return framedBlock(theme, width => { @@ -692,21 +698,23 @@ function renderAgentProgress( const indent = prefix ? `${prefix} ` : ""; let statusLine: string; if (progress.status === "running" || progress.status === "pending") { - // Live (or queued) agents use the task icon: detached async spawns can - // stay "pending" while real work is running, so a pending/hourglass glyph - // reads wrong in the transcript. Keep the row static; the Task tool header - // already carries any live animation. - const taskIcon = theme.styledSymbol("tool.task", frozen ? "dim" : "accent"); + // Live (or queued) agents use the same dot finished rows keep: detached + // async spawns can stay "pending" while real work is running, so a + // pending/hourglass or spinner glyph reads wrong in the transcript. Keep + // the row static; the Task tool header already carries the dispatch icon. + const dot = theme.styledSymbol("status.done", frozen ? "dim" : "accent"); const nameColor = frozen ? "dim" : "accent"; const name = theme.fg(nameColor, description ? theme.bold(displayId) : displayId); - statusLine = `${indent}${taskIcon} ${name}`; + statusLine = `${indent}${dot} ${name}`; if (description) { statusLine += `${theme.fg(nameColor, ":")} ${theme.fg(nameColor, description)}`; } + } else if (progress.status === "completed") { + // Finished rows keep the dot but settle from accent to the plain + // foreground: completion reads as a color change, not a new glyph. + statusLine = `${indent}${theme.styledSymbol("status.done", "text")} ${theme.fg("text", titlePart)}`; } else { - const glyph = - progress.status === "completed" ? theme.styledSymbol("status.done", "accent") : theme.fg(iconColor, icon); - statusLine = `${indent}${glyph} ${theme.fg("accent", titlePart)}`; + statusLine = `${indent}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`; } // Show retry-blocked badge so the parent immediately sees that a child @@ -982,7 +990,7 @@ function renderAgentResult( : needsWarning ? theme.status.warning : success - ? theme.styledSymbol("status.done", "accent") + ? theme.styledSymbol("status.done", "text") : theme.status.error; const iconColor = needsWarning ? "warning" : success ? "success" : mergeFailed ? "warning" : "error"; const statusText = aborted @@ -999,11 +1007,10 @@ function renderAgentResult( const description = result.description?.trim(); const displayId = formatTaskId(result.id); const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId; - let statusLine = `${prefix ? `${prefix} ` : ""}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge( - statusText, - iconColor, - theme, - )}`; + let statusLine = `${prefix ? `${prefix} ` : ""}${theme.fg(iconColor, icon)} ${theme.fg( + success && !needsWarning ? "text" : "accent", + titlePart, + )} ${formatBadge(statusText, iconColor, theme)}`; const showBadge = settings.get("task.showResolvedModelBadge"); statusLine = appendAgentStats( statusLine, @@ -1217,8 +1224,16 @@ export function renderResult( const metaLabel = countLabel ? (agentLabel ? `${countLabel}: ${agentLabel}` : countLabel) : agentLabel; const header = renderStatusLine( { - icon: icon === "success" ? undefined : icon, - iconOverride: icon === "success" ? theme.styledSymbol("status.done", "accent") : undefined, + icon: icon === "success" || icon === "running" ? undefined : icon, + // While agents are in flight the header shows the dispatch glyph, not a + // spinner: async spawns return immediately, so "running" means + // "delegated to peers", not "this call is blocking the turn". + iconOverride: + icon === "running" + ? theme.styledSymbol("tool.task", "accent") + : icon === "success" + ? theme.styledSymbol("status.done", "accent") + : undefined, title: "Task", meta: metaLabel ? [metaLabel] : undefined, }, diff --git a/packages/coding-agent/test/task/render-nested-live.test.ts b/packages/coding-agent/test/task/render-nested-live.test.ts index c88353021..7199ac478 100644 --- a/packages/coding-agent/test/task/render-nested-live.test.ts +++ b/packages/coding-agent/test/task/render-nested-live.test.ts @@ -240,7 +240,7 @@ describe("task renderer: nested live rendering", () => { // Header and per-agent body rows are static; only the tool header owns live animation. expect(header0).toBe(header1); expect(body0).toBe(body1); - expect(Bun.stripANSI(body1)).toContain(`${theme.symbol("tool.task")} Probe: Investigate padding`); + expect(Bun.stripANSI(body1)).toContain(`${theme.status.done} Probe: Investigate padding`); }); it("wraps the completed run summary in bracket glyphs, dropping the Total: label", async () => { diff --git a/packages/coding-agent/test/task/task-progress-render.test.ts b/packages/coding-agent/test/task/task-progress-render.test.ts index 2c468867a..0dfdb6187 100644 --- a/packages/coding-agent/test/task/task-progress-render.test.ts +++ b/packages/coding-agent/test/task/task-progress-render.test.ts @@ -66,7 +66,7 @@ describe("task progress rendering", () => { vi.restoreAllMocks(); resetSettingsForTest(); }); - it("renders running task rows static with the task icon", async () => { + it("renders running task rows static with the agent dot", async () => { const theme = (await getThemeByName("dark"))!; expect(theme).toBeDefined(); const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 }; @@ -88,13 +88,14 @@ describe("task progress rendering", () => { const rawRow1 = renderRow(700); const strippedRow = Bun.stripANSI(rawRow0); - expect(strippedRow).toContain(`${theme.symbol("tool.task")} CountPackages: List workspace packages`); + expect(strippedRow).toContain(`${theme.status.done} CountPackages: List workspace packages`); + expect(strippedRow).not.toContain(theme.symbol("tool.task")); expect(strippedRow).not.toContain(theme.status.running); expect(strippedRow).not.toContain(theme.getSpinnerFrames("status")[0]); expect(rawRow0).toBe(rawRow1); }); - it("keeps the task icon when shimmer is disabled", async () => { + it("keeps the agent dot when shimmer is disabled", async () => { const theme = (await getThemeByName("dark"))!; resetSettingsForTest(); await Settings.init({ inMemory: true, overrides: { "display.shimmer": "disabled" } }); @@ -111,12 +112,12 @@ describe("task progress rendering", () => { ), ); - expect(strippedRow).toContain(`${theme.symbol("tool.task")} KeySettingsHotPaths`); + expect(strippedRow).toContain(`${theme.status.done} KeySettingsHotPaths`); expect(strippedRow).not.toContain(theme.status.running); expect(strippedRow).not.toContain(theme.getSpinnerFrames("status")[0]); }); - it("renders pending task rows with the task icon, not the pending glyph", async () => { + it("renders pending task rows with the agent dot, not the pending glyph", async () => { const theme = (await getThemeByName("dark"))!; const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 }; const progress = runningProgress({ @@ -141,11 +142,57 @@ describe("task progress rendering", () => { const rawRow1 = renderRow(700); const strippedRow = Bun.stripANSI(rawRow0); - expect(strippedRow).toContain(`${theme.symbol("tool.task")} BestGpt: Combine winners for gpt`); + expect(strippedRow).toContain(`${theme.status.done} BestGpt: Combine winners for gpt`); expect(strippedRow).not.toContain(theme.status.pending); expect(rawRow0).toBe(rawRow1); }); + it("settles completed rows to the foreground color with the same dot", async () => { + const theme = (await getThemeByName("dark"))!; + const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 }; + const progress = runningProgress({ + id: "DonePkg", + status: "completed", + description: "List workspace packages", + }); + + const row = findRow( + taskToolRenderer.renderResult( + { content: [{ type: "text", text: "" }], details: detailsFor(progress) }, + options, + theme, + ), + "DonePkg", + ); + + const stripped = Bun.stripANSI(row); + expect(stripped).toContain(`${theme.status.done} DonePkg: List workspace packages`); + expect(stripped).not.toContain(theme.symbol("tool.task")); + // Same dot as live rows; completion reads as the label settling from + // accent to the plain foreground color. + const titlePart = `${theme.bold("DonePkg")}: List workspace packages`; + expect(row).toContain(theme.fg("text", titlePart)); + expect(row).not.toContain(theme.fg("accent", titlePart)); + }); + + it("shows the dispatch glyph in the header while agents run, not a spinner", async () => { + const theme = (await getThemeByName("dark"))!; + const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 }; + const header = findRow( + taskToolRenderer.renderResult( + { content: [{ type: "text", text: "" }], details: detailsFor(runningProgress()) }, + options, + theme, + ), + "Task", + ); + + const stripped = Bun.stripANSI(header); + expect(stripped).toContain(`${theme.symbol("tool.task")} Task`); + expect(stripped).not.toContain(theme.status.running); + expect(stripped).not.toContain(theme.getSpinnerFrames("status")[0]); + }); + it("renders the assignment markdown inside the result frame", async () => { const theme = (await getThemeByName("dark"))!; setThemeInstance(theme); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d3b7c8fec..c9f16dc4f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,10 +4,12 @@ ### Added - Added `fuzzyRank` to return sorted matches together with a fuzzy score +- Added a configurable `Input.prompt` field (defaults to `"> "`; set to `""` for chrome-less embedding inside custom banners) ### Changed - Changed fuzzy matching to normalize queries and text into words, including camelCase and punctuation separators, before scoring +- Changed `Input.setValue` to place the cursor at the end of the new value instead of clamping it to its previous position, so typing after seeding a prefilled value appends rather than prepends ### Fixed