diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 789eb81f8..b2e401998 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -27,7 +27,9 @@ - `/settings`: Tab now toggles keyboard focus between section headings and the setting rows — while headings are focused, ↑/↓ jump whole sections and Enter/Esc drop back into the rows — instead of cycling tabs. ←/→ still switch tabs everywhere, tabs without sections (e.g. Plugins) keep Tab as tab-switching, and the footer hint follows the focus state. - Image-generation (Antigravity, xAI, OpenRouter, Gemini) and xAI TTS request failures now throw pi-ai's typed `ProviderHttpError` carrying status and response headers instead of `Object.assign`-patched `Error`s. - Collapsed bash, ssh, and eval previews now cap the command/code section to a viewport-sized tail window (terminal rows minus a chrome reserve) that renders identically while streaming and after completion, with `ctrl+o` as the only way to uncap. Previously bash/ssh capped the command only while streaming and snapped it fully open the moment the tool finished, and eval never capped cell code at all. -- The `job` renderer now shimmers running-job labels (like the `task` renderer) while the poll block is live, freezing to static once the block seals, and drops the id column when the label repeats it — task jobs label themselves with their agent id, so rows read `⟨task⟩ SessionTree` instead of `SessionTree ⟨task⟩ SessionTree`. +- The `job` renderer now shimmers running-job labels while the poll block is live, freezing to static once the block seals, and drops the id column when the label repeats it — task jobs label themselves with their agent id, so rows read `⟨task⟩ SessionTree` instead of `SessionTree ⟨task⟩ SessionTree`. + +- Task progress rows now render static text with the task icon instead of shimmering the description or showing the pending/hourglass status glyph. ### Fixed @@ -46,7 +48,7 @@ - Fixed unconfigured `pi/smol`, `pi/slow`, and `pi/designer` agent model roles using cloud-priority defaults before the user's configured `modelRoles.default`, which could route local-default setups to authenticated paid providers ([#2336](https://github.com/can1357/oh-my-pi/issues/2336)). - Fixed `issue://` reads failing on older GitHub CLI releases that reject the optional `stateReason` issue JSON field; single issue reads now retry without it and issue listings no longer request it ([#2333](https://github.com/can1357/oh-my-pi/issues/2333)). - Fixed image generation ignoring `/login`-stored OpenRouter and Google API keys: provider selection and requests now resolve through the model registry (with env-var fallback) instead of environment variables only. -- Fixed the detached (`task` async spawn) subagent shimmer animating jumpily once the parent turn ended: the shimmer phase is sampled at render time, but nothing drove redraws while the agent idled, so the band only advanced when a progress snapshot happened to repaint. Detached task blocks now run the 30fps redraw driver while they are inside the transcript live region, and freeze the moment they leave it — the driver stops, progress rows settle on static gray (instead of leaving a mid-sweep shimmer band in native scrollback), and later partial snapshots no longer repaint commit-eligible rows (the final completion snapshot still applies). +- Fixed detached (`task` async spawn) progress snapshots repainting commit-eligible rows after the block leaves the live transcript region; later partial snapshots are dropped while the final completion snapshot still applies. - Fixed five consumers treating Zod tool-parameter schemas as plain JSON Schema objects, leaking schema-instance internals (`def`, `shape`, methods stringified as `undefined`) or silently reading nothing: `/dump` and the RPC `get_state` `dumpTools` payload now convert parameters through the same wire-schema conversion providers receive; context-usage token estimation no longer stringifies the Zod `def` tree (overcounting tool schema tokens in the status line); tool-discovery search indexing recovers `schemaKeys` from Zod tools (previously empty, weakening BM25 ranking); and the extension inspector panel renders Zod tool arguments instead of "(no arguments)". ## [15.11.3] - 2026-06-11 diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 63cc48d75..93bbc1996 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -15,7 +15,6 @@ import { } from "@oh-my-pi/pi-tui"; import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils"; import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit"; -import { shimmerEnabled } from "../../modes/theme/shimmer"; import type { Theme } from "../../modes/theme/theme"; import { theme } from "../../modes/theme/theme"; import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash"; @@ -126,8 +125,8 @@ export interface ToolExecutionOptions { showImages?: boolean; // default: true (only used if terminal supports images) editFuzzyThreshold?: number; editAllowFuzzy?: boolean; - /** Live-region probe driving the detached-task shimmer: animate while the - * block is inside the region, freeze it gray once it leaves. */ + /** Live-region probe used to settle detached task progress once the block + * leaves the repaintable transcript region. */ liveRegion?: TranscriptLiveRegionProbe; } @@ -146,10 +145,9 @@ export interface ToolExecutionHandle { setExpanded(expanded: boolean): void; } -/** Drive pending-tool redraws at 30fps so the running `task` row's shimmered - * subagent name stays smooth without spending twice the frame budget. The TUI - * throttles at the same cadence, and static frames diff to a no-op redraw at - * ~zero cost. */ +/** Drive pending-tool redraws at 30fps for live tool headers and displaceable + * poll blocks. The TUI throttles at the same cadence, and static frames diff to + * a no-op redraw at ~zero cost. */ const SPINNER_RENDER_INTERVAL_MS = 1000 / 30; /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the * render cadence (mirrors `Loader`). */ @@ -218,8 +216,8 @@ export class ToolExecutionComponent extends Container { #liveRegion?: TranscriptLiveRegionProbe; // One-way latch for a detached (`async.state === "running"`) task block // that left the transcript live region: its rows are commit-eligible - // history, so the shimmer driver stops, progress rows render static gray, - // and further partial snapshots are dropped (see #maybeFreezeBackgroundTask). + // history, so progress renders static gray and further partial snapshots are + // dropped (see #maybeFreezeBackgroundTask). #backgroundTaskFrozen = false; #renderState: { spinnerFrame?: number; @@ -467,27 +465,10 @@ export class ToolExecutionComponent extends Container { (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running"; const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning; const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask; - // A detached spawn (`async.state === "running"`) reports finalized so the - // transcript may freeze it, yet its progress rows shimmer while the block - // is still inside the live region. Drive those redraws at 30fps too: the - // shimmer phase is Date.now() sampled at render time, so without a driver - // it only advances when a progress snapshot happens to repaint — a jumpy - // band once the parent turn ends and the status-line loader stops. The - // tick freezes the block gray the moment it leaves the live region, so - // the interval never outlives the block's repaintable life. Gated on the - // probe (absent outside the interactive transcript) and on shimmer being - // enabled (static rows need no redraw driver). - const isLiveBackgroundTask = - isBackgroundAsyncTask && - this.#isPartial && - !this.#backgroundTaskFrozen && - this.#liveRegion !== undefined && - shimmerEnabled(); - // A displaceable waiting poll keeps its spinner ticking: it reads as one - // persistent live poll, and the changing leading glyph keeps the - // transcript's stable-prefix ratchet from committing rows of a block - // that a follow-up `job` call may remove. - const needsSpinner = isStreamingArgs || isPartialTask || isLiveBackgroundTask || this.isDisplaceableBlock(); + // Detached async task progress rows are static now; progress snapshots + // still call #maybeFreezeBackgroundTask before applying so rows settle + // once the block leaves the live region. + const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock(); if (needsSpinner && !this.#spinnerInterval) { const now = performance.now(); const frameCount = theme.spinnerFrames.length; @@ -497,15 +478,15 @@ export class ToolExecutionComponent extends Container { this.#renderState.spinnerFrame = 0; } this.#spinnerInterval = setInterval(() => { - // Detached task: stop animating the instant the block leaves the - // live region (the freeze repaints it gray and clears this interval). + // If a detached task interval from an older render path is still live, + // stop it the instant the block leaves the repaintable region. if (this.#maybeFreezeBackgroundTask()) return; const now = performance.now(); const frameCount = theme.spinnerFrames.length; - // Redraw at 30fps for a smooth `task` name shimmer, but keep the spinner - // glyph phase-locked to its classic ~12.5fps cadence. Advancing the - // anchor by elapsed frames instead of resetting to `now` avoids the - // 30fps timer quantizing the glyph down to one step every three ticks. + // Redraw at 30fps, but keep the spinner glyph phase-locked to its + // classic ~12.5fps cadence. Advancing the anchor by elapsed frames + // instead of resetting to `now` avoids the 30fps timer quantizing the + // glyph down to one step every three ticks. if (frameCount > 0) { const elapsed = now - this.#lastSpinnerAdvanceAt; if (elapsed >= SPINNER_GLYPH_ADVANCE_MS) { @@ -533,9 +514,8 @@ export class ToolExecutionComponent extends Container { /** * Freeze a detached (`async.state === "running"`) task block once it leaves * the transcript's live region. Past that seam its rows are commit-eligible - * native-scrollback history: stop the shimmer driver, repaint the progress - * rows static gray (so scrollback never keeps a mid-sweep shimmer band), and - * drop further partial snapshots. One-way — blocks never re-enter the live + * native-scrollback history: repaint the progress rows static gray and drop + * further partial snapshots. One-way — blocks never re-enter the live * region. Returns whether the block is frozen. */ #maybeFreezeBackgroundTask(): boolean { @@ -619,7 +599,7 @@ export class ToolExecutionComponent extends Container { this.#sealed = true; this.#displaceable = false; // A sealed detached task is abandoned history: settle its progress rows - // on static gray instead of a frozen mid-sweep shimmer band. + // on static gray. this.#backgroundTaskFrozen = true; this.stopAnimation(); this.#updateDisplay(); @@ -963,7 +943,7 @@ export class ToolExecutionComponent extends Container { // `renderCall` to drop its duplicate streaming preview list. context.hasResult = Boolean(this.#result); // Out of the transcript live region: progress rows render static gray - // instead of shimmering (see task/render.ts). + // (see task/render.ts). context.frozen = this.#backgroundTaskFrozen; } else if (isEditLikeToolName(this.#toolName)) { context.editMode = this.#editMode; diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index e8d90d8b2..f644b7664 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -11,7 +11,6 @@ import { formatNumber } from "@oh-my-pi/pi-utils"; import { settings } from "../config/settings"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { formatContextUsage } from "../modes/components/status-line/context-thresholds"; -import { shimmerEnabled, shimmerText } from "../modes/theme/shimmer"; import { getMarkdownTheme, type Theme } from "../modes/theme/theme"; import { formatBadge, @@ -40,9 +39,8 @@ interface TaskRenderContext { hasResult?: boolean; /** * The block left the transcript live region (detached spawn the transcript - * has moved past, or a sealed block): progress rows render static gray - * instead of shimmering, so commit-eligible rows never enter native - * scrollback mid-sweep. + * has moved past, or a sealed block): progress rows render static gray, so + * commit-eligible rows do not repaint after entering native scrollback. */ frozen?: boolean; } @@ -694,24 +692,16 @@ function renderAgentProgress( const indent = prefix ? `${prefix} ` : ""; let statusLine: string; if (progress.status === "running" || progress.status === "pending") { - // Live (or queued) agents shimmer their description so the row reads as - // in-flight — the async spawn result keeps the agent on "pending" while - // the detached job runs. Once the block leaves the live region it - // freezes (`frozen`): its rows are commit-eligible scrollback history, - // so paint them static gray instead of leaving a mid-sweep shimmer band - // in the terminal's history. - const bullet = - progress.status === "running" ? theme.styledSymbol("status.done", "text") : theme.fg(iconColor, icon); + // 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"); const nameColor = frozen ? "dim" : "accent"; const name = theme.fg(nameColor, description ? theme.bold(displayId) : displayId); - statusLine = `${indent}${bullet} ${name}`; + statusLine = `${indent}${taskIcon} ${name}`; if (description) { - const desc = frozen - ? theme.fg("dim", description) - : shimmerEnabled() - ? shimmerText(description, theme) - : theme.fg("accent", description); - statusLine += `${theme.fg(nameColor, ":")} ${desc}`; + statusLine += `${theme.fg(nameColor, ":")} ${theme.fg(nameColor, description)}`; } } else { const glyph = diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index 5c2d3bbdb..276bd8621 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -534,10 +534,10 @@ export const jobToolRenderer = { visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`; } const durationText = uiTheme.fg("dim", formatDuration(job.durationMs)); - // Running rows in a live block shimmer their label like the task - // renderer; once the block stops animating (sealed, or a settled - // snapshot — spinnerFrame cleared) they render static so - // scrollback never keeps a mid-sweep shimmer band. + // Running rows in a live block shimmer their label; once the block + // stops animating (sealed, or a settled snapshot — spinnerFrame + // cleared) they render static so scrollback never keeps a mid-sweep + // shimmer band. const live = job.status === "running" && options.spinnerFrame !== undefined; const headRaw = visibleLabelLines[0] ?? ""; const headLabel = live diff --git a/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts b/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts index cfaf324b8..eb0beea03 100644 --- a/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts +++ b/packages/coding-agent/test/modes/components/tool-execution-background-task.test.ts @@ -71,12 +71,11 @@ function finalSnapshot(output: string): { }; } -// Contract under test: a detached (`async.state === "running"`) task block -// animates its shimmer at the spinner cadence while it sits inside the -// transcript live region, then freezes — driver stopped, bytes static, further -// partial snapshots dropped — the moment it leaves the region. The final -// (completed) snapshot still applies. -describe("ToolExecutionComponent detached task shimmer freeze", () => { +// Contract under test: a detached (`async.state === "running"`) task block keeps +// progress rows static, avoids a redraw driver, freezes once a later partial +// snapshot observes that it left the live region, drops further partial +// snapshots, and still applies the final (completed) snapshot. +describe("ToolExecutionComponent detached task freeze", () => { beforeAll(async () => { await Settings.init({ inMemory: true, cwd: process.cwd() }); await initTheme(); @@ -100,25 +99,15 @@ describe("ToolExecutionComponent detached task shimmer freeze", () => { return { component, requestRender }; } - it("drives redraws while in the live region, then freezes once out and stays byte-stable", () => { + it("does not drive redraws while live and keeps progress bytes static", () => { vi.useFakeTimers(); - let live = true; - const { component, requestRender } = makeComponent(() => live); + const { component, requestRender } = makeComponent(() => true); component.updateResult(asyncSnapshot("scouting the auth flow"), true); requestRender.mockClear(); - vi.advanceTimersByTime(200); - // ~30fps driver: 200ms must produce several redraw requests. - expect(requestRender.mock.calls.length).toBeGreaterThanOrEqual(4); - - // Block leaves the live region: the next tick freezes it and clears the interval. - live = false; - vi.advanceTimersByTime(40); - requestRender.mockClear(); vi.advanceTimersByTime(500); expect(requestRender).not.toHaveBeenCalled(); - // Frozen rows no longer sample the clock: bytes identical across time. vi.spyOn(Date, "now").mockReturnValue(1_000); const frameA = component.render(100).join("\n"); vi.spyOn(Date, "now").mockReturnValue(5_000); 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 1555476b1..c88353021 100644 --- a/packages/coding-agent/test/task/render-nested-live.test.ts +++ b/packages/coding-agent/test/task/render-nested-live.test.ts @@ -212,7 +212,7 @@ describe("task renderer: nested live rendering", () => { expect(text).not.toContain("Σ"); }); - it("renders a static result header while the body shimmers the running task name", async () => { + it("renders a static result header and static running task row", async () => { const theme = (await getThemeByName("dark"))!; const details: TaskToolDetails = { projectAgentsDir: null, @@ -237,11 +237,10 @@ describe("task renderer: nested live rendering", () => { const header1 = f1.find(l => Bun.stripANSI(l).includes("Task"))!; const body0 = f0.find(l => Bun.stripANSI(l).includes("Probe"))!; const body1 = f1.find(l => Bun.stripANSI(l).includes("Probe"))!; - // Header is static — no clock ticking beside "Task". + // Header and per-agent body rows are static; only the tool header owns live animation. expect(header0).toBe(header1); - // The per-agent body line still animates via the shimmered task name. - expect(body0).not.toBe(body1); - expect(Bun.stripANSI(body1)).toContain("• Probe: Investigate padding"); + expect(body0).toBe(body1); + expect(Bun.stripANSI(body1)).toContain(`${theme.symbol("tool.task")} 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 1465ddca0..2c468867a 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("keeps the subagent label solid and shimmers the running description", async () => { + it("renders running task rows static with the task icon", async () => { const theme = (await getThemeByName("dark"))!; expect(theme).toBeDefined(); const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 }; @@ -88,18 +88,13 @@ describe("task progress rendering", () => { const rawRow1 = renderRow(700); const strippedRow = Bun.stripANSI(rawRow0); - expect(strippedRow).toContain("• CountPackages: List workspace packages"); + expect(strippedRow).toContain(`${theme.symbol("tool.task")} CountPackages: List workspace packages`); expect(strippedRow).not.toContain(theme.status.running); expect(strippedRow).not.toContain(theme.getSpinnerFrames("status")[0]); - // The label is one solid bold-accent run, identical across shimmer frames. - const label = theme.fg("accent", theme.bold("CountPackages")); - expect(rawRow0).toContain(label); - expect(rawRow1).toContain(label); - // The description shimmers, so the row as a whole animates between frames. - expect(rawRow0).not.toBe(rawRow1); + expect(rawRow0).toBe(rawRow1); }); - it("keeps the bullet replacement when shimmer is disabled", async () => { + it("keeps the task icon when shimmer is disabled", async () => { const theme = (await getThemeByName("dark"))!; resetSettingsForTest(); await Settings.init({ inMemory: true, overrides: { "display.shimmer": "disabled" } }); @@ -116,12 +111,12 @@ describe("task progress rendering", () => { ), ); - expect(strippedRow).toContain("• KeySettingsHotPaths"); + expect(strippedRow).toContain(`${theme.symbol("tool.task")} KeySettingsHotPaths`); expect(strippedRow).not.toContain(theme.status.running); expect(strippedRow).not.toContain(theme.getSpinnerFrames("status")[0]); }); - it("shimmers the pending description like a running one (frozen async spawn snapshot)", async () => { + it("renders pending task rows with the task icon, not the pending glyph", async () => { const theme = (await getThemeByName("dark"))!; const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 }; const progress = runningProgress({ @@ -144,14 +139,11 @@ describe("task progress rendering", () => { const rawRow0 = renderRow(0); const rawRow1 = renderRow(700); + const strippedRow = Bun.stripANSI(rawRow0); - expect(Bun.stripANSI(rawRow0)).toContain("BestGpt: Combine winners for gpt"); - // The label stays one solid bold-accent run; the description shimmers, - // so the row animates across frames exactly like a running agent's. - const label = theme.fg("accent", theme.bold("BestGpt")); - expect(rawRow0).toContain(label); - expect(rawRow1).toContain(label); - expect(rawRow0).not.toBe(rawRow1); + expect(strippedRow).toContain(`${theme.symbol("tool.task")} BestGpt: Combine winners for gpt`); + expect(strippedRow).not.toContain(theme.status.pending); + expect(rawRow0).toBe(rawRow1); }); it("renders the assignment markdown inside the result frame", async () => {