diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 38c14da40..f3e26c87a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index aee73cc06..e38008f7a 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"; @@ -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. diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 887adbb5e..b07f3b829 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -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(config: ShellRendererConfig) { state: "pending", sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }], width, - animate: true, }, uiTheme, ), @@ -1261,11 +1259,6 @@ export function createShellRenderer(config: ShellRendererConfig) { { 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, ); diff --git a/packages/coding-agent/src/tools/eval-render.ts b/packages/coding-agent/src/tools/eval-render.ts index d9379bba2..94f12e5d4 100644 --- a/packages/coding-agent/src/tools/eval-render.ts +++ b/packages/coding-agent/src/tools/eval-render.ts @@ -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, ); diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 553f8e0f0..34f84132b 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -252,7 +252,6 @@ export const sshToolRenderer = { state: "pending", sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: _options.expanded }) }], width, - animate: true, }, uiTheme, ), diff --git a/packages/coding-agent/src/tui/code-cell.ts b/packages/coding-agent/src/tui/code-cell.ts index c2f061807..76fd38655 100644 --- a/packages/coding-agent/src/tui/code-cell.ts +++ b/packages/coding-agent/src/tui/code-cell.ts @@ -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 { diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index d80344f68..18c90b861 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -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); diff --git a/packages/coding-agent/test/tool-execution-args.test.ts b/packages/coding-agent/test/tool-execution-args.test.ts index 1a4a66dc1..66cc0e12c 100644 --- a/packages/coding-agent/test/tool-execution-args.test.ts +++ b/packages/coding-agent/test/tool-execution-args.test.ts @@ -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(); - }); }); diff --git a/packages/coding-agent/test/tools/bash-sixel-render.test.ts b/packages/coding-agent/test/tools/bash-sixel-render.test.ts index 28db6c233..16d855c26 100644 --- a/packages/coding-agent/test/tools/bash-sixel-render.test.ts +++ b/packages/coding-agent/test/tools/bash-sixel-render.test.ts @@ -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, 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)); - }); }); diff --git a/packages/coding-agent/test/tui/output-block-anim.test.ts b/packages/coding-agent/test/tui/output-block-anim.test.ts deleted file mode 100644 index e78dbcf6f..000000000 --- a/packages/coding-agent/test/tui/output-block-anim.test.ts +++ /dev/null @@ -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); - }); -});