feat(coding-agent): removed animated shimmer border on exec blocks

- Removed sweeping bottom-edge segment from pending bash, eval, and ssh blocks; pending now shows a static accent border.
- Dropped border shimmer geometry, animate options, and related tests.
- Repurposed pending-tool 30fps redraws solely for the running task row shimmer.
This commit is contained in:
can1357
2026-06-08 04:31:45 +02:00
parent f741431b76
commit fe5d27c4a3
10 changed files with 18 additions and 292 deletions
+4
View File
@@ -11,6 +11,10 @@
- Fixed duplicate session suggestions by deduplicating history matches by session path when merging metadata and prompt-history results
- Fixed `/resume` search ranking so sessions whose prompts or metadata match the query now prefer prompt recency and recent literal matches instead of letting older earlier-title fuzzy matches outrank a just-used session.
### Removed
- Removed the animated pending border ("shimmer") on running `bash`, `eval`, and `ssh` execution blocks. While pending, a block now shows a static accent border instead of sweeping a dark segment around its bottom edge; `display.shimmer` still governs the working-status line and `task` row animations.
## [15.10.2] - 2026-06-08
### Added
@@ -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";
@@ -133,9 +132,10 @@ export interface ToolExecutionHandle {
setExpanded(expanded: boolean): void;
}
/** Drive pending-tool redraws at 30fps so the animated border sweep 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 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. */
const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
/** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
* render cadence (mirrors `Loader`). */
@@ -425,16 +425,7 @@ 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;
// Sweep the border of bash/eval execution blocks while they're pending — but
// not once they've been backgrounded: a backgrounded job's block gets
// committed to scrollback and finalizes later via the async update path, so a
// mid-sweep frame would freeze a stray dark "bar" segment into the border.
const isPendingExecBlock =
this.#isPartial &&
shimmerEnabled() &&
(this.#toolName === "bash" || this.#toolName === "eval") &&
!isBackgroundAsyncRunning;
const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
const needsSpinner = isStreamingArgs || isPartialTask;
if (needsSpinner && !this.#spinnerInterval) {
const now = performance.now();
const frameCount = theme.spinnerFrames.length;
@@ -446,7 +437,7 @@ export class ToolExecutionComponent extends Container {
this.#spinnerInterval = setInterval(() => {
const now = performance.now();
const frameCount = theme.spinnerFrames.length;
// Redraw at 30fps for a smooth border sweep, but keep the spinner
// 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.
-7
View File
@@ -14,7 +14,6 @@ import { type BashResult, executeBash } from "../exec/bash-executor";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import { truncateToVisualLines } from "../modes/components/visual-truncate";
import { shimmerEnabled } from "../modes/theme/shimmer";
import { highlightCode, type Theme } from "../modes/theme/theme";
import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
@@ -1130,7 +1129,6 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
state: "pending",
sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }],
width,
animate: true,
},
uiTheme,
),
@@ -1261,11 +1259,6 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
],
width,
// Don't animate once the command has been backgrounded: the block
// gets committed to scrollback and finalizes later via the async
// update path, so a mid-sweep frame would freeze a stray dark
// border segment.
animate: options.isPartial && shimmerEnabled() && details?.async?.state !== "running",
},
uiTheme,
);
@@ -16,9 +16,8 @@ import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } f
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
import { truncateToVisualLines } from "../modes/components/visual-truncate";
import { shimmerEnabled } from "../modes/theme/shimmer";
import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
import { borderShimmerTick, markFramedBlockComponent, renderCodeCell } from "../tui";
import { markFramedBlockComponent, renderCodeCell } from "../tui";
import {
JSON_TREE_MAX_DEPTH_COLLAPSED,
JSON_TREE_MAX_DEPTH_EXPANDED,
@@ -491,8 +490,7 @@ export const evalToolRenderer = {
return markFramedBlockComponent({
render: (width: number): string[] => {
const animate = options.isPartial && shimmerEnabled();
const key = `${animate ? borderShimmerTick() : 0}|${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
const key = `${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
if (cached && cached.key === key && cached.width === width) {
return cached.result;
}
@@ -516,7 +514,6 @@ export const evalToolRenderer = {
// the code has finalized (see `isStreamingPreviewAppendOnly`).
codeMaxLines: Number.POSITIVE_INFINITY,
expanded: options.expanded,
animate,
},
uiTheme,
);
@@ -579,8 +576,7 @@ export const evalToolRenderer = {
render: (width: number): string[] => {
const expanded = options.renderContext?.expanded ?? options.expanded;
const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
const animate = options.isPartial && shimmerEnabled();
const key = `${expanded}|${previewLines}|${options.spinnerFrame}|${animate ? borderShimmerTick() : 0}`;
const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
if (cached && cached.key === key && cached.width === width) {
return cached.result;
}
@@ -622,7 +618,6 @@ export const evalToolRenderer = {
codeMaxLines: Number.POSITIVE_INFINITY,
expanded,
width,
animate,
},
uiTheme,
);
-1
View File
@@ -252,7 +252,6 @@ export const sshToolRenderer = {
state: "pending",
sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: _options.expanded }) }],
width,
animate: true,
},
uiTheme,
),
+1 -6
View File
@@ -32,8 +32,6 @@ export interface CodeCellOptions {
*/
codeTail?: boolean;
expanded?: boolean;
/** Animate the cell border with a sweeping segment while pending/running. */
animate?: boolean;
width: number;
}
@@ -147,10 +145,7 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
sections.push({ label: theme.fg("toolTitle", "Output"), lines: outputLines });
}
return renderOutputBlock(
{ header: title, headerMeta: meta, state, sections, width, animate: options.animate },
theme,
);
return renderOutputBlock({ header: title, headerMeta: meta, state, sections, width }, theme);
}
export interface MarkdownCellOptions {
+2 -97
View File
@@ -17,8 +17,6 @@ export interface OutputBlockOptions {
width: number;
applyBg?: boolean;
contentPaddingLeft?: number;
/** Animate the border with a sweeping dark segment (pending/running state). */
animate?: boolean;
/** Override the state-derived border color. Used for muted "legacy" tool
* frames that should not visually compete with framed-output tools. */
borderColor?: ThemeColor;
@@ -37,59 +35,6 @@ export function isFramedBlockComponent(component: Component): boolean {
return (component as FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] === true;
}
const BORDER_SHIMMER_TICK_MS = 1000 / 30;
/** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
* ms. Position is derived from the wall clock against this fixed cycle so a
* resize only nudges the segment proportionally instead of teleporting it. */
const BORDER_BOUNCE_MS = 3000;
/** Length, in border cells, of the moving segment. */
const BORDER_SEGMENT_LEN = 8;
/**
* Monotonic frame counter for animated borders, quantized to the TUI's ~30fps
* render cap so the cache key advances once per animation frame — fine enough
* for a smooth segment sweep, coarse enough to coalesce multiple render passes
* land inside the same frame.
*/
export function borderShimmerTick(): number {
return Math.floor(Date.now() / BORDER_SHIMMER_TICK_MS);
}
/** Ease-in-out so the segment decelerates into and accelerates out of each wall. */
function easeInOutQuad(t: number): number {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
}
/**
* Column of the travelling segment's center on the bottom edge for a box of
* inner width `W` at time `now`. The segment bounces left → right → left across
* the bottom border: a triangle wave over one full there-and-back cycle, eased
* per leg so it slows as it nears each wall before reversing. Position is
* derived from the wall clock against a fixed cycle, so a resize shifts the
* center proportionally — no reset.
*/
export function borderSegmentHeadCol(W: number, now: number): number {
if (W <= 1) return 0;
const phase = (((now % BORDER_BOUNCE_MS) + BORDER_BOUNCE_MS) % BORDER_BOUNCE_MS) / BORDER_BOUNCE_MS;
// Triangle: 0→1 rightward over the first half, 1→0 leftward over the second.
const leg = phase < 0.5 ? phase * 2 : 2 - phase * 2;
return easeInOutQuad(leg) * (W - 1);
}
/**
* Scale a truecolor foreground escape toward black by `factor`. Returns
* undefined for 256-color escapes (no RGB to scale) so callers fall back to a
* dimmer theme color.
*/
function darkenFgAnsi(ansi: string, factor: number): string | undefined {
const m = /38;2;(\d+);(\d+);(\d+)/.exec(ansi);
if (!m) return undefined;
const r = Math.round(Number(m[1]) * factor);
const g = Math.round(Number(m[2]) * factor);
const b = Math.round(Number(m[3]) * factor);
return `\x1b[38;2;${r};${g};${b}m`;
}
type BlockRow =
| { kind: "bar"; leftChar: string; rightChar: string; label?: string; meta?: string }
| { kind: "bottom"; leftChar: string; rightChar: string }
@@ -135,8 +80,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
const contentWidth = Math.max(0, lineWidth - visibleWidth(v) - contentPaddingLeft - visibleWidth(v));
const contentLeftPadding = contentPaddingLeft > 0 ? padding(contentPaddingLeft) : "";
// ── Layout pass: collect row descriptors so the border perimeter length is
// known before the moving segment is positioned. ──
// ── Layout pass: collect row descriptors before emitting the bordered lines. ──
const rows: BlockRow[] = [];
rows.push({
kind: "bar",
@@ -185,39 +129,6 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
rows.push({ kind: "bottom", leftChar: theme.boxSharp.bottomLeft, rightChar: theme.boxSharp.bottomRight });
const H = rows.length;
const W = lineWidth;
const animate = (options.animate ?? false) && (state === "running" || state === "pending") && W >= 2 && H >= 2;
// ── Segment geometry: one dark run bounces left ↔ right along the bottom
// edge only. The top, interior separators, and side borders stay the flat
// accent color. ──
const segLen = animate ? Math.min(BORDER_SEGMENT_LEN, W) : 0;
const head = animate ? borderSegmentHeadCol(W, Date.now()) : 0;
const segHalf = segLen / 2;
const segAnsi = animate ? (darkenFgAnsi(theme.getFgAnsi(borderColor), 0.4) ?? theme.getFgAnsi("borderMuted")) : "";
const seg = (text: string) => `${segAnsi}${text}\x1b[39m`;
// A bottom-edge column is lit when it lies within half a segment of the
// travelling center.
const isLit = (col: number): boolean => Math.abs(col - head) < segHalf;
// Color a run of bottom-edge glyphs starting at column `startCol`, grouping
// consecutive same-state cells so each run emits a single escape pair.
const colorEdge = (glyphs: string, startCol: number): string => {
let out = "";
let runLit: boolean | null = null;
let buf = "";
for (let i = 0; i < glyphs.length; i++) {
const lit = isLit(startCol + i);
if (lit !== runLit) {
if (runLit !== null) out += (runLit ? seg : border)(buf);
buf = "";
runLit = lit;
}
buf += glyphs[i];
}
if (runLit !== null) out += (runLit ? seg : border)(buf);
return out;
};
const renderBar = (row: { leftChar: string; rightChar: string; label?: string; meta?: string }): string => {
const leftGlyphs = `${row.leftChar}${cap}`;
@@ -245,11 +156,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
const rightGlyph = row.rightChar;
const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
const fillGlyphs = h.repeat(fillCount);
if (!animate) return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
const leftStr = colorEdge(leftGlyphs, 0);
const fillStr = colorEdge(fillGlyphs, visibleWidth(leftGlyphs));
const rightStr = colorEdge(rightGlyph, lineWidth - visibleWidth(rightGlyph));
return `${leftStr}${fillStr}${rightStr}`;
return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
};
const renderContent = (inner: string): string => `${border(v)}${contentLeftPadding}${inner}${border(v)}`;
@@ -302,8 +209,6 @@ export class CachedOutputBlock {
h.optional(options.state);
h.optional(options.borderColor);
h.bool(options.applyBg ?? true);
h.bool(options.animate ?? false);
if (options.animate) h.u32(borderShimmerTick());
if (options.sections) {
for (const s of options.sections) {
h.optional(s.label);
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { Text, type TUI } from "@oh-my-pi/pi-tui";
import type { TUI } from "@oh-my-pi/pi-tui";
import { ToolExecutionComponent } from "../src/modes/components/tool-execution";
describe("ToolExecutionComponent.updateArgs (F8 — no clone, ref-eq fast path)", () => {
@@ -33,28 +32,4 @@ describe("ToolExecutionComponent.updateArgs (F8 — no clone, ref-eq fast path)"
expect(cloneSpy).not.toHaveBeenCalled();
});
it("keeps bash spinner cadence when the shimmer border repaints at 30fps", async () => {
if (!initialized) {
await initTheme();
initialized = true;
}
vi.useFakeTimers();
let renderState: { spinnerFrame?: number } | undefined;
const uiStub = { requestRender: vi.fn() } as unknown as TUI;
const tool = {
label: "Bash",
renderCall: (_args: unknown, options: { spinnerFrame?: number }) => {
renderState = options;
return new Text("", 0, 0);
},
execute: async () => ({ content: [] }),
} as unknown as AgentTool;
const component = new ToolExecutionComponent("bash", { command: "echo ok" }, {}, tool, uiStub);
component.setArgsComplete();
vi.advanceTimersByTime(170);
expect(renderState?.spinnerFrame).toBe(2);
component.stopAnimation();
});
});
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { afterEach, describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core";
@@ -257,36 +257,4 @@ describe("bashToolRenderer", () => {
expect(rendered[idx]).toMatch(/\u001b\[38;(?:2|5);/);
}
});
it("keeps a backgrounded command's border static while a foreground one still shimmers", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
// Render a still-partial bash result at two wall-clock instants a quarter of
// a shimmer cycle apart. The animated bottom-edge segment lives at the far
// left at t=0 and near center at t=750ms, so an animating border yields
// different bytes across the two frames while a static one is identical.
const renderAt = (details: Record<string, unknown>, now: number): string => {
const spy = vi.spyOn(Date, "now").mockReturnValue(now);
try {
const component = bashToolRenderer.renderResult(
{ content: [{ type: "text", text: "Background job bg_1 started: sleep 30" }], details, isError: false },
{ expanded: false, isPartial: true },
uiTheme,
{ command: "sleep 30" },
);
return component.render(60).join("\n");
} finally {
spy.mockRestore();
}
};
// Backgrounded (finalizes later via the async update path): no shimmer, so
// the committed frame can't freeze a stray dark "bar" into the border.
const backgrounded = { async: { state: "running", jobId: "bg_1", type: "bash" } };
expect(renderAt(backgrounded, 0)).toBe(renderAt(backgrounded, 750));
// Foreground pending: the border still sweeps, so frames differ over time.
expect(renderAt({}, 0)).not.toBe(renderAt({}, 750));
});
});
@@ -1,99 +0,0 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { borderSegmentHeadCol, renderOutputBlock } from "@oh-my-pi/pi-coding-agent/tui";
// Matches both truecolor (38;2;r;g;b) and 256-color (38;5;n) foreground escapes
// so the assertions hold regardless of the detected terminal color mode.
const FG = /\x1b\[38;(?:2;\d+;\d+;\d+|5;\d+)m/g;
function fgEscapes(text: string): string[] {
return text.match(FG) ?? [];
}
describe("renderOutputBlock animated border", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("paints a dark traversing segment on the bottom edge distinct from the accent border", async () => {
const theme = (await getThemeByName("dark"))!;
const accent = theme.getFgAnsi("accent");
// Pin the clock so the segment sits at the left wall of the bottom edge.
vi.spyOn(Date, "now").mockReturnValue(0);
const lines = renderOutputBlock(
{ state: "running", sections: [{ lines: ["hello"] }], width: 30, animate: true },
theme,
);
const topLine = lines[0]!;
const bottomLine = lines[lines.length - 1]!;
// The bottom edge carries the base accent plus a second (segment) color.
const bottomColors = new Set(fgEscapes(bottomLine));
expect(bottomColors.has(accent)).toBe(true);
const segColor = [...bottomColors].find(c => c !== accent);
expect(segColor).toBeDefined();
// Only the bottom edge animates — the top edge and interior rows stay accent.
expect(topLine).toContain(accent);
expect(topLine).not.toContain(segColor!);
for (const line of lines.slice(1, -1)) {
expect(line).not.toContain(segColor!);
}
});
it("keeps the border a single accent color when animation is off", async () => {
const theme = (await getThemeByName("dark"))!;
const accent = theme.getFgAnsi("accent");
const lines = renderOutputBlock(
{ state: "running", sections: [{ lines: ["hello"] }], width: 30, animate: false },
theme,
);
expect(new Set(fgEscapes(lines[0]!))).toEqual(new Set([accent]));
});
it("ignores animation for terminal (non-pending) states", async () => {
const theme = (await getThemeByName("dark"))!;
vi.spyOn(Date, "now").mockReturnValue(0);
const animated = renderOutputBlock(
{ state: "success", sections: [{ lines: ["hello"] }], width: 30, animate: true },
theme,
).join("\n");
const plain = renderOutputBlock(
{ state: "success", sections: [{ lines: ["hello"] }], width: 30, animate: false },
theme,
).join("\n");
expect(animated).toBe(plain);
});
});
describe("borderSegmentHeadCol", () => {
it("does not teleport when the box grows a column (smooth on resize)", () => {
// At a fixed instant, widening by one column must nudge the center by at
// most one cell — position is derived from the clock, not remapped.
const now = 1830; // arbitrary mid-cycle instant
for (let W = 10; W < 40; W++) {
const a = borderSegmentHeadCol(W, now);
const b = borderSegmentHeadCol(W + 1, now);
expect(Math.abs(b - a)).toBeLessThanOrEqual(1);
}
});
it("bounces the full width and eases at each wall", () => {
const W = 30;
const centers: number[] = [];
// 6000ms spans at least one full there-and-back bounce.
for (let ms = 0; ms <= 6000; ms += 50) centers.push(borderSegmentHeadCol(W, ms));
// Sweeps the whole bottom edge: reaches both walls.
expect(Math.min(...centers)).toBeLessThan(1);
expect(Math.max(...centers)).toBeGreaterThan(W - 2);
// Eased: per-step speed varies (near-stationary at the walls, faster mid-sweep).
const steps: number[] = [];
for (let i = 1; i < centers.length; i++) steps.push(Math.abs(centers[i]! - centers[i - 1]!));
expect(Math.min(...steps)).toBeLessThan(Math.max(...steps));
});
it("starts at the left wall at cycle origin", () => {
expect(borderSegmentHeadCol(20, 0)).toBe(0);
});
});