diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index b17996aab..4d17f9b48 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -601,6 +601,22 @@ export const SETTINGS_SCHEMA = { default: 3, }, + "display.shimmer": { + type: "enum", + values: ["classic", "kitt", "disabled"] as const, + default: "classic", + ui: { + tab: "appearance", + label: "Shimmer", + description: "Animation style for working/loading messages", + options: [ + { value: "classic", label: "Classic", description: "Soft cosine wave sweeping across the text" }, + { value: "kitt", label: "KITT Scanner", description: "Knight Rider 1982 red light bouncing left-right" }, + { value: "disabled", label: "Disabled", description: "No animation; static muted text" }, + ], + }, + }, + "display.showTokenUsage": { type: "boolean", default: false, diff --git a/packages/coding-agent/src/modes/theme/shimmer.ts b/packages/coding-agent/src/modes/theme/shimmer.ts index eeb44e51c..22d73b882 100644 --- a/packages/coding-agent/src/modes/theme/shimmer.ts +++ b/packages/coding-agent/src/modes/theme/shimmer.ts @@ -1,18 +1,36 @@ +import { isSettingsInitialized, settings } from "../../config/settings"; import type { Theme, ThemeColor } from "./theme"; -const SHIMMER_PADDING = 10; -const SHIMMER_SWEEP_MS = 2000; -const SHIMMER_BAND_HALF_WIDTH = 5; +// ─── Classic sweep tunables ────────────────────────────────────────────────── +const CLASSIC_PADDING = 10; +const CLASSIC_SWEEP_MS = 1400; +const CLASSIC_BAND_HALF_WIDTH = 6; -type ShimmerTheme = Pick; +// ─── KITT scanner tunables ─────────────────────────────────────────────────── +// 1.5s round trip ≈ classic 1982 K.I.T.T. scanner cadence (~0.75s per direction). +const KITT_CYCLE_MS = 1500; +const KITT_HEAD_HALF = 0.6; +const KITT_TRAIL_LEN = 7; + +// ─── Tier thresholds ───────────────────────────────────────────────────────── +const TIER_HIGH = 0.65; +const TIER_MID = 0.22; + +// ─── Raw ANSI codes ────────────────────────────────────────────────────────── +const FG_RESET = "\x1b[39m"; +const BOLD_OPEN = "\x1b[1m"; +const BOLD_CLOSE = "\x1b[22m"; + +type ShimmerTheme = Pick; +type ShimmerMode = "classic" | "kitt" | "disabled"; /** Three-tier color stack a shimmer character cycles through as the band sweeps. */ export interface ShimmerPalette { - /** Color for chars outside / at the edge of the band (intensity < 0.2). */ + /** Color for chars outside / at the edge of the band (intensity < ~0.22). */ low: ThemeColor; - /** Color for chars approaching the crest (0.2 <= intensity < 0.6). */ + /** Color for chars approaching the crest (~0.22 ≤ intensity < ~0.65). */ mid: ThemeColor; - /** Color at the band's crest (intensity >= 0.6). */ + /** Color at the band's crest (intensity ≥ ~0.65). */ high: ThemeColor; /** Whether to bold the crest tier. Default `false`. */ bold?: boolean; @@ -31,47 +49,160 @@ export const DEFAULT_SHIMMER_PALETTE: ShimmerPalette = { bold: true, }; -function shimmerIntensity(index: number, length: number): number { - const period = length + SHIMMER_PADDING * 2; - const pos = Math.floor(((Date.now() % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS) * period); - const dist = Math.abs(index + SHIMMER_PADDING - pos); - if (dist > SHIMMER_BAND_HALF_WIDTH) return 0; - - const x = Math.PI * (dist / SHIMMER_BAND_HALF_WIDTH); - return 0.5 * (1 + Math.cos(x)); +// ─── Palette compilation cache ─────────────────────────────────────────────── +// Resolving ANSI codes for every character was the dominant per-frame cost. +// We resolve once per (theme, palette) pair into ready-to-concat prefix/suffix +// strings, then coalesce same-tier runs at render time so each frame emits a +// handful of escape sequences instead of one per code point. +// +// The cache is stashed as a Symbol-keyed slot directly on the palette object +// — no module-level sidecar — and invalidates when the active Theme changes. +interface TierSeq { + open: string; + close: string; +} +interface CompiledPalette { + low: TierSeq; + mid: TierSeq; + high: TierSeq; } -function styleShimmerChar(ch: string, intensity: number, theme: ShimmerTheme, palette: ShimmerPalette): string { - if (intensity < 0.2) return theme.fg(palette.low, ch); - if (intensity < 0.6) return theme.fg(palette.mid, ch); - const styled = theme.fg(palette.high, ch); - return palette.bold ? theme.bold(styled) : styled; +const kCompiledFor = Symbol("shimmer.compiledFor"); +const kCompiled = Symbol("shimmer.compiled"); +interface PaletteCache { + [kCompiledFor]?: ShimmerTheme; + [kCompiled]?: CompiledPalette; +} + +function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette { + const p = palette as ShimmerPalette & PaletteCache; + const cached = p[kCompiled]; + if (cached && p[kCompiledFor] === theme) return cached; + const highOpen = palette.bold ? `${BOLD_OPEN}${theme.getFgAnsi(palette.high)}` : theme.getFgAnsi(palette.high); + const highClose = palette.bold ? `${BOLD_CLOSE}${FG_RESET}` : FG_RESET; + const out: CompiledPalette = { + low: { open: theme.getFgAnsi(palette.low), close: FG_RESET }, + mid: { open: theme.getFgAnsi(palette.mid), close: FG_RESET }, + high: { open: highOpen, close: highClose }, + }; + p[kCompiledFor] = theme; + p[kCompiled] = out; + return out; +} + +// ─── Intensity profiles ────────────────────────────────────────────────────── +/** Smooth cosine bump sweeping left → right with edge padding. */ +function classicIntensity(time: number, index: number, length: number): number { + const period = length + CLASSIC_PADDING * 2; + // Fractional position — kept un-floored so the band glides at the host's + // frame rate instead of stepping discretely. + const pos = ((time % CLASSIC_SWEEP_MS) / CLASSIC_SWEEP_MS) * period; + const dist = Math.abs(index + CLASSIC_PADDING - pos); + if (dist >= CLASSIC_BAND_HALF_WIDTH) return 0; + return 0.5 * (1 + Math.cos((Math.PI * dist) / CLASSIC_BAND_HALF_WIDTH)); } /** - * Apply a shimmer sweep across one or more segments, treating them as a single - * continuous string for band positioning. Each segment can supply its own - * palette so the gradient stays in lockstep while the colors differ. + * Knight Rider K.I.T.T. scanner: a single bright head ping-pongs across the + * bar with a quadratic-decay trail behind it. No leading glow — LEDs don't + * predict the future. + */ +function kittIntensity(time: number, index: number, length: number): number { + const range = length - 1; + if (range <= 0) return 1; + const phase = (time % KITT_CYCLE_MS) / KITT_CYCLE_MS; + const goingRight = phase < 0.5; + const head = goingRight ? phase * 2 * range : (1 - phase) * 2 * range; + const delta = index - head; + const abs = delta < 0 ? -delta : delta; + if (abs <= KITT_HEAD_HALF) return 1; + // Only chars *behind* the head light up — direction-dependent. + const behind = goingRight ? -delta : delta; + if (behind <= KITT_HEAD_HALF) return 0; + const t = (behind - KITT_HEAD_HALF) / KITT_TRAIL_LEN; + if (t >= 1) return 0; + const f = 1 - t; + return f * f; +} + +type Tier = "low" | "mid" | "high"; + +function tierFor(intensity: number): Tier { + if (intensity >= TIER_HIGH) return "high"; + if (intensity >= TIER_MID) return "mid"; + return "low"; +} + +function resolveMode(): ShimmerMode { + if (!isSettingsInitialized()) return "classic"; + return settings.get("display.shimmer"); +} + +/** + * Apply a shimmer sweep across one or more segments, treating them as a + * single continuous string for band positioning. Each segment can supply + * its own palette so the gradient stays in lockstep while the colors + * differ. + * + * Performance shape (per call, dominant cost): + * - One `Date.now()` read. + * - One `compile()` lookup per segment (Symbol-keyed cache slot, hot path + * skipped after first frame). + * - One ANSI open/close pair per **run of same-tier chars**, not per char. + * - No per-char allocations beyond the run buffer. */ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string { + const mode = resolveMode(); + + // Pre-scan: total code-point count (positions the band) and resolved palette. let total = 0; - const expanded: Array<{ chars: string[]; palette: ShimmerPalette }> = []; + const perSeg: { chars: string[]; palette: ShimmerPalette }[] = []; for (const seg of segments) { - const chars = [...seg.text]; + const chars = Array.from(seg.text); total += chars.length; - expanded.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE }); + perSeg.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE }); } if (total === 0) return ""; - const out: string[] = []; + // Disabled: no animation, no per-char work. Paint each segment in its mid + // tier so the working line stays legible without movement. + if (mode === "disabled") { + let out = ""; + for (const { chars, palette } of perSeg) { + const seq = compile(theme, palette).mid; + out += `${seq.open}${chars.join("")}${seq.close}`; + } + return out; + } + + const time = Date.now(); + const intensityFn = mode === "kitt" ? kittIntensity : classicIntensity; + + let out = ""; let index = 0; - for (const { chars, palette } of expanded) { - for (const ch of chars) { - out.push(styleShimmerChar(ch, shimmerIntensity(index, total), theme, palette)); + for (const { chars, palette } of perSeg) { + const compiled = compile(theme, palette); + let runTier: Tier | null = null; + let runBuf = ""; + for (let i = 0; i < chars.length; i++) { + const tier = tierFor(intensityFn(time, index, total)); + if (tier !== runTier) { + if (runTier !== null) { + const seq = compiled[runTier]; + out += `${seq.open}${runBuf}${seq.close}`; + runBuf = ""; + } + runTier = tier; + } + runBuf += chars[i]; index++; } + if (runTier !== null && runBuf.length > 0) { + const seq = compiled[runTier]; + out += `${seq.open}${runBuf}${seq.close}`; + } } - return out.join(""); + return out; } export function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string { diff --git a/packages/coding-agent/src/slash-commands/helpers/format.ts b/packages/coding-agent/src/slash-commands/helpers/format.ts index a57a343dd..e592f9805 100644 --- a/packages/coding-agent/src/slash-commands/helpers/format.ts +++ b/packages/coding-agent/src/slash-commands/helpers/format.ts @@ -13,7 +13,7 @@ export function formatDuration(ms: number): string { return `${days}d`; } -type ProgressBarTheme = Pick; +type ProgressBarTheme = Pick; const unstyledProgressBarTheme: ProgressBarTheme = { fg(_color, text) { @@ -22,6 +22,9 @@ const unstyledProgressBarTheme: ProgressBarTheme = { bold(text) { return text; }, + getFgAnsi() { + return ""; + }, }; function resolveProgressBarTheme(uiTheme: ProgressBarTheme | undefined): ProgressBarTheme { diff --git a/packages/coding-agent/test/slash-command-format.test.ts b/packages/coding-agent/test/slash-command-format.test.ts index 95e4bc026..807037233 100644 --- a/packages/coding-agent/test/slash-command-format.test.ts +++ b/packages/coding-agent/test/slash-command-format.test.ts @@ -14,6 +14,14 @@ const testTheme = { bold(text: string): string { return `\x1b[1m${text}\x1b[22m`; }, + getFgAnsi(color: Parameters[0]): string { + const codes = { + accent: "\x1b[36m", + dim: "\x1b[2m", + muted: "\x1b[90m", + }; + return codes[color as "accent" | "dim" | "muted"] ?? ""; + }, }; describe("renderAsciiBar", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8bb1057de..4b6d79412 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Updated `Loader` to drive renders at ~60fps (16ms tick) while keeping spinner-frame advancement at 80ms so shimmer/animated message colorizers update smoothly without altering spinner cadence + ## [15.1.9] - 2026-05-21 ### Fixed diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index c08d336a7..b9b2ffca9 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -3,13 +3,27 @@ import { sliceByColumn, visibleWidth } from "../utils"; import { Text } from "./text"; /** - * Loader component that updates every 80ms with spinning animation + * Loader component that drives display refresh at ~60fps so callers whose + * message colorizer is time-dependent (e.g. shimmer/KITT) animate smoothly. + * + * Two cadences are interleaved on a single timer: + * - **Render tick** (every `RENDER_INTERVAL_MS`) → asks the TUI to redraw. + * The TUI already throttles at 16ms (`MIN_RENDER_INTERVAL_MS`), so this + * is the natural upper bound; static messageColorFns produce identical + * output and the differ drops the no-op redraw at ~zero cost. + * - **Spinner advance** (every `SPINNER_ADVANCE_MS`) → bumps the spinner + * frame index. Decoupled from the render cadence so the spinner keeps + * its classic ~12.5fps step pace regardless of shimmer state. */ +const RENDER_INTERVAL_MS = 16; +const SPINNER_ADVANCE_MS = 80; + export class Loader extends Text { #frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; #currentFrame = 0; #intervalId?: NodeJS.Timeout; #ui: TUI | null = null; + #lastSpinnerTick = 0; constructor( ui: TUI, @@ -38,11 +52,16 @@ export class Loader extends Text { } start() { + this.#lastSpinnerTick = performance.now(); this.#updateDisplay(); this.#intervalId = setInterval(() => { - this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length; + const now = performance.now(); + if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) { + this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length; + this.#lastSpinnerTick = now; + } this.#updateDisplay(); - }, 80); + }, RENDER_INTERVAL_MS); } stop() {