@groeponline/pi-wishcraft 1.16.0 → 1.23.0

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 (45) hide show
  1. package/AGENTS.md +52 -22
  2. package/CHANGELOG.md +65 -0
  3. package/CONTRIBUTING.md +12 -1
  4. package/RELEASE.md +142 -49
  5. package/bash-mode/completion.ts +2 -2
  6. package/build-stamp.json +1 -1
  7. package/docs/agents/domain.md +60 -0
  8. package/docs/agents/issue-tracker.md +88 -0
  9. package/docs/configuration.md +6 -1
  10. package/docs/design/regression-testing.md +1 -1
  11. package/docs/testing.md +81 -0
  12. package/package.json +17 -10
  13. package/src/config/settings-registry.ts +2 -1
  14. package/src/config/structural-preset-table.ts +0 -1
  15. package/src/extension/commands/commands.ts +0 -1
  16. package/src/extension/core/peer-guard.ts +7 -2
  17. package/src/extension/core/segment-context.ts +139 -44
  18. package/src/extension/core/state.ts +1 -0
  19. package/src/extension/core/types.ts +5 -0
  20. package/src/extension/queue/queue-integration.ts +20 -2
  21. package/src/extension/session/session-lifecycle.ts +24 -3
  22. package/src/extension/skills/inline-invocation.ts +1 -1
  23. package/src/extension/ui/custom-editor.ts +1 -5
  24. package/src/extension/ui/deck/config-diagnostic-lines.ts +0 -6
  25. package/src/extension/ui/ports-panel.ts +0 -1
  26. package/src/extension/ui/token-overlays.ts +1 -1
  27. package/src/extension/welcome/welcome-integration.ts +65 -5
  28. package/src/i18n/nl.ts +3 -0
  29. package/src/segments/core.ts +0 -1
  30. package/src/segments/system.ts +12 -3
  31. package/src/studio/component.ts +0 -1
  32. package/src/theme/theme.ts +1 -1
  33. package/src/tools/graph.ts +0 -3
  34. package/src/usage/tps-ring.ts +172 -32
  35. package/src/welcome/banner.ts +14 -0
  36. package/src/welcome/overlay.ts +139 -7
  37. package/src/welcome/quick-launch.ts +44 -0
  38. package/src/welcome/types.ts +13 -0
  39. package/src/welcome/widgets/sessions-widget.ts +13 -9
  40. package/src/welcome/widgets/shortcuts-widget.ts +13 -5
  41. package/src/welcome/widgets/whats-new-widget.ts +16 -3
  42. package/src/working-vibes/frames.ts +81 -5
  43. package/src/working-vibes/index.ts +6 -1
  44. package/src/working-vibes/native-indicator.ts +71 -0
  45. package/src/working-vibes/storage.ts +5 -3
@@ -5,9 +5,14 @@ import { dim, renderWelcomeBox } from "./renderer.ts";
5
5
  import { fgOnly } from "../theme/colors.ts";
6
6
  import type { WelcomeData } from "./types.ts";
7
7
  import type { LoadedCounts, RecentSession } from "./types.ts";
8
- import { renderWelcomeArtWithReveal, BOOT_REVEAL_MS } from "./banner.ts";
8
+ import { renderWelcomeArtWithReveal, BOOT_REVEAL_MS, revealElapsedForArt } from "./banner.ts";
9
9
  import { tr } from "../i18n/index.ts";
10
10
  import type { WelcomeArtTheme } from "./welcome-art.ts";
11
+ import { quickLaunchItems } from "./quick-launch.ts";
12
+ import type { WelcomeFocus, WelcomeSection } from "./types.ts";
13
+
14
+ /** Tab order of the cockpit sections. */
15
+ const SECTIONS: readonly WelcomeSection[] = ["news", "sessions", "shortcuts"];
11
16
 
12
17
  // ═══════════════════════════════════════════════════════════════════════════
13
18
  // Welcome Components
