@oh-my-pi/pi-coding-agent 15.2.2 → 15.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -601,6 +601,22 @@ export const SETTINGS_SCHEMA = {
601
601
  default: 3,
602
602
  },
603
603
 
604
+ "display.shimmer": {
605
+ type: "enum",
606
+ values: ["classic", "kitt", "disabled"] as const,
607
+ default: "classic",
608
+ ui: {
609
+ tab: "appearance",
610
+ label: "Shimmer",
611
+ description: "Animation style for working/loading messages",
612
+ options: [
613
+ { value: "classic", label: "Classic", description: "Soft cosine wave sweeping across the text" },
614
+ { value: "kitt", label: "KITT Scanner", description: "Knight Rider 1982 red light bouncing left-right" },
615
+ { value: "disabled", label: "Disabled", description: "No animation; static muted text" },
616
+ ],
617
+ },
618
+ },
619
+
604
620
  "display.showTokenUsage": {
605
621
  type: "boolean",
606
622
  default: false,
@@ -1157,10 +1157,11 @@ export class MCPAddWizard extends Container {
1157
1157
  this.#contentContainer.addChild(new Text(theme.fg("success", "✓ Authentication successful!"), 0, 0));
1158
1158
  this.#contentContainer.addChild(new Spacer(1));
1159
1159
  this.#contentContainer.addChild(new Text(theme.fg("muted", "Running connection health check..."), 0, 0));
1160
- const healthText = new Text(theme.fg("muted", "| Checking server connection..."), 0, 0);
1160
+ const spinnerFrames = theme.spinnerFrames;
1161
+ const initialFrame = spinnerFrames[0] ?? "|";
1162
+ const healthText = new Text(theme.fg("muted", `${initialFrame} Checking server connection...`), 0, 0);
1161
1163
  this.#contentContainer.addChild(healthText);
1162
1164
 
1163
- const spinnerFrames = ["|", "/", "-", "\\"];
1164
1165
  let spinnerIndex = 0;
1165
1166
  const spinner = setInterval(() => {
1166
1167
  healthText.setText(
@@ -1168,7 +1169,7 @@ export class MCPAddWizard extends Container {
1168
1169
  );
1169
1170
  spinnerIndex++;
1170
1171
  this.#requestRender();
1171
- }, 120);
1172
+ }, 80);
1172
1173
 
1173
1174
  let healthPassed = true;
1174
1175
  let healthError = "";
@@ -495,14 +495,6 @@ export class SettingsSelectorComponent extends Container {
495
495
  */
496
496
  #showSettingsTab(tabId: SettingTab): void {
497
497
  const defs = getSettingsForTab(tabId);
498
- const items: SettingItem[] = [];
499
-
500
- for (const def of defs) {
501
- const item = this.#defToItem(def);
502
- if (item) {
503
- items.push(item);
504
- }
505
- }
506
498
 
507
499
  // Add status line preview for appearance tab
508
500
  if (tabId === "appearance") {
@@ -516,7 +508,7 @@ export class SettingsSelectorComponent extends Container {
516
508
  }
517
509
 
518
510
  this.#currentList = new SettingsList(
519
- items,
511
+ this.#buildItemsForDefs(defs),
520
512
  10,
521
513
  getSettingsListTheme(),
522
514
  (id, newValue) => {
@@ -537,7 +529,12 @@ export class SettingsSelectorComponent extends Container {
537
529
  settings.set(path, newValue as never);
538
530
  this.callbacks.onChange(path, newValue);
539
531
  }
540
- // Submenu types are handled in createSubmenu
532
+ // Submenu/text types already persisted the value inside their own
533
+ // done callbacks before SettingsList re-dispatches here. Re-run the
534
+ // definition-to-item mapping so condition-gated settings (e.g. the
535
+ // Hindsight cluster guarded by memory.backend) appear/disappear
536
+ // immediately instead of waiting for the next tab switch.
537
+ this.#refreshCurrentTabItems(defs);
541
538
  },
542
539
  () => this.callbacks.onCancel(),
543
540
  );
@@ -545,6 +542,22 @@ export class SettingsSelectorComponent extends Container {
545
542
  this.addChild(this.#currentList);
546
543
  }
547
544
 
545
+ /** Map a definition list to UI items, dropping any whose condition is false. */
546
+ #buildItemsForDefs(defs: SettingDef[]): SettingItem[] {
547
+ const items: SettingItem[] = [];
548
+ for (const def of defs) {
549
+ const item = this.#defToItem(def);
550
+ if (item) items.push(item);
551
+ }
552
+ return items;
553
+ }
554
+
555
+ /** Re-evaluate condition gates against the current settings and refresh the active list. */
556
+ #refreshCurrentTabItems(defs: SettingDef[]): void {
557
+ if (this.#currentTabId === "plugins" || !this.#currentList) return;
558
+ this.#currentList.setItems(this.#buildItemsForDefs(defs));
559
+ }
560
+
548
561
  /**
549
562
  * Get the status line preview string.
550
563
  */
@@ -45,7 +45,7 @@ export class WelcomeComponent implements Component {
45
45
  this.#stopAnimation();
46
46
  }
47
47
  requestRender();
48
- }, INTRO_MS / INTRO_PHASES);
48
+ }, INTRO_TICK_MS);
49
49
  }
