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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -252,7 +252,6 @@ export const sshToolRenderer = {
|
||||
state: "pending",
|
||||
sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: _options.expanded }) }],
|
||||
width,
|
||||
animate: true,
|
||||
},
|
||||
uiTheme,
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user