feat(coding-agent): added shimmer modes and tuned loader timing cadence

- Added `display.shimmer` setting (`classic`, `kitt`, `disabled`) with default `classic` and UI metadata.
- Added shimmer mode resolution with defaulting plus classic/KITT intensity tier profiles and thresholds.
- Added `getFgAnsi` handling across shimmer compilation and progress-bar themes, with fallback ANSI output defaults.
- Reworked shimmer rendering to coalesce same-tier segments, cache palettes by symbol, and map disabled mode to mid-tier.
- Tuned loader timing to a 16ms render interval and 80ms spinner stepping for smoother ~60fps updates.
This commit is contained in:
can1357
2026-05-22 13:03:03 +09:00
parent 1228c96959
commit e39b48c405
6 changed files with 216 additions and 35 deletions
@@ -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,
+162 -31
View File
@@ -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<Theme, "bold" | "fg">;
// ─── 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<Theme, "bold" | "fg" | "getFgAnsi">;
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 {
@@ -13,7 +13,7 @@ export function formatDuration(ms: number): string {
return `${days}d`;
}
type ProgressBarTheme = Pick<Theme, "bold" | "fg">;
type ProgressBarTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
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 {
@@ -14,6 +14,14 @@ const testTheme = {
bold(text: string): string {
return `\x1b[1m${text}\x1b[22m`;
},
getFgAnsi(color: Parameters<Theme["fg"]>[0]): string {
const codes = {
accent: "\x1b[36m",
dim: "\x1b[2m",
muted: "\x1b[90m",
};
return codes[color as "accent" | "dim" | "muted"] ?? "";
},
};
describe("renderAsciiBar", () => {
+4
View File
@@ -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
+22 -3
View File
@@ -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() {