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

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/types/cli/worktree-cli.d.ts +26 -0
  3. package/dist/types/commands/worktree.d.ts +34 -0
  4. package/dist/types/config/settings-schema.d.ts +23 -0
  5. package/dist/types/hashline/constants.d.ts +0 -2
  6. package/dist/types/hashline/hash.d.ts +13 -39
  7. package/dist/types/hashline/parser.d.ts +2 -6
  8. package/dist/types/modes/shared.d.ts +9 -0
  9. package/dist/types/modes/theme/shimmer.d.ts +21 -10
  10. package/dist/types/session/agent-session.d.ts +2 -0
  11. package/dist/types/session/yield-queue.d.ts +24 -0
  12. package/dist/types/slash-commands/helpers/format.d.ts +1 -1
  13. package/dist/types/task/worktree.d.ts +0 -1
  14. package/dist/types/utils/git.d.ts +1 -0
  15. package/package.json +7 -7
  16. package/src/autoresearch/storage.ts +14 -2
  17. package/src/cli/worktree-cli.ts +291 -0
  18. package/src/cli.ts +1 -0
  19. package/src/commands/worktree.ts +56 -0
  20. package/src/config/prompt-templates.ts +1 -8
  21. package/src/config/settings-schema.ts +16 -0
  22. package/src/edit/index.ts +1 -1
  23. package/src/edit/renderer.ts +5 -7
  24. package/src/edit/streaming.ts +24 -12
  25. package/src/hashline/constants.ts +0 -3
  26. package/src/hashline/diff.ts +1 -1
  27. package/src/hashline/execute.ts +2 -2
  28. package/src/hashline/grammar.lark +7 -8
  29. package/src/hashline/hash.ts +21 -43
  30. package/src/hashline/input.ts +15 -13
  31. package/src/hashline/parser.ts +62 -161
  32. package/src/internal-urls/docs-index.generated.ts +2 -2
  33. package/src/modes/components/mcp-add-wizard.ts +4 -3
  34. package/src/modes/components/settings-selector.ts +23 -10
  35. package/src/modes/components/welcome.ts +77 -35
  36. package/src/modes/controllers/event-controller.ts +2 -1
  37. package/src/modes/controllers/mcp-command-controller.ts +4 -3
  38. package/src/modes/interactive-mode.ts +51 -10
  39. package/src/modes/shared.ts +16 -0
  40. package/src/modes/theme/shimmer.ts +173 -33
  41. package/src/modes/utils/ui-helpers.ts +31 -13
  42. package/src/prompts/tools/async-result.md +5 -2
  43. package/src/prompts/tools/hashline.md +62 -81
  44. package/src/sdk.ts +95 -21
  45. package/src/session/agent-session.ts +22 -0
  46. package/src/session/yield-queue.ts +155 -0
  47. package/src/slash-commands/helpers/format.ts +4 -1
  48. package/src/task/worktree.ts +2 -7
  49. package/src/tools/gh.ts +35 -32
  50. package/src/utils/commit-message-generator.ts +6 -1
  51. package/src/utils/git.ts +4 -0
  52. package/src/utils/title-generator.ts +45 -13
@@ -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);
@@ -18,6 +18,7 @@ import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
18
18
  import type { AgentSessionEvent } from "../../session/agent-session";
19
19
  import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
20
20
  import type { ResolveToolDetails } from "../../tools/resolve";
21
+ import { interruptHint } from "../shared";
21
22
 
22
23
  type AgentSessionEventKind = AgentSessionEvent["type"];
23
24
 
