@underactive/pi-topping 0.5.1 → 0.6.1
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/README.md +23 -9
- package/index.ts +0 -1
- package/package.json +3 -3
- package/src/flags.ts +19 -0
- package/src/menu.ts +52 -31
- package/src/pi-installer.ts +63 -0
- package/src/preview.ts +46 -13
- package/src/prompt-decorator.ts +9 -8
- package/src/session.ts +29 -12
- package/src/settings.ts +41 -7
- package/src/setup-check.ts +20 -0
- package/src/setup-command.ts +136 -0
- package/src/toppings.ts +158 -0
- package/src/util.ts +9 -0
- package/src/word-packs.ts +119 -0
- package/wordpacks/simcity.json +432 -0
- package/wordpacks/star-trek.json +283 -0
- package/wordpacks/star-wars.json +283 -0
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
|
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
**“Working” Loader** — animated spinner (with color choice), randomized activity word, shimmer (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, and live output-token rate (with color choice and dim toggle) — all arrangeable left to right.
|
|
9
|
+
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, and live output-token rate (with color choice and dim toggle) — all arrangeable left to right.
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
13
|
-
**Completion Marker** — end-of-turn marker with icon, randomized verb
|
|
13
|
+
**Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event.
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -23,7 +23,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
23
23
|
╔═[ Pi Topping: Settings ]═══════════════════════════════════╗
|
|
24
24
|
╟─ Preview ──────────────────────────────────────────────────╢
|
|
25
25
|
║ ║
|
|
26
|
-
║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tok/s · 3s · ↓ 84 tokens
|
|
26
|
+
║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tok/s · 3s · ↓ 84 tokens ║
|
|
27
27
|
║ ║
|
|
28
28
|
╟─ User Prompt ──────────────────────────────────────────────╢
|
|
29
29
|
║ [■] High-vis prompt ON ║
|
|
@@ -62,6 +62,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
62
62
|
║ ║
|
|
63
63
|
╟─ Completion Marker ────────────────────────────────────────╢
|
|
64
64
|
║ [■] Show completion marker ON ║
|
|
65
|
+
║ [■] Marker style ‹ elite › ║
|
|
65
66
|
║ [■] Border style ‹ none › ║
|
|
66
67
|
║ [■] Border color ‹ borderAccent › ║
|
|
67
68
|
║ [■] Pi icon ON ║
|
|
@@ -69,22 +70,35 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
69
70
|
║ [■] Tokens spent ON ║
|
|
70
71
|
║ [■] Mid-turn inputs ON ║
|
|
71
72
|
║ ║
|
|
73
|
+
╟─ Word Packs ───────────────────────────────────────────────╢
|
|
74
|
+
║ [ ] SimCity OFF ║
|
|
75
|
+
║ [ ] Star Trek OFF ║
|
|
76
|
+
║ [ ] Star Wars OFF ║
|
|
77
|
+
║ ║
|
|
72
78
|
╟─ Options ──────────────────────────────────────────────────╢
|
|
73
79
|
║ [■] Use NerdFont icons ON ║
|
|
74
80
|
╟────────────────────────────────────────────────────────────╢
|
|
75
81
|
║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
|
|
76
|
-
╚════════════════════════════════════════════════════[ 9/
|
|
82
|
+
╚════════════════════════════════════════════════════[ 9/41 ]╝
|
|
77
83
|
```
|
|
78
84
|
|
|
79
|
-
|
|
85
|
+
### Word packs
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
Base activity words are always available. All word packs, including the shipped **SimCity**, **Star Trek**, and **Star Wars** packs and custom packs, are disabled by default. Enable packs in `/topping-settings` to add their entries to the same uniformly selected pool. When Randomize “Worked” text is on, the completion marker uses the selected entry’s matching past tense; otherwise it uses `Worked`.
|
|
82
88
|
|
|
83
|
-
|
|
89
|
+
Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/simcity.json`, `wordpacks/star-trek.json`, or `wordpacks/star-wars.json` to `~/.pi/agent/pi-topping/word-packs.json`, then change the pack `id` (the bundled `simcity`, `star-trek`, and `star-wars` IDs are reserved), name, and words:
|
|
84
90
|
|
|
85
|
-
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"packs": [{
|
|
94
|
+
"id": "cooking",
|
|
95
|
+
"name": "Cooking",
|
|
96
|
+
"words": [{ "present_tense": "Sautéing onions", "past_tense": "Sautéed" }]
|
|
97
|
+
}]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
86
100
|
|
|
87
|
-
|
|
101
|
+
Pack IDs must start with a lowercase letter and contain only lowercase letters, digits, and `-`. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. Invalid entries are ignored. Packs reload when a session starts and whenever `/topping-settings` opens, not while a turn is running. Preferences for missing packs are retained so they apply when the pack returns.
|
|
88
102
|
|
|
89
103
|
Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
|
|
90
104
|
left or right within the loader. Elapsed time, output tokens, and token rate are joined with
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
4
|
-
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
|
|
5
5
|
"keywords": ["pi-package", "pi-extension"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Eric Sison",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
|
-
"files": ["index.ts", "src/**/*.ts", "README.md", "LICENSE"],
|
|
23
|
+
"files": ["index.ts", "src/**/*.ts", "wordpacks/*.json", "README.md", "LICENSE"],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"test": "node --test tests/*.test.ts",
|
|
26
26
|
"typecheck": "tsc --noEmit"
|
package/src/flags.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature gates for unreleased functionality.
|
|
3
|
+
*
|
|
4
|
+
* Sibling-topping detection and the `/topping-setup` command ship dark: the
|
|
5
|
+
* default below stays `false` until the flow is ready for the public. Setting
|
|
6
|
+
* `PI_TOPPING_SIBLING_SETUP` to any value other than `0`, `false`, `off`, or
|
|
7
|
+
* `no` (case-insensitive) opts a session in without touching code.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SIBLING_SETUP_DEFAULT = false;
|
|
11
|
+
|
|
12
|
+
const FALSEY_FLAG_VALUES = new Set(["0", "false", "off", "no"]);
|
|
13
|
+
|
|
14
|
+
/** Whether the sibling-topping setup flow is registered and advertised. */
|
|
15
|
+
export function isSiblingSetupEnabled(): boolean {
|
|
16
|
+
const value = process.env.PI_TOPPING_SIBLING_SETUP?.trim().toLowerCase();
|
|
17
|
+
if (value === undefined || value === "") return SIBLING_SETUP_DEFAULT;
|
|
18
|
+
return !FALSEY_FLAG_VALUES.has(value);
|
|
19
|
+
}
|
package/src/menu.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* menu.ts
|
|
3
|
-
*
|
|
4
2
|
* A reusable, dependency-light box-drawing toggle-menu component for Pi
|
|
5
3
|
* extensions, built on top of `@earendil-works/pi-tui`'s `Component`
|
|
6
4
|
* contract and `ctx.ui.custom()` overlay API.
|
|
@@ -74,13 +72,13 @@ export interface MenuConfig {
|
|
|
74
72
|
* since the menu opened. Lines may contain ANSI styling and are truncated/
|
|
75
73
|
* padded to fit automatically.
|
|
76
74
|
*
|
|
77
|
-
* Return `string[]` for legacy interval-based animation (
|
|
78
|
-
*
|
|
75
|
+
* Return `string[]` for legacy interval-based animation (defaults to 50ms
|
|
76
|
+
* refresh), or a `PreviewResult` to declare the next refresh interval.
|
|
79
77
|
* Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
|
|
80
78
|
*/
|
|
81
|
-
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined) => string[] | PreviewResult;
|
|
82
|
-
/**
|
|
83
|
-
|
|
79
|
+
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => string[] | PreviewResult;
|
|
80
|
+
/** Optional heading for the preview block. Defaults to `Preview`. */
|
|
81
|
+
previewTitle?: string;
|
|
84
82
|
}
|
|
85
83
|
|
|
86
84
|
export interface MenuResult<T> {
|
|
@@ -89,9 +87,17 @@ export interface MenuResult<T> {
|
|
|
89
87
|
}
|
|
90
88
|
|
|
91
89
|
const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
|
|
92
|
-
const
|
|
90
|
+
const OVERLAY_WIDTH = "86%";
|
|
91
|
+
export const DEFAULT_PREVIEW_WIDTH = 76;
|
|
93
92
|
const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
|
|
94
93
|
|
|
94
|
+
/** Close OSC 8 links truncated by pi-tui's SGR-only truncation reset. */
|
|
95
|
+
function closeTruncatedHyperlink(text: string): string {
|
|
96
|
+
const lastOpen = text.lastIndexOf("\x1b]8;;");
|
|
97
|
+
const lastClose = text.lastIndexOf("\x1b]8;;\x1b\\");
|
|
98
|
+
return lastOpen > lastClose ? `${text}\x1b]8;;\x1b\\` : text;
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
interface FlatItem {
|
|
96
102
|
id: string;
|
|
97
103
|
label: string;
|
|
@@ -128,11 +134,11 @@ export class MenuComponent implements Component {
|
|
|
128
134
|
private readonly title: string;
|
|
129
135
|
private readonly sections: MenuSection[];
|
|
130
136
|
private readonly hints: string[];
|
|
137
|
+
private readonly previewTitle: string;
|
|
131
138
|
private readonly initialValues: Record<string, MenuValue>;
|
|
132
139
|
private readonly values: Record<string, MenuValue>;
|
|
133
140
|
private readonly flat: FlatItem[];
|
|
134
141
|
private readonly previewFn: MenuConfig["preview"];
|
|
135
|
-
private readonly previewIntervalMs: number | undefined;
|
|
136
142
|
private readonly previewOrigin: number | undefined;
|
|
137
143
|
private readonly tui: TUI | undefined;
|
|
138
144
|
private previewTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -155,6 +161,7 @@ export class MenuComponent implements Component {
|
|
|
155
161
|
this.title = config.title;
|
|
156
162
|
this.sections = config.sections;
|
|
157
163
|
this.hints = config.hints ?? DEFAULT_HINTS;
|
|
164
|
+
this.previewTitle = config.previewTitle ?? "Preview";
|
|
158
165
|
this.tui = tui;
|
|
159
166
|
this.values = buildInitialValues(config);
|
|
160
167
|
this.flat = [];
|
|
@@ -166,7 +173,6 @@ export class MenuComponent implements Component {
|
|
|
166
173
|
this.initialValues = { ...this.values };
|
|
167
174
|
|
|
168
175
|
this.previewFn = config.preview;
|
|
169
|
-
this.previewIntervalMs = config.previewIntervalMs;
|
|
170
176
|
if (this.previewFn) {
|
|
171
177
|
this.previewOrigin = Date.now();
|
|
172
178
|
if (tui) {
|
|
@@ -286,16 +292,17 @@ export class MenuComponent implements Component {
|
|
|
286
292
|
this.invalidate();
|
|
287
293
|
}
|
|
288
294
|
|
|
289
|
-
private samplePreview(): string[] | undefined {
|
|
295
|
+
private samplePreview(width: number = DEFAULT_PREVIEW_WIDTH): string[] | undefined {
|
|
290
296
|
if (!this.previewFn) return undefined;
|
|
291
297
|
|
|
292
298
|
const result = this.previewFn(
|
|
293
299
|
this.values,
|
|
294
300
|
this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
|
|
295
301
|
this.flat[this.cursor]?.id,
|
|
302
|
+
width,
|
|
296
303
|
);
|
|
297
304
|
if (Array.isArray(result)) {
|
|
298
|
-
this.previewNextRefreshMs =
|
|
305
|
+
this.previewNextRefreshMs = 50;
|
|
299
306
|
return result;
|
|
300
307
|
}
|
|
301
308
|
|
|
@@ -320,7 +327,7 @@ export class MenuComponent implements Component {
|
|
|
320
327
|
|
|
321
328
|
private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] {
|
|
322
329
|
if (!previewLines?.length) return [];
|
|
323
|
-
return [this.renderSectionDivider(
|
|
330
|
+
return [this.renderSectionDivider(this.previewTitle, innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
|
|
324
331
|
}
|
|
325
332
|
|
|
326
333
|
/** Render every section from `this.flat`, the single source of truth for row order. */
|
|
@@ -348,6 +355,12 @@ export class MenuComponent implements Component {
|
|
|
348
355
|
const flat = this.flat[index]!;
|
|
349
356
|
if (flat.sectionIndex !== currentSection) {
|
|
350
357
|
const remaining = maxRows - lines.length;
|
|
358
|
+
// A section continued from above needs room for a blank separator
|
|
359
|
+
// row, its divider, and at least one item row; otherwise end the
|
|
360
|
+
// window here rather than crowd the divider against the previous
|
|
361
|
+
// section or fold the section into it. The window's first section
|
|
362
|
+
// always renders (a degenerate window may drop its divider).
|
|
363
|
+
if (lines.length > 0 && remaining < 3) break;
|
|
351
364
|
if (lines.length > 0 && remaining >= 3) lines.push(this.renderBlankRow(innerWidth));
|
|
352
365
|
if (maxRows - lines.length >= 2) lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
|
|
353
366
|
currentSection = flat.sectionIndex;
|
|
@@ -361,8 +374,7 @@ export class MenuComponent implements Component {
|
|
|
361
374
|
}
|
|
362
375
|
|
|
363
376
|
/** Render the scrolling settings body while keeping the selected item in view. */
|
|
364
|
-
private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] {
|
|
365
|
-
const allLines = this.buildToggleSections(innerWidth);
|
|
377
|
+
private buildResponsiveToggleSections(innerWidth: number, maxRows: number, allLines: string[]): string[] {
|
|
366
378
|
if (allLines.length <= maxRows) {
|
|
367
379
|
this.scrollStart = 0;
|
|
368
380
|
return allLines;
|
|
@@ -374,18 +386,25 @@ export class MenuComponent implements Component {
|
|
|
374
386
|
if (this.cursor < this.scrollStart) this.scrollStart = this.cursor;
|
|
375
387
|
this.scrollStart = Math.max(0, Math.min(this.scrollStart, this.flat.length - 1));
|
|
376
388
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
389
|
+
// Walk backward from cursor to find the lowest scrollStart that fits in contentRows.
|
|
390
|
+
{
|
|
391
|
+
let rowsUsed = 2; // 1 for cursor item + 1 for first-section divider
|
|
392
|
+
let section = this.flat[this.cursor]!.sectionIndex;
|
|
393
|
+
this.scrollStart = this.cursor;
|
|
394
|
+
for (let i = this.cursor - 1; i >= 0; i--) {
|
|
395
|
+
const sec = this.flat[i]!.sectionIndex;
|
|
396
|
+
if (sec !== section) {
|
|
397
|
+
if (rowsUsed + 3 > contentRows) break;
|
|
398
|
+
rowsUsed += 3;
|
|
399
|
+
section = sec;
|
|
400
|
+
} else {
|
|
401
|
+
if (rowsUsed + 1 > contentRows) break;
|
|
402
|
+
rowsUsed += 1;
|
|
403
|
+
}
|
|
404
|
+
this.scrollStart = i;
|
|
405
|
+
}
|
|
388
406
|
}
|
|
407
|
+
const window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
|
|
389
408
|
|
|
390
409
|
const rows = [...window.lines];
|
|
391
410
|
while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth));
|
|
@@ -403,9 +422,10 @@ export class MenuComponent implements Component {
|
|
|
403
422
|
}
|
|
404
423
|
|
|
405
424
|
private buildLines(maxWidth: number, maxRows?: number): string[] {
|
|
406
|
-
const
|
|
407
|
-
const boxWidth = Math.max(0, Math.min(MAX_WIDTH, maxWidth));
|
|
425
|
+
const boxWidth = Math.max(0, maxWidth);
|
|
408
426
|
const innerWidth = Math.max(0, boxWidth - 2);
|
|
427
|
+
// Preview rows are prefixed with a leading space by buildPreviewBlock/renderContentRow.
|
|
428
|
+
const previewLines = this.samplePreview(Math.max(0, innerWidth - 1));
|
|
409
429
|
const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
|
|
410
430
|
const footer = this.buildFooter(innerWidth);
|
|
411
431
|
const naturalBody = this.buildToggleSections(innerWidth);
|
|
@@ -415,7 +435,7 @@ export class MenuComponent implements Component {
|
|
|
415
435
|
}
|
|
416
436
|
|
|
417
437
|
const bodyRows = Math.max(0, maxRows - header.length - footer.length);
|
|
418
|
-
const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
|
|
438
|
+
const body = this.buildResponsiveToggleSections(innerWidth, bodyRows, naturalBody);
|
|
419
439
|
return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
|
|
420
440
|
}
|
|
421
441
|
|
|
@@ -476,7 +496,8 @@ export class MenuComponent implements Component {
|
|
|
476
496
|
|
|
477
497
|
/** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */
|
|
478
498
|
private renderContentRow(content: string, innerWidth: number): string {
|
|
479
|
-
const
|
|
499
|
+
const truncated = visibleWidth(content) > innerWidth;
|
|
500
|
+
const shown = truncated ? closeTruncatedHyperlink(truncateToWidth(content, innerWidth)) : content;
|
|
480
501
|
const pad = Math.max(0, innerWidth - visibleWidth(shown));
|
|
481
502
|
return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551");
|
|
482
503
|
}
|
|
@@ -553,6 +574,6 @@ export async function showMenu<T extends Record<string, MenuValue>>(
|
|
|
553
574
|
return ctx.ui.custom<MenuResult<T>>(
|
|
554
575
|
(tui, theme, _keybindings, done) =>
|
|
555
576
|
new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
|
|
556
|
-
{ overlay: true, overlayOptions: { width:
|
|
577
|
+
{ overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight: "100%" } },
|
|
557
578
|
);
|
|
558
579
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface PiInstallResult {
|
|
4
|
+
code: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SIGKILL_GRACE_MS = 5_000;
|
|
10
|
+
const EXIT_TIMEOUT = 124;
|
|
11
|
+
|
|
12
|
+
/** Run `pi install <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
|
|
13
|
+
export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
14
|
+
if (!/^npm:@?[\w.-]+(\/[\w.-]+)?$/.test(spec)) {
|
|
15
|
+
return Promise.resolve({ code: 1, stdout: "", stderr: `invalid spec: ${spec}` });
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const isWindows = process.platform === "win32";
|
|
19
|
+
const [command, args, options] = isWindows
|
|
20
|
+
? (["cmd.exe", ["/c", "pi", "install", spec], { windowsHide: true }] as const)
|
|
21
|
+
: (["pi", ["install", spec], {}] as const);
|
|
22
|
+
const processHandle = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
23
|
+
let stdout = "";
|
|
24
|
+
let stderr = "";
|
|
25
|
+
let settled = false;
|
|
26
|
+
let closed = false;
|
|
27
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
28
|
+
|
|
29
|
+
const finish = (result: PiInstallResult): void => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
settled = true;
|
|
32
|
+
clearTimeout(timeoutTimer);
|
|
33
|
+
resolve(result);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const timeoutTimer = setTimeout(() => {
|
|
37
|
+
processHandle.kill("SIGTERM");
|
|
38
|
+
killTimer = setTimeout(() => {
|
|
39
|
+
if (!closed) processHandle.kill("SIGKILL");
|
|
40
|
+
}, SIGKILL_GRACE_MS);
|
|
41
|
+
finish({
|
|
42
|
+
code: EXIT_TIMEOUT,
|
|
43
|
+
stdout,
|
|
44
|
+
stderr: `${stderr}${stderr ? "\n" : ""}[timed out after ${timeoutMs}ms]`,
|
|
45
|
+
});
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
|
|
48
|
+
processHandle.stdout?.on("data", (chunk: Buffer) => {
|
|
49
|
+
stdout += chunk.toString();
|
|
50
|
+
});
|
|
51
|
+
processHandle.stderr?.on("data", (chunk: Buffer) => {
|
|
52
|
+
stderr += chunk.toString();
|
|
53
|
+
});
|
|
54
|
+
processHandle.on("error", (error) => {
|
|
55
|
+
finish({ code: 1, stdout, stderr: `${stderr}${stderr ? "\n" : ""}${error.message}` });
|
|
56
|
+
});
|
|
57
|
+
processHandle.on("close", (code) => {
|
|
58
|
+
closed = true;
|
|
59
|
+
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
60
|
+
finish({ code: code ?? 1, stdout, stderr });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -1,28 +1,38 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type
|
|
2
|
+
import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
|
|
3
3
|
import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
|
|
4
4
|
import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
|
|
5
5
|
import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
6
|
-
import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isSettingColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
|
|
6
|
+
import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isDoneMarkerStyle, isSettingColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
|
|
7
|
+
import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
|
|
7
8
|
import { pickRandomWord } from "./words.ts";
|
|
8
9
|
// Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tok/s for the meter,
|
|
9
10
|
// flat 28 tok/s for the token readouts.
|
|
10
|
-
const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28
|
|
11
|
+
const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
|
|
11
12
|
const PROMPT_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "User Prompt").map(entry => entry.id));
|
|
12
13
|
const MARKER_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "Completion Marker").map(entry => entry.id));
|
|
13
14
|
function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
|
|
14
15
|
|
|
15
16
|
/** Stateful, per-menu preview renderer that follows the active settings section. */
|
|
16
17
|
export class PreviewRenderer {
|
|
18
|
+
// Preserve constructor-time randomness so rendering never changes the preview phrase.
|
|
17
19
|
readonly #word = pickRandomWord();
|
|
20
|
+
readonly #poolFraction = Math.random();
|
|
21
|
+
readonly #packs: readonly WordPack[];
|
|
18
22
|
readonly #meter = new ActivityMeter();
|
|
19
23
|
#lastMeterUpdate = 0;
|
|
24
|
+
#cachedPackWord = "";
|
|
25
|
+
#cachedPackSignature = "";
|
|
20
26
|
readonly #ctx: ExtensionContext;
|
|
21
|
-
constructor(ctx: ExtensionContext
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
constructor(ctx: ExtensionContext, packs: readonly WordPack[] = []) {
|
|
28
|
+
this.#ctx = ctx;
|
|
29
|
+
this.#packs = packs;
|
|
30
|
+
}
|
|
31
|
+
render(values: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string, width: number = DEFAULT_PREVIEW_WIDTH): PreviewResult {
|
|
32
|
+
if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values, width);
|
|
33
|
+
if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values, width);
|
|
34
|
+
if (activeItemId === "useNerdFont") return { lines: ["", `Icon preview: ${values.useNerdFont ? "" : "π"}`, ""] };
|
|
35
|
+
if (activeItemId?.startsWith("pack:")) return this.packPreview(activeItemId.slice("pack:".length), values);
|
|
26
36
|
let nextRefreshInMs: number | undefined;
|
|
27
37
|
if (values.shimmer !== false) {
|
|
28
38
|
nextRefreshInMs = SHIMMER_INTERVAL_MS;
|
|
@@ -57,7 +67,14 @@ export class PreviewRenderer {
|
|
|
57
67
|
}
|
|
58
68
|
let word = DEFAULT_WORKING_WORD;
|
|
59
69
|
if (features.substituteDefaultMessage) {
|
|
60
|
-
|
|
70
|
+
const packValues = this.packValues(values);
|
|
71
|
+
const signature = JSON.stringify(Object.keys(packValues).sort().map(k => `${k}=${packValues[k]}`));
|
|
72
|
+
if (signature !== this.#cachedPackSignature) {
|
|
73
|
+
const enabledPacks = this.#packs.filter((pack) => isWordPackEnabled(pack.id, packValues));
|
|
74
|
+
this.#cachedPackWord = enabledPacks.length ? selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text : this.#word;
|
|
75
|
+
this.#cachedPackSignature = signature;
|
|
76
|
+
}
|
|
77
|
+
word = this.#cachedPackWord;
|
|
61
78
|
}
|
|
62
79
|
let styledWord: string;
|
|
63
80
|
if (decorations.shimmer) {
|
|
@@ -106,13 +123,28 @@ export class PreviewRenderer {
|
|
|
106
123
|
tokenRate,
|
|
107
124
|
}, order);
|
|
108
125
|
}
|
|
109
|
-
private
|
|
126
|
+
private packValues(values: Record<string, boolean | string>): Record<string, boolean> {
|
|
127
|
+
const enabled: Record<string, boolean> = {};
|
|
128
|
+
for (const pack of this.#packs) {
|
|
129
|
+
const value = values[`pack:${pack.id}`];
|
|
130
|
+
if (typeof value === "boolean") enabled[pack.id] = value;
|
|
131
|
+
}
|
|
132
|
+
return enabled;
|
|
133
|
+
}
|
|
134
|
+
private packPreview(id: string, values: Record<string, boolean | string>): PreviewResult {
|
|
135
|
+
const pack = this.#packs.find((candidate) => candidate.id === id);
|
|
136
|
+
if (!pack) return { lines: ["This word pack is unavailable."] };
|
|
137
|
+
const enabled = isWordPackEnabled(id, this.packValues(values));
|
|
138
|
+
const source = pack.bundled ? "Shipped example" : `Custom: ${wordPacksPath()}`;
|
|
139
|
+
return { lines: [`${pack.name}: ${enabled ? "enabled" : "disabled"}`, `${pack.words.length} phrases · ${source}`, ...(pack.description ? [pack.description] : [])] };
|
|
140
|
+
}
|
|
141
|
+
private promptPreview(values: Record<string, boolean | string>, width: number): PreviewResult {
|
|
110
142
|
if (values.decorateUserPrompt !== true) return { lines: ["User prompt decoration is disabled."] };
|
|
111
143
|
|
|
112
144
|
const borderColor = values.borderColor;
|
|
113
145
|
const borderStyle = values.borderStyle;
|
|
114
146
|
const timestamp = values.promptTimestamp === true ? Date.now() : undefined;
|
|
115
|
-
const lines = buildPromptBoxLines("ping", timestamp,
|
|
147
|
+
const lines = buildPromptBoxLines("ping", timestamp, width, this.#ctx.ui.theme, {
|
|
116
148
|
showIcon: values.promptIcon === true,
|
|
117
149
|
showTimestamp: values.promptTimestamp === true,
|
|
118
150
|
showProvider: values.promptProvider === true,
|
|
@@ -127,7 +159,7 @@ export class PreviewRenderer {
|
|
|
127
159
|
? { lines }
|
|
128
160
|
: { lines, nextRefreshInMs: 1_000 - (timestamp % 1_000) };
|
|
129
161
|
}
|
|
130
|
-
private markerPreview(values: Record<string, boolean | string
|
|
162
|
+
private markerPreview(values: Record<string, boolean | string>, width: number): PreviewResult {
|
|
131
163
|
if (values.doneMarker !== true) return { lines: ["Completion marker is disabled."] };
|
|
132
164
|
const theme = this.#ctx.ui.theme;
|
|
133
165
|
const icon = values.doneMarkerIcon === true ? theme.fg("text", values.useNerdFont === true ? "" : "π") : "";
|
|
@@ -138,7 +170,8 @@ export class PreviewRenderer {
|
|
|
138
170
|
const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details);
|
|
139
171
|
const borderStyle = isDoneMarkerBorderStyle(values.doneMarkerBorderStyle) ? values.doneMarkerBorderStyle : "none";
|
|
140
172
|
const borderColor = isSettingColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
|
|
141
|
-
const
|
|
173
|
+
const markerStyle = isDoneMarkerStyle(values.doneMarkerStyle) ? values.doneMarkerStyle : DEFAULT_SETTINGS.decorations.doneMarkerStyle;
|
|
174
|
+
const marker = buildCompletionMarkerLine(content, width, theme, borderStyle, borderColor, markerStyle);
|
|
142
175
|
return { lines: ["", marker, ""] };
|
|
143
176
|
}
|
|
144
177
|
}
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MessageRenderer, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
-
import { DEFAULT_SETTINGS, isBorderStyle, isSettingColor, type BorderStyle, type DoneMarkerBorderStyle, type SettingColor } from "./settings.ts";
|
|
3
|
+
import { DEFAULT_SETTINGS, isBorderStyle, isSettingColor, type BorderStyle, type DoneMarkerBorderStyle, type DoneMarkerStyle, type SettingColor } from "./settings.ts";
|
|
4
|
+
import { stripControlChars } from "./util.ts";
|
|
4
5
|
|
|
5
6
|
export const PROMPT_BOX_TYPE = "pi-topping-prompt";
|
|
6
7
|
|
|
@@ -32,11 +33,6 @@ type PromptTheme = { fg(color: string, text: string): string };
|
|
|
32
33
|
|
|
33
34
|
const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
|
|
34
35
|
|
|
35
|
-
/** Strip control and formatting characters (bidi overrides, zero-width joiners, etc.) from untrusted single-line text. */
|
|
36
|
-
export function stripControlChars(text: string): string {
|
|
37
|
-
return text.replace(/[\p{Cc}\p{Cf}]/gu, "");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
36
|
/** Assemble the completion-marker content: an optional icon, then the dim `word for elapsed (details)` summary. */
|
|
41
37
|
export function buildCompletionMarkerContent(
|
|
42
38
|
theme: PromptTheme,
|
|
@@ -56,6 +52,7 @@ export function buildCompletionMarkerLine(
|
|
|
56
52
|
theme: PromptTheme,
|
|
57
53
|
borderStyle: DoneMarkerBorderStyle,
|
|
58
54
|
borderColor: SettingColor,
|
|
55
|
+
markerStyle: DoneMarkerStyle = DEFAULT_SETTINGS.decorations.doneMarkerStyle,
|
|
59
56
|
): string {
|
|
60
57
|
const safeWidth = Math.max(0, Math.floor(width));
|
|
61
58
|
if (safeWidth === 0) return "";
|
|
@@ -63,8 +60,12 @@ export function buildCompletionMarkerLine(
|
|
|
63
60
|
|
|
64
61
|
const g = BORDER_GLYPHS[borderStyle];
|
|
65
62
|
const border = (text: string) => theme.fg(borderColor, text);
|
|
66
|
-
const
|
|
67
|
-
|
|
63
|
+
const prefix = `${g.bl}${g.h.repeat(2)} `;
|
|
64
|
+
|
|
65
|
+
const suffix = markerStyle === "bookend"
|
|
66
|
+
? ` ${g.h.repeat(Math.max(0, safeWidth - visibleWidth(prefix) - visibleWidth(content) - 2))}${g.br}`
|
|
67
|
+
: ` ${COMPLETION_MARKER_TRAIL_RUNS.map(length => g.h.repeat(length)).join(" ")}`;
|
|
68
|
+
const line = `${border(prefix)}${content}${border(suffix)}`;
|
|
68
69
|
return visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth) : line;
|
|
69
70
|
}
|
|
70
71
|
|