@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.
- package/CHANGELOG.md +49 -1
- package/dist/types/cli/worktree-cli.d.ts +26 -0
- package/dist/types/commands/worktree.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +23 -0
- package/dist/types/hashline/constants.d.ts +0 -2
- package/dist/types/hashline/hash.d.ts +13 -39
- package/dist/types/hashline/parser.d.ts +2 -6
- package/dist/types/modes/shared.d.ts +9 -0
- package/dist/types/modes/theme/shimmer.d.ts +21 -10
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/yield-queue.d.ts +24 -0
- package/dist/types/slash-commands/helpers/format.d.ts +1 -1
- package/dist/types/task/worktree.d.ts +0 -1
- package/dist/types/utils/git.d.ts +1 -0
- package/package.json +7 -7
- package/src/autoresearch/storage.ts +14 -2
- package/src/cli/worktree-cli.ts +291 -0
- package/src/cli.ts +1 -0
- package/src/commands/worktree.ts +56 -0
- package/src/config/prompt-templates.ts +1 -8
- package/src/config/settings-schema.ts +16 -0
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +5 -7
- package/src/edit/streaming.ts +24 -12
- package/src/hashline/constants.ts +0 -3
- package/src/hashline/diff.ts +1 -1
- package/src/hashline/execute.ts +2 -2
- package/src/hashline/grammar.lark +7 -8
- package/src/hashline/hash.ts +21 -43
- package/src/hashline/input.ts +15 -13
- package/src/hashline/parser.ts +62 -161
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/modes/components/mcp-add-wizard.ts +4 -3
- package/src/modes/components/settings-selector.ts +23 -10
- package/src/modes/components/welcome.ts +77 -35
- package/src/modes/controllers/event-controller.ts +2 -1
- package/src/modes/controllers/mcp-command-controller.ts +4 -3
- package/src/modes/interactive-mode.ts +51 -10
- package/src/modes/shared.ts +16 -0
- package/src/modes/theme/shimmer.ts +173 -33
- package/src/modes/utils/ui-helpers.ts +31 -13
- package/src/prompts/tools/async-result.md +5 -2
- package/src/prompts/tools/hashline.md +62 -81
- package/src/sdk.ts +95 -21
- package/src/session/agent-session.ts +22 -0
- package/src/session/yield-queue.ts +155 -0
- package/src/slash-commands/helpers/format.ts +4 -1
- package/src/task/worktree.ts +2 -7
- package/src/tools/gh.ts +35 -32
- package/src/utils/commit-message-generator.ts +6 -1
- package/src/utils/git.ts +4 -0
- 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
|
-
},
|
|
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
|
|
256
|
+
if (this.#animStart == null) return REST_FRAME;
|
|
257
257
|
const elapsed = performance.now() - this.#animStart;
|
|
258
|
-
if (elapsed >= INTRO_MS) return
|
|
259
|
-
// Ease-out cubic so the
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
274
|
-
* `phase` (0..1) shifts the
|
|
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 (
|
|
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
|
-
//
|
|
286
|
-
//
|
|
287
|
-
const stops
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 =
|
|
304
|
-
|
|
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
|
-
/**
|
|
325
|
-
const
|
|
326
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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 =>
|
|
2198
|
-
|
|
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
|
);
|
package/src/modes/shared.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
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
|
-
|
|
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.
|
|
12
|
-
low:
|
|
13
|
-
/** Color for chars approaching the crest (0.
|
|
14
|
-
mid:
|
|
15
|
-
/** Color at the band's crest (intensity
|
|
16
|
-
high:
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
168
|
+
const perSeg: { chars: string[]; palette: ShimmerPalette }[] = [];
|
|
59
169
|
for (const seg of segments) {
|
|
60
|
-
const chars =
|
|
170
|
+
const chars = Array.from(seg.text);
|
|
61
171
|
total += chars.length;
|
|
62
|
-
|
|
172
|
+
perSeg.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
|
|
63
173
|
}
|
|
64
174
|
if (total === 0) return "";
|
|
65
175
|
|
|
66
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
2
|
+
{{#if multiple}}{{jobs.length}} background jobs have completed. Resume your work using the results below.
|
|
3
3
|
|
|
4
|
-
{{
|
|
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>
|