@@ -21,6 +26,11 @@ export class WelcomeComponent implements Component {
21
26
  private data: WelcomeData;
22
27
  private countdown: number = 30;
23
28
  private mountedAt = 0;
29
+ private timerPaused = false;
30
+ private selectedWhatsNew: number | null = null;
31
+ private focus: WelcomeFocus = { section: "news", index: 0 };
32
+ /** True once a navigation key has been seen; keeps the default render calm. */
33
+ private focusEngaged = false;
24
34
 
25
35
  constructor(
26
36
  modelName: string,
@@ -55,6 +65,99 @@ export class WelcomeComponent implements Component {
55
65
  this.countdown = seconds;
56
66
  }
57
67
 
68
+ isTimerPaused(): boolean {
69
+ return this.timerPaused;
70
+ }
71
+
72
+ /**
73
+ * Step through the `What's new` bullets so truncated entries can be read in
74
+ * full. Interacting pauses the auto-dismiss countdown; only non-printable
75
+ * navigation keys call this, so typing a prompt still just dismisses.
76
+ */
77
+ moveSelection(delta: -1 | 1): void {
78
+ const count = this.data.whatsNew?.length ?? 0;
79
+ if (count === 0) return;
80
+ this.timerPaused = true;
81
+ this.selectedWhatsNew =
82
+ this.selectedWhatsNew === null
83
+ ? delta > 0
84
+ ? 0
85
+ : count - 1
86
+ : (this.selectedWhatsNew + delta + count) % count;
87
+ }
88
+
89
+ getSelectedWhatsNew(): number | null {
90
+ return this.selectedWhatsNew;
91
+ }
92
+
93
+ /** How many rows a section can highlight (0 = section is skipped). */
94
+ private rowsFor(section: WelcomeSection): number {
95
+ switch (section) {
96
+ case "news":
97
+ return this.data.whatsNew?.length ?? 0;
98
+ case "sessions":
99
+ return Math.min(3, this.data.recentSessions.length);
100
+ case "shortcuts":
101
+ return quickLaunchItems().length;
102
+ default:
103
+ return 0;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Move inside the focused section. The news section keeps its wrapping
109
+ * expanded-bullet behaviour; other sections clamp at the ends.
110
+ */
111
+ moveFocus(delta: -1 | 1): void {
112
+ if (this.focus.section === "news") {
113
+ this.moveSelection(delta);
114
+ if (this.selectedWhatsNew !== null) {
115
+ this.focusEngaged = true;
116
+ this.focus = { section: "news", index: this.selectedWhatsNew };
117
+ }
118
+ return;
119
+ }
120
+ const rows = this.rowsFor(this.focus.section);
121
+ if (rows === 0) return;
122
+ this.timerPaused = true;
123
+ this.focusEngaged = true;
124
+ this.focus = {
125
+ section: this.focus.section,
126
+ index: Math.max(0, Math.min(rows - 1, this.focus.index + delta)),
127
+ };
128
+ }
129
+
130
+ /** Tab / Shift+Tab: step to the next non-empty section. */
131
+ cycleSection(delta: -1 | 1): void {
132
+ const start = SECTIONS.indexOf(this.focus.section);
133
+ for (let step = 1; step <= SECTIONS.length; step++) {
134
+ const next = (((start + delta * step) % SECTIONS.length) + SECTIONS.length) % SECTIONS.length;
135
+ const section = SECTIONS[next];
136
+ if (this.rowsFor(section) === 0) continue;
137
+ this.timerPaused = true;
138
+ this.focusEngaged = true;
139
+ this.focus = {
140
+ section,
141
+ index: section === "news" ? (this.selectedWhatsNew ?? 0) : 0,
142
+ };
143
+ return;
144
+ }
145
+ }
146
+
147
+ getFocus(): WelcomeFocus | null {
148
+ return this.focusEngaged ? { ...this.focus } : null;
149
+ }
150
+
151
+ /**
152
+ * Enter on the focused row. Only meaningful once navigation paused the
153
+ * countdown, and only quick-launch rows carry text for the editor.
154
+ */
155
+ activate(): string | null {
156
+ if (!this.timerPaused || this.focus.section !== "shortcuts") return null;
157
+ const item = quickLaunchItems()[this.focus.index];
158
+ return item?.insert ?? null;
159
+ }
160
+
58
161
  setArt(art: WelcomeArtTheme, animate: boolean): void {
59
162
  this.data.art = art;
60
163
  this.data.animateArt = animate;
@@ -88,7 +191,9 @@ export class WelcomeComponent implements Component {
88
191
  );
89
192
 
90
193
  // Bottom line with countdown
91
- const countdownText = ` ${tr("welcome.countdown", "Press any key to continue ({n}s)", { n: this.countdown })} `;
194
+ const countdownText = this.timerPaused
195
+ ? ` ${this.pausedHint()} `
196
+ : ` ${tr("welcome.countdown", "Press any key to continue ({n}s)", { n: this.countdown })} `;
92
197
  const countdownStyled = dim(countdownText);
93
198
  const bottomContentWidth = boxWidth - 2;
94
199
  const countdownVisLen = visibleWidth(countdownText);
@@ -100,20 +205,47 @@ export class WelcomeComponent implements Component {
100
205
  countdownStyled +
101
206
  dim(hChar.repeat(Math.max(0, rightPad)));
102
207
 
103
- return renderWelcomeBox(this.data, termWidth, bottomLine, this.revealArt(termWidth));
208
+ const renderData: WelcomeData = {
209
+ ...this.data,
210
+ whatsNewSelected: this.selectedWhatsNew,
211
+ focus: this.getFocus() ?? undefined,
212
+ };
213
+ return renderWelcomeBox(renderData, termWidth, bottomLine, this.revealArt(termWidth));
214
+ }
215
+
216
+ /** One hint per section: what the arrows and Enter can do right now. */
217
+ private pausedHint(): string {
218
+ switch (this.focus.section) {
219
+ case "sessions":
220
+ return tr(
221
+ "welcome.pausedHint.sessions",
222
+ "↑/↓ browse crafts · tab switches section · any other key continues",
223
+ );
224
+ case "shortcuts":
225
+ return tr(
226
+ "welcome.pausedHint.shortcuts",
227
+ "↑/↓ choose · enter primes the prompt · tab switches section",
228
+ );
229
+ default:
230
+ return tr("welcome.pausedHint", "↑/↓ browse news · any other key continues");
231
+ }
104
232
  }
105
233
 
106
- /** Left-column reveal frames while the boot window is open. */
234
+ /**
235
+ * Left-column art override. Animated lanterns keep their own frame source
236
+ * after the boot window so the flame never freezes on the first paint.
237
+ */
107
238
  private revealArt(termWidth: number): string[] | undefined {
108
239
  if (this.mountedAt === 0) return undefined;
109
240
  const now = Date.now();
110
241
  const elapsed = now - this.mountedAt;
111
- if (elapsed >= BOOT_REVEAL_MS) return undefined;
242
+ const revealElapsed = revealElapsedForArt(elapsed, this.data.animateArt === true);
243
+ if (revealElapsed === undefined) return undefined;
112
244
  const frames = renderWelcomeArtWithReveal(
113
245
  this.data.art ?? "lantern",
114
246
  termWidth,
115
- this.data.animateArt === true,
116
- elapsed,
247
+ this.data.animateArt === true || revealElapsed < BOOT_REVEAL_MS,
248
+ revealElapsed,
117
249
  now,
118
250
  );
119
251
  const boxLayout = getBoxLayout(termWidth);
@@ -0,0 +1,44 @@
1
+ import { tr } from "../i18n/index.ts";
2
+
3
+ /**
4
+ * Quick Launch rows. The welcome cockpit renders these and lets non-printable
5
+ * navigation move between them, so the list lives here once instead of being
6
+ * hardcoded inside the widget.
7
+ *
8
+ * `insert` is the text primed in the prompt editor when the row is activated.
9
+ * Rows without `insert` are genuine keybindings — the overlay cannot press a
10
+ * key for the operator, so activating them only closes the overlay.
11
+ */
12
+ export interface QuickLaunchItem {
13
+ id: string;
14
+ keys: string;
15
+ label: string;
16
+ insert?: string;
17
+ }
18
+
19
+ export function quickLaunchItems(): QuickLaunchItem[] {
20
+ return [
21
+ {
22
+ id: "idea",
23
+ keys: "# <idea>",
24
+ label: tr("welcome.shortcuts.idea", "capture idea to queue"),
25
+ insert: "# ",
26
+ },
27
+ {
28
+ id: "deck",
29
+ keys: "alt+p",
30
+ label: tr("welcome.shortcuts.deck", "tactical powerline overlay"),
31
+ },
32
+ {
33
+ id: "bash",
34
+ keys: "!cmd",
35
+ label: tr("welcome.shortcuts.bash", "sticky bash session"),
36
+ insert: "! ",
37
+ },
38
+ {
39
+ id: "stash",
40
+ keys: "alt+s",
41
+ label: tr("welcome.shortcuts.stash", "stash/pop prompt draft"),
42
+ },
43
+ ];
44
+ }
@@ -12,6 +12,15 @@ export interface RecentSession {
12
12
  timeAgo: string;
13
13
  }
14
14
 
15
+ /** Sections the welcome overlay lets the operator step through with Tab. */
16
+ export type WelcomeSection = "news" | "sessions" | "shortcuts";
17
+
18
+ export interface WelcomeFocus {
19
+ section: WelcomeSection;
20
+ /** Row index within that section. */
21
+ index: number;
22
+ }
23
+
15
24
  export interface WelcomeData {
16
25
  modelName: string;
17
26
  providerName: string;
@@ -22,6 +31,10 @@ export interface WelcomeData {
22
31
  hasStash?: boolean;
23
32
  nextIdeaText?: string;
24
33
  whatsNew?: string[];
34
+ /** Index of the `whatsNew` bullet shown expanded (wrapped, not truncated). */
35
+ whatsNewSelected?: number | null;
36
+ /** Which section Tab is on and which row inside it is highlighted. */
37
+ focus?: WelcomeFocus;
25
38
  /**
26
39
  * Heading above `whatsNew`. Defaults to "What's new"; the first-run flow
27
40
  * swaps it for a short "Getting started" so a brand-new operator sees three
@@ -6,19 +6,23 @@ export const SessionsWidget: WelcomeWidget = {
6
6
  render(ctx: WidgetRenderContext): string[] {
7
7
  const { data, dim, bold, color } = ctx;
8
8
  const lines: string[] = [];
9
-
9
+ const focused =
10
+ data.focus?.section === "sessions" ? data.focus.index : null;
11
+
10
12
  lines.push(` ${bold(color("accent", tr("welcome.sessions", "Recent Crafts")))}`);
11
13
 
12
14
  if (data.recentSessions.length === 0) {
13
15
  lines.push(` ${dim(tr("welcome.sessions.none", "No recent sessions"))}`);
14
- } else {
15
- for (const session of data.recentSessions.slice(0, 3)) {
16
- lines.push(
17
- ` ${dim("• ")}${color("path", session.name)}${dim(` (${session.timeAgo})`)}`
18
- );
19
- }
16
+ return lines;
20
17
  }
21
-
18
+
19
+ data.recentSessions.slice(0, 3).forEach((session, index) => {
20
+ const marker = focused === index ? color("accent", "▸ ") : dim("• ");
21
+ lines.push(
22
+ ` ${marker}${color("path", session.name)}${dim(` (${session.timeAgo})`)}`
23
+ );
24
+ });
25
+
22
26
  return lines;
23
- }
27
+ },
24
28
  };
@@ -1,17 +1,25 @@
1
1
  import { tr } from "../../i18n/index.ts";
2
2
  import type { WelcomeWidget, WidgetRenderContext } from "../types.ts";
3
+ import { quickLaunchItems } from "../quick-launch.ts";
4
+
5
+ const KEYS_WIDTH = 10;
3
6
 
4
7
  export const ShortcutsWidget: WelcomeWidget = {
5
8
  id: "shortcuts",
6
9
  render(ctx: WidgetRenderContext): string[] {
7
- const { dim, bold, color } = ctx;
10
+ const { data, dim, bold, color } = ctx;
8
11
  const lines: string[] = [];
12
+ const focused =
13
+ data.focus?.section === "shortcuts" ? data.focus.index : null;
9
14
 
10
15
  lines.push(` ${bold(color("accent", tr("welcome.shortcuts", "Quick Launch / Tactical")))}`);
11
- lines.push(` ${dim("# <idea> ")} ${tr("welcome.shortcuts.idea", "capture idea to queue")}`);
12
- lines.push(` ${dim("alt+p ")} ${tr("welcome.shortcuts.deck", "tactical powerline overlay")}`);
13
- lines.push(` ${dim("!cmd ")} ${tr("welcome.shortcuts.bash", "sticky bash session")}`);
14
- lines.push(` ${dim("alt+s ")} ${tr("welcome.shortcuts.stash", "stash/pop prompt draft")}`);
16
+
17
+ quickLaunchItems().forEach((item, index) => {
18
+ const marker = focused === index ? color("accent", "▸") : " ";
19
+ const keys = item.keys.padEnd(KEYS_WIDTH);
20
+ const keysStyled = focused === index ? color("accent", keys) : dim(keys);
21
+ lines.push(`${marker}${keysStyled} ${item.label}`);
22
+ });
15
23
 
16
24
  return lines;
17
25
  },
@@ -1,3 +1,4 @@
1
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
1
2
  import { tr } from "../../i18n/index.ts";
2
3
  import type { WelcomeWidget, WidgetRenderContext } from "../types.ts";
3
4
 
@@ -16,9 +17,21 @@ export const WhatsNewWidget: WelcomeWidget = {
16
17
 
17
18
  const lines: string[] = [];
18
19
  lines.push(` ${bold(color("accent", title))}`);
19
- for (const entry of entries) {
20
- lines.push(` ${dim("• ")}${entry}`);
21
- }
20
+ const selected =
21
+ data.focus?.section === "news"
22
+ ? data.focus.index
23
+ : (data.whatsNewSelected ?? null);
24
+ entries.forEach((entry, index) => {
25
+ if (selected !== index) {
26
+ lines.push(` ${dim("• ")}${entry}`);
27
+ return;
28
+ }
29
+ // Expanded: wrap instead of letting the column truncate the bullet.
30
+ const wrapped = wrapTextWithAnsi(entry, Math.max(12, ctx.width - 4));
31
+ wrapped.forEach((line, row) => {
32
+ lines.push(row === 0 ? ` ${color("accent", "▸ ")}${line}` : ` ${line}`);
33
+ });
34
+ });
22
35
  return lines;
23
36
  },
24
37
  };
@@ -1,25 +1,101 @@
1
1
  /** Pure working-indicator frames shared by status renderers and tests. */
2
2
 
3
- export type WorkingIndicatorStyle = "dots" | "pulse" | "bar" | "ascii";
4
-
5
- const FRAMES: Record<WorkingIndicatorStyle, readonly string[]> = {
3
+ const FRAMES = {
6
4
  dots: ["· ", "·· ", "···", " ··", " ·", " ··"],
7
5
  pulse: ["○", "◌", "●", "◌"],
8
6
  bar: ["[= ]", "[== ]", "[===]", "[ ==]", "[ =]"],
9
7
  ascii: ["-", "\\", "|", "/"],
8
+ lantern: ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"],
9
+ braille: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
10
+ } as const satisfies Record<string, readonly string[]>;
11
+
12
+ /** Styles with a fixed frame table of their own. */
13
+ export type BuiltinIndicatorStyle = keyof typeof FRAMES;
14
+
15
+ /**
16
+ * `motion` is not a frame table: it borrows the frames of whichever MotionDef
17
+ * the Motion Catalog is running (see `src/motion/catalog.ts`), falling back to
18
+ * the lantern ramp when no catalog frame is supplied.
19
+ */
20
+ export type WorkingIndicatorStyle = BuiltinIndicatorStyle | "motion";
21
+
22
+ /** Frames `motion` falls back to when the caller has no MotionDef. */
23
+ export const MOTION_STYLE_FALLBACK: BuiltinIndicatorStyle = "lantern";
24
+
25
+ const NATIVE_INTERVAL_MS: Record<BuiltinIndicatorStyle, number> = {
26
+ dots: 140,
27
+ pulse: 160,
28
+ bar: 120,
29
+ ascii: 100,
30
+ lantern: 90,
31
+ braille: 80,
10
32
  };
11
33
 
34
+ /** Fixed frames for a style; `motion` resolves to the lantern ramp. */
35
+ export function framesForStyle(
36
+ style: WorkingIndicatorStyle = "dots",
37
+ ): readonly string[] {
38
+ if (style === "motion") return FRAMES[MOTION_STYLE_FALLBACK];
39
+ return FRAMES[style] ?? FRAMES.dots;
40
+ }
41
+
12
42
  export function workingIndicatorFrame(
13
43
  style: WorkingIndicatorStyle = "dots",
14
44
  tick = 0,
15
45
  motion: "full" | "reduced" | "functional" | "off" = "full",
16
46
  ): string {
17
- const frames = FRAMES[style] ?? FRAMES.dots;
47
+ const frames = framesForStyle(style);
18
48
  if (motion === "off" || motion === "reduced") return frames[0]!;
19
49
  if (motion === "functional") return frames[tick % 2 === 0 ? 0 : Math.min(1, frames.length - 1)]!;
20
50
  return frames[Math.abs(Math.floor(tick)) % frames.length]!;
21
51
  }
22
52
 
23
53
  export function workingIndicatorStyles(): WorkingIndicatorStyle[] {
24
- return Object.keys(FRAMES) as WorkingIndicatorStyle[];
54
+ return [...(Object.keys(FRAMES) as BuiltinIndicatorStyle[]), "motion"];
55
+ }
56
+
57
+ /** Every glyph any style can draw, for stripping spinner noise from captured text. */
58
+ export function allWorkingIndicatorFrames(): string[] {
59
+ return Object.values(FRAMES).flatMap((frames) => [...frames]);
60
+ }
61
+
62
+ export interface NativeIndicatorSpec {
63
+ frames: string[];
64
+ intervalMs: number;
65
+ }
66
+
67
+ /** Interval of a built-in style, or of the supplied catalog motion. */
68
+ function nativeInterval(
69
+ style: WorkingIndicatorStyle,
70
+ catalog?: NativeIndicatorSpec,
71
+ ): number {
72
+ if (style === "motion") {
73
+ return catalog?.intervalMs ?? NATIVE_INTERVAL_MS[MOTION_STYLE_FALLBACK];
74
+ }
75
+ return NATIVE_INTERVAL_MS[style] ?? NATIVE_INTERVAL_MS.dots;
76
+ }
77
+
78
+ /**
79
+ * Options for Pi's native `ctx.ui.setWorkingIndicator`. Pi owns the timer, so
80
+ * this is set once per run (not per tick): full motion animates every frame,
81
+ * functional motion alternates two frames slowly, reduced/off hold one frame.
82
+ */
83
+ export function nativeWorkingIndicator(
84
+ style: WorkingIndicatorStyle = "dots",
85
+ motion: "full" | "reduced" | "functional" | "off" = "full",
86
+ ascii = false,
87
+ /** Frames + cadence of the active MotionDef, used by the `motion` style. */
88
+ catalog?: NativeIndicatorSpec,
89
+ ): NativeIndicatorSpec {
90
+ const key: WorkingIndicatorStyle = ascii ? "ascii" : style;
91
+ const source =
92
+ key === "motion" && catalog?.frames.length ? catalog.frames : framesForStyle(key);
93
+ const frames = source.length > 0 ? [...source] : ["·"];
94
+ if (motion === "off" || motion === "reduced") {
95
+ return { frames: [frames[0]!], intervalMs: 1000 };
96
+ }
97
+ if (motion === "functional") {
98
+ return { frames: [frames[0]!, frames[Math.min(1, frames.length - 1)]!], intervalMs: 500 };
99
+ }
100
+ return { frames, intervalMs: nativeInterval(key, catalog) };
25
101
  }
@@ -20,7 +20,12 @@ export {
20
20
 
21
21
  export type { VibeMode } from "./storage.ts";
22
22
  export type { WorkingIndicatorStyle } from "./frames.ts";
23
- export { workingIndicatorFrame, workingIndicatorStyles } from "./frames.ts";
23
+ export {
24
+ allWorkingIndicatorFrames,
25
+ nativeWorkingIndicator,
26
+ workingIndicatorFrame,
27
+ workingIndicatorStyles,
28
+ } from "./frames.ts";
24
29
 
25
30
  export type {
26
31
  GenerateVibesResult,
@@ -0,0 +1,71 @@
1
+ // native-indicator.ts
2
+ // Drives Pi's own working spinner (`ctx.ui.setWorkingIndicator`) from the
3
+ // `wishcraft.workingIndicatorStyle` setting and the active motion policy.
4
+ // Pi owns the animation timer; this only hands it frames once per run, so it
5
+ // never competes with the MotionScheduler and idle sessions cost zero ticks.
6
+
7
+ import { effectiveLevel, framesOf, getMotion, prefersAsciiGlyphs } from "../motion/index.ts";
8
+ import type { MotionPolicy } from "../motion/index.ts";
9
+ import { hasNerdFonts } from "../theme/icons.ts";
10
+ import { nativeWorkingIndicator } from "./frames.ts";
11
+ import type { NativeIndicatorSpec } from "./frames.ts";
12
+ import { vibeState } from "./storage.ts";
13
+
14
+ type NativeIndicatorUi = {
15
+ setWorkingIndicator?: (options?: { frames?: string[]; intervalMs?: number }) => void;
16
+ theme?: { fg?: (color: "accent", text: string) => string };
17
+ };
18
+
19
+ /**
20
+ * Catalog frames for the `motion` style. Resolved from the Motion Catalog so
21
+ * the native spinner runs the same glyphs as the status rail instead of a
22
+ * second, divergent animation.
23
+ */
24
+ function motionSpec(motionId: string | undefined): NativeIndicatorSpec | undefined {
25
+ if (!motionId) return undefined;
26
+ const def = getMotion(motionId);
27
+ if (!def) return undefined;
28
+ const frames = framesOf(def);
29
+ if (frames.length === 0) return undefined;
30
+ return { frames: [...frames], intervalMs: def.generator?.intervalMs ?? 100 };
31
+ }
32
+
33
+ export function applyNativeWorkingIndicator(
34
+ ui: NativeIndicatorUi | undefined,
35
+ policy: MotionPolicy,
36
+ activeMotionId?: string,
37
+ ): void {
38
+ if (!ui?.setWorkingIndicator) return;
39
+ try {
40
+ const level = effectiveLevel(policy);
41
+ const ascii = prefersAsciiGlyphs(policy, hasNerdFonts());
42
+ const style = vibeState.config.workingIndicatorStyle;
43
+ const spec = nativeWorkingIndicator(
44
+ style,
45
+ level,
46
+ ascii,
47
+ style === "motion" && !ascii && level === "full"
48
+ ? motionSpec(activeMotionId)
49
+ : undefined,
50
+ );
51
+ // Custom frames render verbatim, so colour them like Pi's default spinner.
52
+ const paint = (frame: string): string => {
53
+ try {
54
+ return ui.theme?.fg?.("accent", frame) ?? frame;
55
+ } catch {
56
+ return frame;
57
+ }
58
+ };
59
+ ui.setWorkingIndicator({ frames: spec.frames.map(paint), intervalMs: spec.intervalMs });
60
+ } catch (error) {
61
+ console.debug("[working-vibes] native indicator failed:", error);
62
+ }
63
+ }
64
+
65
+ export function resetNativeWorkingIndicator(ui: NativeIndicatorUi | undefined): void {
66
+ try {
67
+ ui?.setWorkingIndicator?.();
68
+ } catch (error) {
69
+ console.debug("[working-vibes] native indicator reset failed:", error);
70
+ }
71
+ }
@@ -9,7 +9,8 @@ import { join, dirname } from "node:path";
9
9
  import { getAgentPath } from "../paths/agent-dirs.ts";
10
10
 
11
11
  export type VibeMode = "generate" | "file";
12
- export type WorkingIndicatorStyle = "dots" | "pulse" | "bar" | "ascii";
12
+ import { workingIndicatorStyles, type WorkingIndicatorStyle } from "./frames.ts";
13
+ export type { WorkingIndicatorStyle };
13
14
 
14
15
  // ═══════════════════════════════════════════════════════════════════════════
15
16
  // Constants
@@ -186,8 +187,9 @@ export function loadConfig(): VibeConfig {
186
187
  ? settings.wishcraft.workingIndicatorStyle
187
188
  : settings.workingIndicatorStyle;
188
189
  const workingIndicatorStyle: WorkingIndicatorStyle =
189
- rawIndicator === "pulse" || rawIndicator === "bar" || rawIndicator === "ascii"
190
- ? rawIndicator
190
+ typeof rawIndicator === "string" &&
191
+ (workingIndicatorStyles() as string[]).includes(rawIndicator)
192
+ ? (rawIndicator as WorkingIndicatorStyle)
191
193
  : "dots";
192
194
 
193
195
  const maxLength =