@@ -133,7 +134,7 @@ export class EventController {
133
134
  const trimmed = intent.trim();
134
135
  if (!trimmed || trimmed === this.#lastIntent) return;
135
136
  this.#lastIntent = trimmed;
136
- this.ctx.setWorkingMessage(`${trimmed} (esc to interrupt)`);
137
+ this.ctx.setWorkingMessage(`${trimmed}${interruptHint()}`);
137
138
  }
138
139
 
139
140
  subscribeToAgent(): void {
@@ -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 {
@@ -26,7 +26,7 @@ import {
26
26
  TUI,
27
27
  visibleWidth,
28
28
  } from "@oh-my-pi/pi-tui";
29
- import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
29
+ import { APP_NAME, adjustHsv, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
30
30
  import chalk from "chalk";
31
31
  import { KeybindingsManager } from "../config/keybindings";
32
32
  import { isSettingsInitialized, Settings, settings } from "../config/settings";
@@ -98,6 +98,7 @@ import {
98
98
  } from "./loop-limit";
99
99
  import { OAuthManualInputManager } from "./oauth-manual-input";
100
100
  import { SessionObserverRegistry } from "./session-observer-registry";
101
+ import { interruptHint } from "./shared";
101
102
  import { type ShimmerPalette, shimmerSegments, shimmerText } from "./theme/shimmer";
102
103
  import type { Theme } from "./theme/theme";
103
104
  import {
@@ -111,18 +112,43 @@ import {
111
112
  import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
112
113
  import { UiHelpers } from "./utils/ui-helpers";
113
114
 
114
- const WORKING_INTERRUPT_HINT = " (esc to interrupt)";
115
-
116
115
  const HINT_SHIMMER_PALETTE: ShimmerPalette = {
117
116
  low: "dim",
118
117
  mid: "muted",
119
118
  high: "borderAccent",
120
119
  };
121
120
 
122
- function renderWorkingMessage(message: string): string {
123
- if (!message.endsWith(WORKING_INTERRUPT_HINT)) return shimmerText(message, theme);
124
- const header = message.slice(0, -WORKING_INTERRUPT_HINT.length);
125
- return shimmerSegments([{ text: header }, { text: WORKING_INTERRUPT_HINT, palette: HINT_SHIMMER_PALETTE }], theme);
121
+ interface WorkingMessageAccent {
122
+ main: string;
123
+ dim: string;
124
+ }
125
+
126
+ function renderWorkingMessage(message: string, accent?: WorkingMessageAccent): string {
127
+ const palette = accent
128
+ ? ({
129
+ low: "dim",
130
+ mid: { ansi: accent.main },
131
+ high: { ansi: accent.main },
132
+ bold: true,
133
+ } satisfies ShimmerPalette)
134
+ : undefined;
135
+ const hint = interruptHint();
136
+ if (!message.endsWith(hint)) return shimmerText(message, theme, palette);
137
+ const header = message.slice(0, -hint.length);
138
+ const hintPalette = accent
139
+ ? ({
140
+ low: "dim",
141
+ mid: { ansi: accent.dim },
142
+ high: { ansi: accent.dim },
143
+ } satisfies ShimmerPalette)
144
+ : HINT_SHIMMER_PALETTE;
145
+ return shimmerSegments(
146
+ [
147
+ { text: header, palette },
148
+ { text: hint, palette: hintPalette },
149
+ ],
150
+ theme,
151
+ );
126
152
  }
127
153
 
128
154
  const EDITOR_MAX_HEIGHT_MIN = 6;
@@ -232,7 +258,9 @@ export class InteractiveMode implements InteractiveModeContext {
232
258
  autoCompactionLoader: Loader | undefined = undefined;
233
259
  retryLoader: Loader | undefined = undefined;
234
260
  #pendingWorkingMessage: string | undefined;
235
- readonly #defaultWorkingMessage = `Working… (esc to interrupt)`;
261
+ get #defaultWorkingMessage(): string {
262
+ return `Working…${interruptHint()}`;
263
+ }
236
264
  autoCompactionEscapeHandler?: () => void;
237
265
  retryEscapeHandler?: () => void;
238
266
  unsubscribe?: () => void;
@@ -2189,13 +2217,26 @@ export class InteractiveMode implements InteractiveModeContext {
2189
2217
  this.ui.requestRender();
2190
2218
  }
2191
2219
 
2220
+ #getWorkingMessageAccent(): WorkingMessageAccent | undefined {
2221
+ const accentEnabled = !isSettingsInitialized() || settings.get("statusLine.sessionAccent") !== false;
2222
+ const sessionName = accentEnabled ? this.sessionManager.getSessionName() : undefined;
2223
+ if (!sessionName) return undefined;
2224
+ const hex = getSessionAccentHex(sessionName);
2225
+ const main = getSessionAccentAnsi(hex);
2226
+ const dim = getSessionAccentAnsi(adjustHsv(hex, { s: 0.55, v: 0.65 }));
2227
+ return main && dim ? { main, dim } : undefined;
2228
+ }
2229
+
2192
2230
  ensureLoadingAnimation(): void {
2193
2231
  if (!this.loadingAnimation) {
2194
2232
  this.statusContainer.clear();
2195
2233
  this.loadingAnimation = new Loader(
2196
2234
  this.ui,
2197
- spinner => theme.fg("accent", spinner),
2198
- renderWorkingMessage,
2235
+ spinner => {
2236
+ const accent = this.#getWorkingMessageAccent();
2237
+ return accent ? `${accent.main}${spinner}\x1b[39m` : theme.fg("accent", spinner);
2238
+ },
2239
+ message => renderWorkingMessage(message, this.#getWorkingMessageAccent()),
2199
2240
  this.#defaultWorkingMessage,
2200
2241
  getSymbolTheme().spinnerFrames,
2201
2242
  );
@@ -36,4 +36,20 @@ export function getTabBarTheme(): TabBarTheme {
36
36
  };
37
37
  }
38
38
 
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+ // Working-message hint
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+
43
+ /**
44
+ * Suffix appended to the loader's working message to remind users they can
45
+ * abort with Esc. Rendered with the active theme's bracket glyphs so it stays
46
+ * visually consistent with badges and other bracketed UI affordances.
47
+ *
48
+ * The leading space separates the hint from the message body and is consumed
49
+ * by `endsWith`/`slice` matching in the loader renderer.
50
+ */
51
+ export function interruptHint(): string {
52
+ return ` ${theme.format.bracketLeft}esc${theme.format.bracketRight}`;
53
+ }
54
+
39
55
  export { parseCommandArgs } from "../utils/command-args";
@@ -1,19 +1,43 @@
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";
26
+
27
+ type ShimmerPaletteTier = ThemeColor | { ansi: string };
28
+
29
+ function resolveTierAnsi(theme: ShimmerTheme, tier: ShimmerPaletteTier): string {
30
+ return typeof tier === "string" ? theme.getFgAnsi(tier) : tier.ansi;
31
+ }
8
32
 
9
33
  /** Three-tier color stack a shimmer character cycles through as the band sweeps. */
10
34
  export interface ShimmerPalette {
11
- /** Color for chars outside / at the edge of the band (intensity < 0.2). */
12
- low: ThemeColor;
13
- /** Color for chars approaching the crest (0.2 <= intensity < 0.6). */
14
- mid: ThemeColor;
15
- /** Color at the band's crest (intensity >= 0.6). */
16
- high: ThemeColor;
35
+ /** Color for chars outside / at the edge of the band (intensity < ~0.22). */
36
+ low: ShimmerPaletteTier;
37
+ /** Color for chars approaching the crest (~0.22 intensity < ~0.65). */
38
+ mid: ShimmerPaletteTier;
39
+ /** Color at the band's crest (intensity ~0.65). */
40
+ high: ShimmerPaletteTier;
17
41
  /** Whether to bold the crest tier. Default `false`. */
18
42
  bold?: boolean;
19
43
  }
@@ -31,47 +55,163 @@ export const DEFAULT_SHIMMER_PALETTE: ShimmerPalette = {
31
55
  bold: true,
32
56
  };
33
57
 
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;
58
+ // ─── Palette compilation cache ───────────────────────────────────────────────
59
+ // Resolving ANSI codes for every character was the dominant per-frame cost.
60
+ // We resolve once per (theme, palette) pair into ready-to-concat prefix/suffix
61
+ // strings, then coalesce same-tier runs at render time so each frame emits a
62
+ // handful of escape sequences instead of one per code point.
63
+ //
64
+ // The cache is stashed as a Symbol-keyed slot directly on the palette object
65
+ // — no module-level sidecar — and invalidates when the active Theme changes.
66
+ interface TierSeq {
67
+ open: string;
68
+ close: string;
69
+ }
70
+ interface CompiledPalette {
71
+ low: TierSeq;
72
+ mid: TierSeq;
73
+ high: TierSeq;
74
+ }
75
+
76
+ const kCompiledFor = Symbol("shimmer.compiledFor");
77
+ const kCompiled = Symbol("shimmer.compiled");
78
+ interface PaletteCache {
79
+ [kCompiledFor]?: ShimmerTheme;
80
+ [kCompiled]?: CompiledPalette;
81
+ }
39
82
 
40
- const x = Math.PI * (dist / SHIMMER_BAND_HALF_WIDTH);
41
- return 0.5 * (1 + Math.cos(x));
83
+ function compile(theme: ShimmerTheme, palette: ShimmerPalette): CompiledPalette {
84
+ const p = palette as ShimmerPalette & PaletteCache;
85
+ const cached = p[kCompiled];
86
+ if (cached && p[kCompiledFor] === theme) return cached;
87
+ const lowOpen = resolveTierAnsi(theme, palette.low);
88
+ const midOpen = resolveTierAnsi(theme, palette.mid);
89
+ const highColorOpen = resolveTierAnsi(theme, palette.high);
90
+ const highOpen = palette.bold ? `${BOLD_OPEN}${highColorOpen}` : highColorOpen;
91
+ const highClose = palette.bold ? `${BOLD_CLOSE}${FG_RESET}` : FG_RESET;
92
+ const out: CompiledPalette = {
93
+ low: { open: lowOpen, close: FG_RESET },
94
+ mid: { open: midOpen, close: FG_RESET },
95
+ high: { open: highOpen, close: highClose },
96
+ };
97
+ p[kCompiledFor] = theme;
98
+ p[kCompiled] = out;
99
+ return out;
42
100
  }
43
101
 
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;
102
+ // ─── Intensity profiles ──────────────────────────────────────────────────────
103
+ /** Smooth cosine bump sweeping left → right with edge padding. */
104
+ function classicIntensity(time: number, index: number, length: number): number {
105
+ const period = length + CLASSIC_PADDING * 2;
106
+ // Fractional position kept un-floored so the band glides at the host's
107
+ // frame rate instead of stepping discretely.
108
+ const pos = ((time % CLASSIC_SWEEP_MS) / CLASSIC_SWEEP_MS) * period;
109
+ const dist = Math.abs(index + CLASSIC_PADDING - pos);
110
+ if (dist >= CLASSIC_BAND_HALF_WIDTH) return 0;
111
+ return 0.5 * (1 + Math.cos((Math.PI * dist) / CLASSIC_BAND_HALF_WIDTH));
49
112
  }
50
113
 
51
114
  /**
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.
115
+ * Knight Rider K.I.T.T. scanner: a single bright head ping-pongs across the
116
+ * bar with a quadratic-decay trail behind it. No leading glow LEDs don't
117
+ * predict the future.
118
+ */
119
+ function kittIntensity(time: number, index: number, length: number): number {
120
+ const range = length - 1;
121
+ if (range <= 0) return 1;
122
+ const phase = (time % KITT_CYCLE_MS) / KITT_CYCLE_MS;
123
+ const goingRight = phase < 0.5;
124
+ const head = goingRight ? phase * 2 * range : (1 - phase) * 2 * range;
125
+ const delta = index - head;
126
+ const abs = delta < 0 ? -delta : delta;
127
+ if (abs <= KITT_HEAD_HALF) return 1;
128
+ // Only chars *behind* the head light up — direction-dependent.
129
+ const behind = goingRight ? -delta : delta;
130
+ if (behind <= KITT_HEAD_HALF) return 0;
131
+ const t = (behind - KITT_HEAD_HALF) / KITT_TRAIL_LEN;
132
+ if (t >= 1) return 0;
133
+ const f = 1 - t;
134
+ return f * f;
135
+ }
136
+
137
+ type Tier = "low" | "mid" | "high";
138
+
139
+ function tierFor(intensity: number): Tier {
140
+ if (intensity >= TIER_HIGH) return "high";
141
+ if (intensity >= TIER_MID) return "mid";
142
+ return "low";
143
+ }
144
+
145
+ function resolveMode(): ShimmerMode {
146
+ if (!isSettingsInitialized()) return "classic";
147
+ return settings.get("display.shimmer");
148
+ }
149
+
150
+ /**
151
+ * Apply a shimmer sweep across one or more segments, treating them as a
152
+ * single continuous string for band positioning. Each segment can supply
153
+ * its own palette so the gradient stays in lockstep while the colors
154
+ * differ.
155
+ *
156
+ * Performance shape (per call, dominant cost):
157
+ * - One `Date.now()` read.
158
+ * - One `compile()` lookup per segment (Symbol-keyed cache slot, hot path
159
+ * skipped after first frame).
160
+ * - One ANSI open/close pair per **run of same-tier chars**, not per char.
161
+ * - No per-char allocations beyond the run buffer.
55
162
  */
56
163
  export function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string {
164
+ const mode = resolveMode();
165
+
166
+ // Pre-scan: total code-point count (positions the band) and resolved palette.
57
167
  let total = 0;
58
- const expanded: Array<{ chars: string[]; palette: ShimmerPalette }> = [];
168
+ const perSeg: { chars: string[]; palette: ShimmerPalette }[] = [];
59
169
  for (const seg of segments) {
60
- const chars = [...seg.text];
170
+ const chars = Array.from(seg.text);
61
171
  total += chars.length;
62
- expanded.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
172
+ perSeg.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
63
173
  }
64
174
  if (total === 0) return "";
65
175
 
66
- const out: string[] = [];
176
+ // Disabled: no animation, no per-char work. Paint each segment in its mid
177
+ // tier so the working line stays legible without movement.
178
+ if (mode === "disabled") {
179
+ let out = "";
180
+ for (const { chars, palette } of perSeg) {
181
+ const seq = compile(theme, palette).mid;
182
+ out += `${seq.open}${chars.join("")}${seq.close}`;
183
+ }
184
+ return out;
185
+ }
186
+
187
+ const time = Date.now();
188
+ const intensityFn = mode === "kitt" ? kittIntensity : classicIntensity;
189
+
190
+ let out = "";
67
191
  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));
192
+ for (const { chars, palette } of perSeg) {
193
+ const compiled = compile(theme, palette);
194
+ let runTier: Tier | null = null;
195
+ let runBuf = "";
196
+ for (let i = 0; i < chars.length; i++) {
197
+ const tier = tierFor(intensityFn(time, index, total));
198
+ if (tier !== runTier) {
199
+ if (runTier !== null) {
200
+ const seq = compiled[runTier];
201
+ out += `${seq.open}${runBuf}${seq.close}`;
202
+ runBuf = "";
203
+ }
204
+ runTier = tier;
205
+ }
206
+ runBuf += chars[i];
71
207
  index++;
72
208
  }
209
+ if (runTier !== null && runBuf.length > 0) {
210
+ const seq = compiled[runTier];
211
+ out += `${seq.open}${runBuf}${seq.close}`;
212
+ }
73
213
  }
74
- return out.join("");
214
+ return out;
75
215
  }
76
216
 
77
217
  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>