@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.
- package/AGENTS.md +52 -22
- package/CHANGELOG.md +65 -0
- package/CONTRIBUTING.md +12 -1
- package/RELEASE.md +142 -49
- package/bash-mode/completion.ts +2 -2
- package/build-stamp.json +1 -1
- package/docs/agents/domain.md +60 -0
- package/docs/agents/issue-tracker.md +88 -0
- package/docs/configuration.md +6 -1
- package/docs/design/regression-testing.md +1 -1
- package/docs/testing.md +81 -0
- package/package.json +17 -10
- package/src/config/settings-registry.ts +2 -1
- package/src/config/structural-preset-table.ts +0 -1
- package/src/extension/commands/commands.ts +0 -1
- package/src/extension/core/peer-guard.ts +7 -2
- package/src/extension/core/segment-context.ts +139 -44
- package/src/extension/core/state.ts +1 -0
- package/src/extension/core/types.ts +5 -0
- package/src/extension/queue/queue-integration.ts +20 -2
- package/src/extension/session/session-lifecycle.ts +24 -3
- package/src/extension/skills/inline-invocation.ts +1 -1
- package/src/extension/ui/custom-editor.ts +1 -5
- package/src/extension/ui/deck/config-diagnostic-lines.ts +0 -6
- package/src/extension/ui/ports-panel.ts +0 -1
- package/src/extension/ui/token-overlays.ts +1 -1
- package/src/extension/welcome/welcome-integration.ts +65 -5
- package/src/i18n/nl.ts +3 -0
- package/src/segments/core.ts +0 -1
- package/src/segments/system.ts +12 -3
- package/src/studio/component.ts +0 -1
- package/src/theme/theme.ts +1 -1
- package/src/tools/graph.ts +0 -3
- package/src/usage/tps-ring.ts +172 -32
- package/src/welcome/banner.ts +14 -0
- package/src/welcome/overlay.ts +139 -7
- package/src/welcome/quick-launch.ts +44 -0
- package/src/welcome/types.ts +13 -0
- package/src/welcome/widgets/sessions-widget.ts +13 -9
- package/src/welcome/widgets/shortcuts-widget.ts +13 -5
- package/src/welcome/widgets/whats-new-widget.ts +16 -3
- package/src/working-vibes/frames.ts +81 -5
- package/src/working-vibes/index.ts +6 -1
- package/src/working-vibes/native-indicator.ts +71 -0
- package/src/working-vibes/storage.ts +5 -3
package/src/welcome/overlay.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/welcome/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
190
|
+
typeof rawIndicator === "string" &&
|
|
191
|
+
(workingIndicatorStyles() as string[]).includes(rawIndicator)
|
|
192
|
+
? (rawIndicator as WorkingIndicatorStyle)
|
|
191
193
|
: "dots";
|
|
192
194
|
|
|
193
195
|
const maxLength =
|