@underactive/pi-topping 0.3.1 → 0.5.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/README.md +59 -17
- package/package.json +5 -5
- package/src/activity-meter.ts +11 -8
- package/src/format.ts +159 -47
- package/src/menu.ts +84 -23
- package/src/preview.ts +115 -32
- package/src/prompt-decorator.ts +85 -10
- package/src/session.ts +184 -58
- package/src/settings.ts +168 -38
package/src/menu.ts
CHANGED
|
@@ -5,17 +5,17 @@
|
|
|
5
5
|
* extensions, built on top of `@earendil-works/pi-tui`'s `Component`
|
|
6
6
|
* contract and `ctx.ui.custom()` overlay API.
|
|
7
7
|
*
|
|
8
|
-
* Renders a titled box containing one or more sections of boolean toggle
|
|
9
|
-
*
|
|
10
|
-
* caller-supplied render callback, e.g.:
|
|
8
|
+
* Renders a titled box containing one or more sections of boolean toggle,
|
|
9
|
+
* multi-value cycle, or drag-to-reorder items, plus an optional live-updating
|
|
10
|
+
* preview section driven by a caller-supplied render callback, e.g.:
|
|
11
11
|
*
|
|
12
12
|
* ╔═[ Pi Topping: Settings ]════════════════════════╗
|
|
13
|
-
* ╟─ Preview
|
|
13
|
+
* ╟─ Preview ────────────────────────────────────╢
|
|
14
14
|
* ║ ║
|
|
15
|
-
* ║ ⠋ Cerebrating… ⣤⣤⣤⣤
|
|
15
|
+
* ║ ⠋ Cerebrating… ⣤⣤⣤⣤ 28 tok/s · 3s · ↓ 84 tokens ║
|
|
16
16
|
* ║ ║
|
|
17
17
|
* ╟─ Decorations ───────────────────────────────────╢
|
|
18
|
-
* ║
|
|
18
|
+
* ║ ❯ [■] Animated spinner ON ║
|
|
19
19
|
* ║ [ ] "Working..." text shimmer OFF ║
|
|
20
20
|
* ║ ║
|
|
21
21
|
* ╟──────────────────────────────────────────────────╢
|
|
@@ -49,6 +49,13 @@ export interface MenuItem {
|
|
|
49
49
|
cycleEnabled?: boolean;
|
|
50
50
|
/** Value snapped to when the gating checkbox is unchecked. */
|
|
51
51
|
cycleDisabledValue?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Marks the item as a reorderable row. Space grabs/releases it; while grabbed,
|
|
54
|
+
* up/down move it among the other rows sharing this group instead of moving the
|
|
55
|
+
* cursor. The group's current order is published as a comma-joined list of item
|
|
56
|
+
* ids under this key in the result values.
|
|
57
|
+
*/
|
|
58
|
+
reorderGroup?: string;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
export interface MenuSection {
|
|
@@ -83,6 +90,7 @@ export interface MenuResult<T> {
|
|
|
83
90
|
|
|
84
91
|
const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
|
|
85
92
|
const MAX_WIDTH = 76;
|
|
93
|
+
const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
|
|
86
94
|
|
|
87
95
|
interface FlatItem {
|
|
88
96
|
id: string;
|
|
@@ -90,18 +98,26 @@ interface FlatItem {
|
|
|
90
98
|
cycleValues?: readonly string[];
|
|
91
99
|
cycleEnabledBy?: string;
|
|
92
100
|
cycleDisabledValue?: string;
|
|
101
|
+
reorderGroup?: string;
|
|
93
102
|
item: MenuItem;
|
|
94
103
|
sectionIndex: number;
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
function buildInitialValues(config: MenuConfig): Record<string, MenuValue> {
|
|
98
107
|
const values: Record<string, MenuValue> = {};
|
|
108
|
+
const reorderGroups = new Map<string, string[]>();
|
|
99
109
|
for (const section of config.sections) {
|
|
100
110
|
for (const item of section.items) {
|
|
101
111
|
values[item.id] = item.value;
|
|
102
112
|
if (item.cycleEnabledBy) values[item.cycleEnabledBy] = item.cycleEnabled ?? true;
|
|
113
|
+
if (item.reorderGroup) {
|
|
114
|
+
const ids = reorderGroups.get(item.reorderGroup) ?? [];
|
|
115
|
+
ids.push(item.id);
|
|
116
|
+
reorderGroups.set(item.reorderGroup, ids);
|
|
117
|
+
}
|
|
103
118
|
}
|
|
104
119
|
}
|
|
120
|
+
for (const [group, ids] of reorderGroups) values[group] = ids.join(",");
|
|
105
121
|
return values;
|
|
106
122
|
}
|
|
107
123
|
|
|
@@ -144,7 +160,7 @@ export class MenuComponent implements Component {
|
|
|
144
160
|
this.flat = [];
|
|
145
161
|
for (const [sectionIndex, section] of config.sections.entries()) {
|
|
146
162
|
for (const item of section.items) {
|
|
147
|
-
this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, item, sectionIndex });
|
|
163
|
+
this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, reorderGroup: item.reorderGroup, item, sectionIndex });
|
|
148
164
|
}
|
|
149
165
|
}
|
|
150
166
|
this.initialValues = { ...this.values };
|
|
@@ -191,10 +207,12 @@ export class MenuComponent implements Component {
|
|
|
191
207
|
|
|
192
208
|
const keyActions: Record<string, () => void> = {
|
|
193
209
|
[Key.up]: () => {
|
|
210
|
+
if (this.moveGrabbedItem(-1)) return;
|
|
194
211
|
this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
|
|
195
212
|
this.invalidate();
|
|
196
213
|
},
|
|
197
214
|
[Key.down]: () => {
|
|
215
|
+
if (this.moveGrabbedItem(1)) return;
|
|
198
216
|
this.cursor = (this.cursor + 1) % this.flat.length;
|
|
199
217
|
this.invalidate();
|
|
200
218
|
},
|
|
@@ -233,6 +251,32 @@ export class MenuComponent implements Component {
|
|
|
233
251
|
return lines;
|
|
234
252
|
}
|
|
235
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Move the grabbed reorder row one slot within its group, clamped at the group
|
|
256
|
+
* edges. Returns true when the key belongs to the mover and must not also move
|
|
257
|
+
* the cursor — which is what keeps a grab from escaping its group.
|
|
258
|
+
*/
|
|
259
|
+
private moveGrabbedItem(delta: number): boolean {
|
|
260
|
+
const grabbed = this.flat[this.cursor]!;
|
|
261
|
+
const group = grabbed.reorderGroup;
|
|
262
|
+
if (group === undefined || this.values[grabbed.id] !== true) return false;
|
|
263
|
+
|
|
264
|
+
let start = this.cursor;
|
|
265
|
+
while (start > 0 && this.flat[start - 1]!.reorderGroup === group) start--;
|
|
266
|
+
let end = this.cursor;
|
|
267
|
+
while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
|
|
268
|
+
|
|
269
|
+
const target = this.cursor + delta;
|
|
270
|
+
if (target >= start && target <= end) {
|
|
271
|
+
this.flat[this.cursor] = this.flat[target]!;
|
|
272
|
+
this.flat[target] = grabbed;
|
|
273
|
+
this.cursor = target;
|
|
274
|
+
this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
|
|
275
|
+
this.invalidate();
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
236
280
|
private cycleCurrentValue(delta: number): void {
|
|
237
281
|
const item = this.flat[this.cursor]!;
|
|
238
282
|
if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
|
|
@@ -279,13 +323,20 @@ export class MenuComponent implements Component {
|
|
|
279
323
|
return [this.renderSectionDivider("Preview", innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
|
|
280
324
|
}
|
|
281
325
|
|
|
326
|
+
/** Render every section from `this.flat`, the single source of truth for row order. */
|
|
282
327
|
private buildToggleSections(innerWidth: number): string[] {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
328
|
+
const lines: string[] = [];
|
|
329
|
+
let currentSection = -1;
|
|
330
|
+
for (const [index, flat] of this.flat.entries()) {
|
|
331
|
+
if (flat.sectionIndex !== currentSection) {
|
|
332
|
+
if (currentSection !== -1) lines.push(this.renderBlankRow(innerWidth));
|
|
333
|
+
lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
|
|
334
|
+
currentSection = flat.sectionIndex;
|
|
335
|
+
}
|
|
336
|
+
lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
|
|
337
|
+
}
|
|
338
|
+
if (lines.length) lines.push(this.renderBlankRow(innerWidth));
|
|
339
|
+
return lines;
|
|
289
340
|
}
|
|
290
341
|
|
|
291
342
|
/** Build a section-aware item window, keeping a divider above each visible section. */
|
|
@@ -392,7 +443,7 @@ export class MenuComponent implements Component {
|
|
|
392
443
|
const prefix = titlePrefix ?? "";
|
|
393
444
|
const suffix = titleSuffix ?? "";
|
|
394
445
|
const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length);
|
|
395
|
-
const shownTitle = title
|
|
446
|
+
const shownTitle = visibleWidth(title) > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
|
|
396
447
|
const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
|
|
397
448
|
const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
|
|
398
449
|
const titleContent = th.fg("text", styledTitle);
|
|
@@ -443,32 +494,42 @@ export class MenuComponent implements Component {
|
|
|
443
494
|
private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
|
|
444
495
|
const th = this.theme;
|
|
445
496
|
const value = this.values[item.id]!;
|
|
446
|
-
const marker = selected ? "\
|
|
497
|
+
const marker = selected ? "\u276f" : " ";
|
|
447
498
|
const markerColored = selected ? th.fg("accent", marker) : marker;
|
|
448
499
|
|
|
500
|
+
if (item.reorderGroup) {
|
|
501
|
+
const held = value as boolean;
|
|
502
|
+
const stateWord = held ? "\u2191 \u2193" : "";
|
|
503
|
+
const rightPlain = `${stateWord} `;
|
|
504
|
+
const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
|
|
505
|
+
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
506
|
+
const leftPlain = ` ${marker} [${held ? "\u25a0" : " "}] ${label}`;
|
|
507
|
+
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
508
|
+
const content = ` ${markerColored} [${held ? th.fg("accent", "\u25a0") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${held ? th.fg("accent", stateWord) : ""} `;
|
|
509
|
+
return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
|
|
510
|
+
}
|
|
511
|
+
|
|
449
512
|
if (item.cycleValues) {
|
|
450
513
|
const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
|
|
451
514
|
const stateWord = `‹ ${value} ›`;
|
|
452
|
-
const
|
|
453
|
-
const
|
|
454
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
515
|
+
const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1);
|
|
516
|
+
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
455
517
|
const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
|
|
456
518
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
|
|
457
519
|
const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
|
|
458
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
520
|
+
return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
|
|
459
521
|
}
|
|
460
522
|
|
|
461
523
|
const enabled = value as boolean;
|
|
462
524
|
const box = enabled ? "\u25a0" : " ";
|
|
463
525
|
const stateWord = enabled ? "ON" : "OFF";
|
|
464
526
|
const rightPlain = `${stateWord} `;
|
|
465
|
-
const
|
|
466
|
-
const
|
|
467
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
527
|
+
const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
|
|
528
|
+
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
468
529
|
const leftPlain = ` ${marker} [${box}] ${label}`;
|
|
469
530
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
470
531
|
const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `;
|
|
471
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
532
|
+
return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
|
|
472
533
|
}
|
|
473
534
|
}
|
|
474
535
|
|
package/src/preview.ts
CHANGED
|
@@ -1,61 +1,144 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { PreviewResult } from "./menu.ts";
|
|
3
3
|
import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
|
|
4
|
-
import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, SPINNER_FRAMES } from "./format.ts";
|
|
5
|
-
import { buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
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
|
+
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
7
|
import { pickRandomWord } from "./words.ts";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
8
|
+
// Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tok/s for the meter,
|
|
9
|
+
// flat 28 tok/s for the token readouts.
|
|
10
|
+
const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28, PREVIEW_WIDTH = 70;
|
|
11
|
+
const PROMPT_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "User Prompt").map(entry => entry.id));
|
|
12
|
+
const MARKER_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "Completion Marker").map(entry => entry.id));
|
|
11
13
|
function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
|
|
12
14
|
|
|
13
15
|
/** Stateful, per-menu preview renderer that follows the active settings section. */
|
|
14
16
|
export class PreviewRenderer {
|
|
15
|
-
readonly #word = pickRandomWord();
|
|
17
|
+
readonly #word = pickRandomWord();
|
|
18
|
+
readonly #meter = new ActivityMeter();
|
|
19
|
+
#lastMeterUpdate = 0;
|
|
16
20
|
readonly #ctx: ExtensionContext;
|
|
17
21
|
constructor(ctx: ExtensionContext) { this.#ctx = ctx; }
|
|
18
22
|
render(values: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string): PreviewResult {
|
|
19
23
|
if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values);
|
|
20
24
|
if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values);
|
|
21
25
|
if (activeItemId === "useNerdFont") return { lines: [`Icon preview: ${values.useNerdFont ? "" : "π"}`] };
|
|
22
|
-
|
|
26
|
+
let nextRefreshInMs: number | undefined;
|
|
27
|
+
if (values.shimmer !== false) {
|
|
28
|
+
nextRefreshInMs = SHIMMER_INTERVAL_MS;
|
|
29
|
+
} else if (values.animatedSpinner !== false) {
|
|
30
|
+
nextRefreshInMs = SPINNER_FRAME_MS;
|
|
31
|
+
} else if (values.tokenActivityMonitor !== false) {
|
|
32
|
+
nextRefreshInMs = METER_INTERVAL_MS;
|
|
33
|
+
} else if (values.elapsedTime !== false || values.outputTokens !== false) {
|
|
34
|
+
nextRefreshInMs = ELAPSED_INTERVAL_MS;
|
|
35
|
+
}
|
|
36
|
+
return { lines: ["", this.loaderPreview(values, elapsedMs), ""], nextRefreshInMs };
|
|
23
37
|
}
|
|
24
38
|
private loaderPreview(values: Record<string, boolean | string>, elapsedMs: number): string {
|
|
25
|
-
this.#meter.setDirection(values.meterDirection
|
|
26
|
-
if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
this.#meter.setDirection(fromCycleDirection(values.meterDirection));
|
|
40
|
+
if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
|
|
41
|
+
this.#meter.push(rateToLevel(meterRate(elapsedMs)));
|
|
42
|
+
this.#lastMeterUpdate = elapsedMs;
|
|
43
|
+
}
|
|
44
|
+
const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false };
|
|
45
|
+
const decorations = { shimmer: values.shimmer !== false, shimmerInverted: values.shimmerInverted === true, tokenActivityMonitor: values.tokenActivityMonitor !== false };
|
|
46
|
+
let spinnerColor = DEFAULT_SETTINGS.decorations.spinnerColor;
|
|
47
|
+
if (values.spinnerColorEnabled !== false && isSettingColor(values.spinnerColor)) {
|
|
48
|
+
spinnerColor = values.spinnerColor;
|
|
49
|
+
}
|
|
50
|
+
let spinner = "";
|
|
51
|
+
if (values.animatedSpinner !== false) {
|
|
52
|
+
spinner = this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
|
|
53
|
+
}
|
|
54
|
+
const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
|
|
55
|
+
if (isFullyDefaultAppearance(features, decorations)) {
|
|
56
|
+
return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, order);
|
|
57
|
+
}
|
|
58
|
+
let word = DEFAULT_WORKING_WORD;
|
|
59
|
+
if (features.substituteDefaultMessage) {
|
|
60
|
+
word = this.#word;
|
|
61
|
+
}
|
|
62
|
+
let styledWord: string;
|
|
63
|
+
if (decorations.shimmer) {
|
|
64
|
+
styledWord = shimmerString(word, elapsedMs, this.#ctx.ui.theme, fromCycleDirection(values.shimmerDirection), fromCycleSpeed(values.shimmerSpeed), decorations.shimmerInverted);
|
|
65
|
+
} else {
|
|
66
|
+
styledWord = this.#ctx.ui.theme.fg("text", word);
|
|
67
|
+
}
|
|
68
|
+
let meterColor = DEFAULT_SETTINGS.decorations.meterColor;
|
|
69
|
+
if (values.meterColorEnabled !== false && isSettingColor(values.meterColor)) {
|
|
70
|
+
meterColor = values.meterColor;
|
|
71
|
+
}
|
|
72
|
+
let meter = "";
|
|
73
|
+
if (decorations.tokenActivityMonitor) {
|
|
74
|
+
meter = this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColor, values.meterDimmed === true));
|
|
75
|
+
}
|
|
76
|
+
let tokenRateText = "";
|
|
77
|
+
if (features.tokenRate) {
|
|
78
|
+
tokenRateText = formatTokenRate(TOKEN_RATE_PER_SEC);
|
|
79
|
+
}
|
|
80
|
+
let tokenRateColor = DEFAULT_SETTINGS.decorations.tokenRateColor;
|
|
81
|
+
if (isSettingColor(values.tokenRateColor)) {
|
|
82
|
+
tokenRateColor = values.tokenRateColor;
|
|
83
|
+
}
|
|
84
|
+
let tokenRateColored = "";
|
|
85
|
+
if (tokenRateText) {
|
|
86
|
+
tokenRateColored = this.#ctx.ui.theme.fg(tokenRateColor, tokenRateText);
|
|
87
|
+
}
|
|
88
|
+
let tokenRate = tokenRateColored;
|
|
89
|
+
if (tokenRateColored && values.tokenRateDimmed === true) {
|
|
90
|
+
tokenRate = dimAttribute(tokenRateColored);
|
|
91
|
+
}
|
|
92
|
+
let elapsed = "";
|
|
93
|
+
if (features.elapsedTime) {
|
|
94
|
+
elapsed = formatElapsed(elapsedMs);
|
|
95
|
+
}
|
|
96
|
+
let tokens = "";
|
|
97
|
+
if (features.outputTokens) {
|
|
98
|
+
tokens = `↓ ${formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)))} tokens`;
|
|
99
|
+
}
|
|
100
|
+
return buildWorkingMessage(this.#ctx.ui.theme, {
|
|
101
|
+
spinner,
|
|
102
|
+
text: styledWord,
|
|
103
|
+
meter,
|
|
104
|
+
elapsed,
|
|
105
|
+
tokens,
|
|
106
|
+
tokenRate,
|
|
107
|
+
}, order);
|
|
37
108
|
}
|
|
38
109
|
private promptPreview(values: Record<string, boolean | string>): PreviewResult {
|
|
39
|
-
if (
|
|
110
|
+
if (values.decorateUserPrompt !== true) return { lines: ["User prompt decoration is disabled."] };
|
|
40
111
|
|
|
41
112
|
const borderColor = values.borderColor;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
113
|
+
const borderStyle = values.borderStyle;
|
|
114
|
+
const timestamp = values.promptTimestamp === true ? Date.now() : undefined;
|
|
115
|
+
const lines = buildPromptBoxLines("ping", timestamp, PREVIEW_WIDTH, this.#ctx.ui.theme, {
|
|
116
|
+
showIcon: values.promptIcon === true,
|
|
117
|
+
showTimestamp: values.promptTimestamp === true,
|
|
118
|
+
showProvider: values.promptProvider === true,
|
|
119
|
+
showModel: values.promptModel === true,
|
|
46
120
|
icon: values.useNerdFont ? "" : "π",
|
|
47
|
-
|
|
121
|
+
provider: this.#ctx.model?.provider,
|
|
122
|
+
model: this.#ctx.model?.id,
|
|
123
|
+
borderColor: isSettingColor(borderColor) ? borderColor : DEFAULT_SETTINGS.decorations.borderColor,
|
|
124
|
+
borderStyle: isBorderStyle(borderStyle) ? borderStyle : "double",
|
|
48
125
|
});
|
|
49
126
|
return timestamp === undefined
|
|
50
127
|
? { lines }
|
|
51
128
|
: { lines, nextRefreshInMs: 1_000 - (timestamp % 1_000) };
|
|
52
129
|
}
|
|
53
130
|
private markerPreview(values: Record<string, boolean | string>): PreviewResult {
|
|
54
|
-
if (
|
|
55
|
-
const
|
|
56
|
-
const icon = values.doneMarkerIcon ?
|
|
57
|
-
const word =
|
|
58
|
-
const
|
|
59
|
-
|
|
131
|
+
if (values.doneMarker !== true) return { lines: ["Completion marker is disabled."] };
|
|
132
|
+
const theme = this.#ctx.ui.theme;
|
|
133
|
+
const icon = values.doneMarkerIcon === true ? theme.fg("text", values.useNerdFont === true ? "" : "π") : "";
|
|
134
|
+
const word = values.randomizeDoneMarker === true ? "Concocted" : "Worked";
|
|
135
|
+
const details: string[] = [];
|
|
136
|
+
if (values.doneMarkerTokens === true) details.push("↓ 949 tokens");
|
|
137
|
+
if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
|
|
138
|
+
const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details);
|
|
139
|
+
const borderStyle = isDoneMarkerBorderStyle(values.doneMarkerBorderStyle) ? values.doneMarkerBorderStyle : "none";
|
|
140
|
+
const borderColor = isSettingColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
|
|
141
|
+
const marker = buildCompletionMarkerLine(content, PREVIEW_WIDTH, theme, borderStyle, borderColor);
|
|
142
|
+
return { lines: ["", marker, ""] };
|
|
60
143
|
}
|
|
61
144
|
}
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -1,18 +1,73 @@
|
|
|
1
|
-
import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
|
|
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
4
|
|
|
4
5
|
export const PROMPT_BOX_TYPE = "pi-topping-prompt";
|
|
5
6
|
|
|
7
|
+
const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; br: string; h: string; v: string }> = {
|
|
8
|
+
double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" },
|
|
9
|
+
single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
|
|
10
|
+
rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
|
|
11
|
+
heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* False visibility options hide each corresponding part; present provider and
|
|
16
|
+
* model values join with `/` in the bottom border.
|
|
17
|
+
*/
|
|
6
18
|
export interface PromptBoxDetails {
|
|
7
19
|
submittedAt?: number;
|
|
8
20
|
showIcon?: boolean;
|
|
9
21
|
showTimestamp?: boolean;
|
|
22
|
+
showProvider?: boolean;
|
|
23
|
+
showModel?: boolean;
|
|
10
24
|
icon?: string;
|
|
11
|
-
|
|
25
|
+
provider?: string;
|
|
26
|
+
model?: string;
|
|
27
|
+
borderColor?: ThemeColor;
|
|
28
|
+
borderStyle?: BorderStyle;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
type PromptTheme = { fg(color: string, text: string): string };
|
|
15
32
|
|
|
33
|
+
const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
|
|
34
|
+
|
|
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
|
+
/** Assemble the completion-marker content: an optional icon, then the dim `word for elapsed (details)` summary. */
|
|
41
|
+
export function buildCompletionMarkerContent(
|
|
42
|
+
theme: PromptTheme,
|
|
43
|
+
icon: string,
|
|
44
|
+
word: string,
|
|
45
|
+
elapsed: string,
|
|
46
|
+
details: string[],
|
|
47
|
+
): string {
|
|
48
|
+
const tail = details.length ? ` (${details.join(" · ")})` : "";
|
|
49
|
+
return `${icon}${theme.fg("dim", `${icon ? " " : ""}${word} for ${elapsed}${tail}`)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build one decorated completion-marker line, clipped to the available width. */
|
|
53
|
+
export function buildCompletionMarkerLine(
|
|
54
|
+
content: string,
|
|
55
|
+
width: number,
|
|
56
|
+
theme: PromptTheme,
|
|
57
|
+
borderStyle: DoneMarkerBorderStyle,
|
|
58
|
+
borderColor: SettingColor,
|
|
59
|
+
): string {
|
|
60
|
+
const safeWidth = Math.max(0, Math.floor(width));
|
|
61
|
+
if (safeWidth === 0) return "";
|
|
62
|
+
if (borderStyle === "none") return truncateToWidth(content, safeWidth);
|
|
63
|
+
|
|
64
|
+
const g = BORDER_GLYPHS[borderStyle];
|
|
65
|
+
const border = (text: string) => theme.fg(borderColor, text);
|
|
66
|
+
const trail = ` ${COMPLETION_MARKER_TRAIL_RUNS.map(length => g.h.repeat(length)).join(" ")}`;
|
|
67
|
+
const line = `${border(`${g.bl}${g.h.repeat(2)} `)}${content}${border(trail)}`;
|
|
68
|
+
return visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth) : line;
|
|
69
|
+
}
|
|
70
|
+
|
|
16
71
|
/** Build the rendered lines for a decorated user prompt. */
|
|
17
72
|
export function buildPromptBoxLines(
|
|
18
73
|
content: string,
|
|
@@ -23,7 +78,10 @@ export function buildPromptBoxLines(
|
|
|
23
78
|
): string[] {
|
|
24
79
|
if (width < 10) return [];
|
|
25
80
|
|
|
26
|
-
const
|
|
81
|
+
const borderStyle = isBorderStyle(options.borderStyle) ? options.borderStyle : "double";
|
|
82
|
+
const g = BORDER_GLYPHS[borderStyle];
|
|
83
|
+
const borderColor = isSettingColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
|
|
84
|
+
const border = (text: string) => theme.fg(borderColor, text);
|
|
27
85
|
const label = (text: string) => theme.fg("customMessageLabel", text);
|
|
28
86
|
const muted = (text: string) => theme.fg("dim", text);
|
|
29
87
|
const bodyText = (text: string) => theme.fg("customMessageText", text);
|
|
@@ -36,12 +94,27 @@ export function buildPromptBoxLines(
|
|
|
36
94
|
second: "2-digit",
|
|
37
95
|
});
|
|
38
96
|
const innerWidth = width - 2;
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
97
|
+
const rawIcon = options.icon ?? "";
|
|
98
|
+
const icon = options.showIcon === false || typeof rawIcon !== "string"
|
|
99
|
+
? ""
|
|
100
|
+
: stripControlChars(rawIcon);
|
|
101
|
+
const provider = options.showProvider === false || typeof options.provider !== "string"
|
|
102
|
+
? ""
|
|
103
|
+
: stripControlChars(options.provider);
|
|
104
|
+
const model = options.showModel === false || typeof options.model !== "string"
|
|
105
|
+
? ""
|
|
106
|
+
: stripControlChars(options.model);
|
|
107
|
+
const combinedLabel = [provider, model].filter(Boolean).join("/");
|
|
108
|
+
// truncateToWidth injects reset codes when clipping; remove them before muted() styles the label.
|
|
109
|
+
const labelText = combinedLabel
|
|
110
|
+
? truncateToWidth(combinedLabel, Math.max(0, innerWidth - 3)).replace(/\x1b\[0m/g, "")
|
|
111
|
+
: "";
|
|
112
|
+
const titleLength = icon ? visibleWidth(`${g.h}${g.h} ${icon} `) : 0;
|
|
42
113
|
const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
|
|
43
114
|
const topFill = Math.max(0, innerWidth - titleLength - timeLength);
|
|
44
|
-
const
|
|
115
|
+
const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${label(icon)}${border(" ")}` : "";
|
|
116
|
+
const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : "";
|
|
117
|
+
const topLine = border(g.tl) + iconSeg + border(g.h.repeat(topFill)) + timeSeg + border(g.tr);
|
|
45
118
|
const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
|
|
46
119
|
|
|
47
120
|
if (content) {
|
|
@@ -49,18 +122,20 @@ export function buildPromptBoxLines(
|
|
|
49
122
|
for (const rawLine of wrapTextWithAnsi(content, textWidth)) {
|
|
50
123
|
const displayLine = rawLine || " ";
|
|
51
124
|
const padded = `${displayLine}${" ".repeat(Math.max(0, textWidth - visibleWidth(displayLine)))}`;
|
|
52
|
-
const line = `${border(
|
|
125
|
+
const line = `${border(g.v)}${bodyText(` ${padded} `)}${border(g.v)}`;
|
|
53
126
|
lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
|
|
54
127
|
}
|
|
55
128
|
}
|
|
56
129
|
|
|
57
|
-
const
|
|
130
|
+
const labelLength = labelText ? visibleWidth(labelText) + 3 : 0; // label + right-side " ═"
|
|
131
|
+
const labelSeg = labelText ? `${border(" ")}${muted(labelText)}${border(` ${g.h}`)}` : "";
|
|
132
|
+
const bottomLine = `${border(g.bl)}${border(g.h.repeat(Math.max(0, innerWidth - labelLength)))}${labelSeg}${border(g.br)}`;
|
|
58
133
|
lines.push(visibleWidth(bottomLine) > width ? truncateToWidth(bottomLine, width) : bottomLine);
|
|
59
134
|
return lines;
|
|
60
135
|
}
|
|
61
136
|
|
|
62
137
|
export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
|
|
63
|
-
const content = typeof message.content === "string" ? message.content : "";
|
|
138
|
+
const content = typeof message.content === "string" ? message.content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "") : "";
|
|
64
139
|
const details = (message.details ?? {}) as PromptBoxDetails;
|
|
65
140
|
const linesByWidth = new Map<number, string[]>();
|
|
66
141
|
return {
|