50
50
 
51
51
  #stopAnimation(): void {
@@ -253,55 +253,97 @@ export class WelcomeComponent implements Component {
253
253
 
254
254
  /** Pick the logo frame for the current intro phase, or the resting frame. */
255
255
  #currentLogoFrame(): readonly string[] {
256
- if (this.#animStart == null) return LOGO_FRAMES[0];
256
+ if (this.#animStart == null) return REST_FRAME;
257
257
  const elapsed = performance.now() - this.#animStart;
258
- if (elapsed >= INTRO_MS) return LOGO_FRAMES[0];
259
- // Ease-out cubic so the sweep settles into the resting frame instead of
260
- // stopping abruptly. Sweeps backward through the phase ring → lands on 0.
258
+ if (elapsed >= INTRO_MS) return REST_FRAME;
259
+ // Ease-out cubic so the spin decelerates into the resting state.
261
260
  const progress = elapsed / INTRO_MS;
262
261
  const eased = 1 - (1 - progress) ** 3;
263
- const stepsDone = Math.min(INTRO_PHASES - 1, Math.floor(eased * INTRO_PHASES));
264
- const idx = (INTRO_PHASES - stepsDone) % INTRO_PHASES;
265
- return LOGO_FRAMES[idx];
262
+ // Sweep backward through INTRO_SWEEPS full rotations so the gradient
263
+ // visibly spins multiple times. `eased == 1` → phase = 0 = resting frame.
264
+ const phase = ((((1 - eased) * INTRO_SWEEPS) % 1) + 1) % 1;
265
+ // Shine traverses the diagonal at a steady pace, decoupled from the
266
+ // gradient phase so the two layers parallax. Strength fades out with
267
+ // the same ease-out curve so the highlight is gone by the resting frame.
268
+ const shinePos = (((progress * INTRO_SHINE_TRAVERSALS) % 1) + 1) % 1;
269
+ const shineStrength = (1 - eased) ** 1.5;
270
+ return gradientLogo(PI_LOGO, phase, { strength: shineStrength, pos: shinePos });
266
271
  }
267
272
  }
268
273
 
269
274
  // biome-ignore format: preserve ASCII art layout
270
275
  const PI_LOGO = ["▀██████████▀", " ╘██ ██ ", " ██ ██ ", " ██ ██ ", " ▄██▄ ▄██▄ "];
271
276
 
277
+ /** Multi-stop palette for the diagonal gradient. */
278
+ const GRADIENT_STOPS: ReadonlyArray<readonly [number, number, number]> = [
279
+ [255, 92, 200], // hot pink
280
+ [200, 110, 255], // violet
281
+ [120, 130, 255], // periwinkle
282
+ [60, 200, 255], // bright cyan
283
+ [120, 255, 220], // mint
284
+ ];
285
+
286
+ /** 256-color ramp fallback when truecolor isn't available. */
287
+ const GRADIENT_RAMP_256 = [199, 171, 135, 99, 75, 51, 87];
288
+
289
+ /** Half-width of the shine highlight band, expressed in gradient-t units. */
290
+ const SHINE_HALF_WIDTH = 0.18;
291
+
292
+ interface ShineConfig {
293
+ /** Overall opacity of the shine overlay, in [0, 1]. */
294
+ strength: number;
295
+ /** Center of the shine band along the diagonal, in [0, 1]. */
296
+ pos: number;
297
+ }
298
+
272
299
  /**
273
- * Apply magenta→cyan diagonal gradient (bottom-left → top-right) across multi-line art.
274
- * `phase` (0..1) shifts the gradient along the diagonal, wrapping at 1.
300
+ * Apply a multi-stop diagonal gradient (bottom-left → top-right) plus an
301
+ * optional sliding shine band across multi-line art. `phase` (0..1) shifts the
302
+ * gradient along the diagonal, wrapping at 1. When `shine` is provided, a soft
303
+ * white highlight is composited on top, centered at `shine.pos`.
275
304
  */
276
- function gradientLogo(lines: readonly string[], phase = 0): string[] {
305
+ function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig): string[] {
277
306
  const reset = "\x1b[0m";
278
307
  const rows = lines.length;
279
308
  const cols = Math.max(...lines.map(l => l.length));
280
309
  // span+1 so `base` stays strictly < 1: avoids the wrap-around at the
281
- // far corner mapping back to t=0 (magenta) on the resting frame.
310
+ // far corner mapping back to t=0 (hot pink) on the resting frame.
282
311
  const span = Math.max(1, cols + rows - 1);
312
+ const shineStrength = shine && shine.strength > 0 ? shine.strength : 0;
313
+ const shinePos = shine ? shine.pos : 0;
283
314
  const colorAt = TERMINAL.trueColor
284
315
  ? (t: number): string => {
285
- // Multi-stop gradient: hot magenta light violet bright cyan.
286
- // Picked stops avoid the deep-blue valley a naive HSL lerp falls into.
287
- const stops: [number, number, number][] = [
288
- [255, 62, 201], // hot magenta-pink
289
- [180, 120, 255], // light violet
290
- [62, 230, 255], // bright cyan
291
- ];
316
+ // 5-stop palette widens the visible color range and avoids the
317
+ // deep-blue valley a naive HSL lerp falls into.
318
+ const stops = GRADIENT_STOPS;
292
319
  const seg = t * (stops.length - 1);
293
320
  const i = Math.min(stops.length - 2, Math.floor(seg));
294
321
  const f = seg - i;
295
322
  const a = stops[i];
296
323
  const b = stops[i + 1];
297
- const r = Math.round(a[0] + (b[0] - a[0]) * f);
298
- const g = Math.round(a[1] + (b[1] - a[1]) * f);
299
- const bl = Math.round(a[2] + (b[2] - a[2]) * f);
300
- return `\x1b[38;2;${r};${g};${bl}m`;
324
+ let r = a[0] + (b[0] - a[0]) * f;
325
+ let g = a[1] + (b[1] - a[1]) * f;
326
+ let bl = a[2] + (b[2] - a[2]) * f;
327
+ if (shineStrength > 0) {
328
+ const dist = Math.abs(t - shinePos);
329
+ const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
330
+ if (intensity > 0) {
331
+ r += (255 - r) * intensity;
332
+ g += (255 - g) * intensity;
333
+ bl += (255 - bl) * intensity;
334
+ }
335
+ }
336
+ return `\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(bl)}m`;
301
337
  }
302
338
  : (t: number): string => {
303
- const ramp = [199, 171, 135, 99, 75, 51];
304
- const idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
339
+ const ramp = GRADIENT_RAMP_256;
340
+ let idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
341
+ if (shineStrength > 0) {
342
+ const dist = Math.abs(t - shinePos);
343
+ const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
344
+ // Promote to the brightest ramp slot when the shine band peaks here.
345
+ if (intensity > 0.5) idx = ramp.length - 1;
346
+ }
305
347
  return `\x1b[38;5;${ramp[idx]}m`;
306
348
  };
307
349
  return lines.map((line, y) => {
@@ -321,14 +363,14 @@ function gradientLogo(lines: readonly string[], phase = 0): string[] {
321
363
  });
322
364
  }
323
365
 
324
- /** Intro animation: how many discrete gradient phases and total duration. */
325
- const INTRO_PHASES = 60;
326
- const INTRO_MS = 2000;
366
+ /** Total length of the intro animation. */
367
+ const INTRO_MS = 3000;
368
+ /** Render cadence during the intro (~30fps). */
369
+ const INTRO_TICK_MS = 33;
370
+ /** Number of full gradient rotations the sweep performs before settling. */
371
+ const INTRO_SWEEPS = 2.5;
372
+ /** Number of times the shine highlight crosses the diagonal across the intro. */
373
+ const INTRO_SHINE_TRAVERSALS = 3;
327
374
 
328
- /**
329
- * Pre-rendered logo frames, one per phase. Frame 0 is the resting state;
330
- * the intro sweeps frames in reverse so it lands on frame 0.
331
- */
332
- const LOGO_FRAMES: readonly (readonly string[])[] = Array.from({ length: INTRO_PHASES }, (_, i) =>
333
- gradientLogo(PI_LOGO, i / INTRO_PHASES),
334
- );
375
+ /** Resting gradient frame, cached for re-renders outside of the intro. */
376
+ const REST_FRAME = gradientLogo(PI_LOGO, 0);
@@ -763,17 +763,18 @@ export class MCPCommandController {
763
763
  if (!this.ctx.mcpManager) return "disconnected";
764
764
 
765
765
  this.ctx.chatContainer.addChild(new Spacer(1));
766
- const statusText = new Text(theme.fg("muted", `| Connecting to "${name}"...`), 1, 0);
766
+ const frames = theme.spinnerFrames;
767
+ const initialFrame = frames[0] ?? "|";
768
+ const statusText = new Text(theme.fg("muted", `${initialFrame} Connecting to "${name}"...`), 1, 0);
767
769
  this.ctx.chatContainer.addChild(statusText);
768
770
  this.ctx.ui.requestRender();
769
771
 
770
- const frames = ["|", "/", "-", "\\"];
771
772
  let frame = 0;
772
773
  const interval = setInterval(() => {
773
774
  statusText.setText(theme.fg("muted", `${frames[frame % frames.length]} Connecting to "${name}"...`));
774
775
  frame++;
775
776
  this.ctx.ui.requestRender();
776
- }, 120);
777
+ }, 80);
777
778
 
778
779
  try {
779
780
  try {
@@ -1,18 +1,36 @@
1
+ import { isSettingsInitialized, settings } from "../../config/settings";
1
2
  import type { Theme, ThemeColor } from "./theme";
2
3
 
3
- const SHIMMER_PADDING = 10;
4
- const SHIMMER_SWEEP_MS = 2000;
5
- const SHIMMER_BAND_HALF_WIDTH = 5;
4
+ // ─── Classic sweep tunables ──────────────────────────────────────────────────
5
+ const CLASSIC_PADDING = 10;
6
+ const CLASSIC_SWEEP_MS = 1400;
7
+ const CLASSIC_BAND_HALF_WIDTH = 6;
6
8
 
7
- type ShimmerTheme = Pick<Theme, "bold" | "fg">;
9
+ // ─── KITT scanner tunables ───────────────────────────────────────────────────
10
+ // 1.5s round trip ≈ classic 1982 K.I.T.T. scanner cadence (~0.75s per direction).
11
+ const KITT_CYCLE_MS = 1500;
12
+ const KITT_HEAD_HALF = 0.6;
13
+ const KITT_TRAIL_LEN = 7;
14
+
15
+ // ─── Tier thresholds ─────────────────────────────────────────────────────────
16
+ const TIER_HIGH = 0.65;
17
+ const TIER_MID = 0.22;
18
+
19
+ // ─── Raw ANSI codes ──────────────────────────────────────────────────────────
20
+ const FG_RESET = "\x1b[39m";
21
+ const BOLD_OPEN = "\x1b[1m";
22
+ const BOLD_CLOSE = "\x1b[22m";
23
+
24
+ type ShimmerTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
25
+ type ShimmerMode = "classic" | "kitt" | "disabled";
8
26
 
9
27
  /** Three-tier color stack a shimmer character cycles through as the band sweeps. */
10
28
  export interface ShimmerPalette {
11
- /** Color for chars outside / at the edge of the band (intensity < 0.2). */
29
+ /** Color for chars outside / at the edge of the band (intensity < ~0.22). */
12
30
  low: ThemeColor;
13
- /** Color for chars approaching the crest (0.2 <= intensity < 0.6). */
31
+ /** Color for chars approaching the crest (~0.22 intensity < ~0.65). */
14
32
  mid: ThemeColor;
15
- /** Color at the band's crest (intensity >= 0.6). */
33
+ /** Color at the band's crest (intensity ~0.65). */
16
34
  high: ThemeColor;
17
35
  /** Whether to bold the crest tier. Default `false`. */
18
36
  bold?: boolean;
@@ -31,47 +49,160 @@ export const DEFAULT_SHIMMER_PALETTE: ShimmerPalette = {
31
49
  bold: true,
32
50
  };
33
51
 
34
- function shimmerIntensity(index: number, length: number): number {
35
- const period = length + SHIMMER_PADDING * 2;
36
- const pos = Math.floor(((Date.now() % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS) * period);
37
- const dist = Math.abs(index + SHIMMER_PADDING - pos);
38
- if (dist > SHIMMER_BAND_HALF_WIDTH) return 0;
52
+ // ─── Palette compilation cache ───────────────────────────────────────────────
53
+ // Resolving ANSI codes for every character was the dominant per-frame cost.
54
+ // We resolve once per (theme, palette) pair into ready-to-concat prefix/suffix
55
+ // strings, then coalesce same-tier runs at render time so each frame emits a
56
+ // handful of escape sequences instead of one per code point.
57
+ //
58
+ // The cache is stashed as a Symbol-keyed slot directly on the palette object
59
+ // — no module-level sidecar — and invalidates when the active Theme changes.
60
+ interface TierSeq {
61
+ open: string;
62
+ close: string;
63
+ }
64
+ interface CompiledPalette {
65
+ low: TierSeq;
66
+ mid: TierSeq;
67
+ high: TierSeq;
68
+ }
69
+
70
+ const kCompiledFor = Symbol("shimmer.compiledFor");
71
+ const kCompiled = Symbol("shimmer.compiled");
72
+ interface PaletteCache {
73
+ [kCompiledFor]?: ShimmerTheme;
74
+ [kCompiled]?: CompiledPalette;
75
+ }
76
+
77
+ function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette {
78
+ const p = palette as ShimmerPalette & PaletteCache;
79
+ const cached = p[kCompiled];
80
+ if (cached && p[kCompiledFor] === theme) return cached;
81
+ const highOpen = palette.bold ? `${BOLD_OPEN}${theme.getFgAnsi(palette.high)}` : theme.getFgAnsi(palette.high);
82
+ const highClose = palette.bold ? `${BOLD_CLOSE}${FG_RESET}` : FG_RESET;
83
+ const out: CompiledPalette = {
84
+ low: { open: theme.getFgAnsi(palette.low), close: FG_RESET },
85
+ mid: { open: theme.getFgAnsi(palette.mid), close: FG_RESET },
86
+ high: { open: highOpen, close: highClose },
87
+ };
88
+ p[kCompiledFor] = theme;
89
+ p[kCompiled] = out;
90
+ return out;
91
+ }
92
+
93
+ // ─── Intensity profiles ──────────────────────────────────────────────────────
94
+ /** Smooth cosine bump sweeping left → right with edge padding. */
95
+ function classicIntensity(time: number, index: number, length: number): number {
96
+ const period = length + CLASSIC_PADDING * 2;
97
+ // Fractional position — kept un-floored so the band glides at the host's
98
+ // frame rate instead of stepping discretely.
99
+ const pos = ((time % CLASSIC_SWEEP_MS) / CLASSIC_SWEEP_MS) * period;
100
+ const dist = Math.abs(index + CLASSIC_PADDING - pos);
101
+ if (dist >= CLASSIC_BAND_HALF_WIDTH) return 0;
102
+ return 0.5 * (1 + Math.cos((Math.PI * dist) / CLASSIC_BAND_HALF_WIDTH));
103
+ }
104
+
105
+ /**
106
+ * Knight Rider K.I.T.T. scanner: a single bright head ping-pongs across the
107
+ * bar with a quadratic-decay trail behind it. No leading glow — LEDs don't
108
+ * predict the future.
109
+ */
110
+ function kittIntensity(time: number, index: number, length: number): number {
111
+ const range = length - 1;
112
+ if (range <= 0) return 1;
113
+ const phase = (time % KITT_CYCLE_MS) / KITT_CYCLE_MS;
114
+ const goingRight = phase < 0.5;
115
+ const head = goingRight ? phase * 2 * range : (1 - phase) * 2 * range;
116
+ const delta = index - head;
117
+ const abs = delta < 0 ? -delta : delta;
118
+ if (abs <= KITT_HEAD_HALF) return 1;
119
+ // Only chars *behind* the head light up — direction-dependent.
120
+ const behind = goingRight ? -delta : delta;
121
+ if (behind <= KITT_HEAD_HALF) return 0;
122
+ const t = (behind - KITT_HEAD_HALF) / KITT_TRAIL_LEN;
123
+ if (t >= 1) return 0;
124
+ const f = 1 - t;
125
+ return f * f;
126
+ }
127
+
128
+ type Tier = "low" | "mid" | "high";
39
129
 
40
- const x = Math.PI * (dist / SHIMMER_BAND_HALF_WIDTH);
41
- return 0.5 * (1 + Math.cos(x));
130
+ function tierFor(intensity: number): Tier {
131
+ if (intensity >= TIER_HIGH) return "high";
132
+ if (intensity >= TIER_MID) return "mid";
133
+ return "low";
42
134
  }
43
135
 
44
- function styleShimmerChar(ch: string, intensity: number, theme: ShimmerTheme, palette: ShimmerPalette): string {
45
- if (intensity < 0.2) return theme.fg(palette.low, ch);
46
- if (intensity < 0.6) return theme.fg(palette.mid, ch);
47
- const styled = theme.fg(palette.high, ch);
48
- return palette.bold ? theme.bold(styled) : styled;
136
+ function resolveMode(): ShimmerMode {
137
+ if (!isSettingsInitialized()) return "classic";
138
+ return settings.get("display.shimmer");
49
139
  }
50
140
 
51
141
  /**
52
- * Apply a shimmer sweep across one or more segments, treating them as a single
53
- * continuous string for band positioning. Each segment can supply its own
54
- * palette so the gradient stays in lockstep while the colors differ.
142
+ * Apply a shimmer sweep across one or more segments, treating them as a
143
+ * single continuous string for band positioning. Each segment can supply
144
+ * its own palette so the gradient stays in lockstep while the colors
145
+ * differ.
146
+ *
147
+ * Performance shape (per call, dominant cost):
148
+ * - One `Date.now()` read.
149
+ * - One `compile()` lookup per segment (Symbol-keyed cache slot, hot path
150
+ * skipped after first frame).
151
+ * - One ANSI open/close pair per **run of same-tier chars**, not per char.
152
+ * - No per-char allocations beyond the run buffer.
55
153
  */
56
154
  export function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string {
155
+ const mode = resolveMode();
156
+
157
+ // Pre-scan: total code-point count (positions the band) and resolved palette.
57
158
  let total = 0;
58
- const expanded: Array<{ chars: string[]; palette: ShimmerPalette }> = [];
159
+ const perSeg: { chars: string[]; palette: ShimmerPalette }[] = [];
59
160
  for (const seg of segments) {
60
- const chars = [...seg.text];
161
+ const chars = Array.from(seg.text);
61
162
  total += chars.length;
62
- expanded.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
163
+ perSeg.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
63
164
  }
64
165
  if (total === 0) return "";
65
166
 
66
- const out: string[] = [];
167
+ // Disabled: no animation, no per-char work. Paint each segment in its mid
168
+ // tier so the working line stays legible without movement.
169
+ if (mode === "disabled") {
170
+ let out = "";
171
+ for (const { chars, palette } of perSeg) {
172
+ const seq = compile(theme, palette).mid;
173
+ out += `${seq.open}${chars.join("")}${seq.close}`;
174
+ }
175
+ return out;
176
+ }
177
+
178
+ const time = Date.now();
179
+ const intensityFn = mode === "kitt" ? kittIntensity : classicIntensity;
180
+
181
+ let out = "";
67
182
  let index = 0;
68
- for (const { chars, palette } of expanded) {
69
- for (const ch of chars) {
70
- out.push(styleShimmerChar(ch, shimmerIntensity(index, total), theme, palette));
183
+ for (const { chars, palette } of perSeg) {
184
+ const compiled = compile(theme, palette);
185
+ let runTier: Tier | null = null;
186
+ let runBuf = "";
187
+ for (let i = 0; i < chars.length; i++) {
188
+ const tier = tierFor(intensityFn(time, index, total));
189
+ if (tier !== runTier) {
190
+ if (runTier !== null) {
191
+ const seq = compiled[runTier];
192
+ out += `${seq.open}${runBuf}${seq.close}`;
193
+ runBuf = "";
194
+ }
195
+ runTier = tier;
196
+ }
197
+ runBuf += chars[i];
71
198
  index++;
72
199
  }
200
+ if (runTier !== null && runBuf.length > 0) {
201
+ const seq = compiled[runTier];
202
+ out += `${seq.open}${runBuf}${seq.close}`;
203
+ }
73
204
  }
74
- return out.join("");
205
+ return out;
75
206
  }
76
207
 
77
208
  export function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string {
@@ -113,21 +113,39 @@ export class UiHelpers {
113
113
  type?: "bash" | "task";
114
114
  label?: string;
115
115
  durationMs?: number;
116
+ jobs?: Array<{
117
+ jobId?: string;
118
+ type?: "bash" | "task";
119
+ label?: string;
120
+ durationMs?: number;
121
+ }>;
116
122
  }>
117
123
  ).details;
118
- const jobId = details?.jobId ?? "unknown";
119
- const typeLabel = details?.type ? `[${details.type}]` : "[job]";
120
- const duration =
121
- typeof details?.durationMs === "number" ? formatDuration(details.durationMs) : undefined;
122
- const line = [
123
- theme.fg("success", `${theme.status.success} Background job completed`),
124
- theme.fg("dim", typeLabel),
125
- theme.fg("accent", jobId),
126
- duration ? theme.fg("dim", `(${duration})`) : undefined,
127
- ]
128
- .filter(Boolean)
129
- .join(" ");
130
- this.ctx.chatContainer.addChild(new Text(line, 1, 0));
124
+ const jobs =
125
+ details?.jobs && details.jobs.length > 0
126
+ ? details.jobs
127
+ : [
128
+ {
129
+ jobId: details?.jobId,
130
+ type: details?.type,
131
+ label: details?.label,
132
+ durationMs: details?.durationMs,
133
+ },
134
+ ];
135
+ for (const job of jobs) {
136
+ const jobId = job.jobId ?? "unknown";
137
+ const typeLabel = job.type ? `[${job.type}]` : "[job]";
138
+ const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
139
+ const line = [
140
+ theme.fg("success", `${theme.status.success} Background job completed`),
141
+ theme.fg("dim", typeLabel),
142
+ theme.fg("accent", jobId),
143
+ duration ? theme.fg("dim", `(${duration})`) : undefined,
144
+ ]
145
+ .filter(Boolean)
146
+ .join(" ");
147
+ this.ctx.chatContainer.addChild(new Text(line, 1, 0));
148
+ }
131
149
  break;
132
150
  }
133
151
  if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
@@ -1,5 +1,8 @@
1
1
  <system-notice>
2
- Background job {{jobId}} has completed. Resume your work using the result below.
2
+ {{#if multiple}}{{jobs.length}} background jobs have completed. Resume your work using the results below.
3
3
 
4
- {{result}}
4
+ {{else}}Background job {{jobs.[0].jobId}} has completed. Resume your work using the result below.
5
+ {{/if}}{{#each jobs}}{{#if @root.multiple}}── Job {{this.jobId}}{{#if this.label}} ({{this.label}}){{/if}} ──
6
+ {{/if}}{{this.result}}{{#unless @last}}
7
+ {{/unless}}{{/each}}
5
8
  </system-notice>