@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/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
- * or multi-value cycle items, plus an optional live-updating preview section driven by a
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… ⣤⣤⣤⣤ (0m 03s · ↓ 84 tokens) ║
15
+ * ║ ⠋ Cerebrating… ⣤⣤⣤⣤ 28 tok/s · 3s · ↓ 84 tokens ║
16
16
  * ║ ║
17
17
  * ╟─ Decorations ───────────────────────────────────╢
18
- * ║ ▸ [■] Animated spinner ON ║
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
- let flatIndex = 0;
284
- return this.sections.flatMap(section => [
285
- this.renderSectionDivider(section.title, innerWidth),
286
- ...section.items.map(item => this.renderItemRow(item, flatIndex++ === this.cursor, innerWidth)),
287
- this.renderBlankRow(innerWidth),
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.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : 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 ? "\u25b8" : " ";
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 fixedLeftLen = 8;
453
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - visibleWidth(stateWord) - 1);
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 fixedLeftLen = 8; // " " + marker(1) + " " + "[" + box(1) + "]" + " "
466
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - 1);
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
- const METER_INTERVAL_MS = 100, METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
8
- const DEFAULT_WORKING_WORD = "Working…";
9
- const PROMPT_IDS = new Set(["decorateUserPrompt", "borderColor", "promptIcon", "promptTimestamp"]);
10
- const MARKER_IDS = new Set(["doneMarker", "doneMarkerIcon", "randomizeDoneMarker", "doneMarkerTokens"]);
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(); readonly #meter = new ActivityMeter(); #lastMeterUpdate = 0;
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
- return { lines: ["", this.loaderPreview(values, elapsedMs), ""], nextRefreshInMs: 80 };
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 === "Right to Left" ? "rtl" : "ltr");
26
- if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) { this.#meter.push(rateToLevel(meterRate(elapsedMs))); this.#lastMeterUpdate = elapsedMs; }
27
- const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false };
28
- const decorations = { shimmer: values.shimmer !== false, tokenActivityMonitor: values.tokenActivityMonitor !== false };
29
- const spinnerColor = (values.spinnerColorEnabled === false ? "accent" : values.spinnerColor === "border" || values.spinnerColor === "borderAccent" ? values.spinnerColor : "accent") as "accent" | "border" | "borderAccent";
30
- const spinner = values.animatedSpinner ? this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / 80) % SPINNER_FRAMES.length]!) : "";
31
- if (isFullyDefaultAppearance(features, decorations)) return [spinner, this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD)].filter(Boolean).join(" ");
32
- const word = features.substituteDefaultMessage ? this.#word : DEFAULT_WORKING_WORD;
33
- const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme, values.shimmerDirection === "Right to Left" ? "rtl" : "ltr") : this.#ctx.ui.theme.fg("text", word);
34
- const meterColor = (values.meterColorEnabled === false ? "accent" : values.meterColor === "border" || values.meterColor === "borderAccent" ? values.meterColor : "accent") as "accent" | "border" | "borderAccent";
35
- const meter = decorations.tokenActivityMonitor ? this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColor, values.meterDimmed !== false)) : "";
36
- return [spinner, buildWorkingMessage(this.#ctx.ui.theme, styledWord, meter, formatElapsed(elapsedMs), formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC))), features)].filter(Boolean).join(" ");
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 (!values.decorateUserPrompt) return { lines: ["User prompt decoration is disabled."] };
110
+ if (values.decorateUserPrompt !== true) return { lines: ["User prompt decoration is disabled."] };
40
111
 
41
112
  const borderColor = values.borderColor;
42
- const timestamp = values.promptTimestamp ? Date.now() : undefined;
43
- const lines = buildPromptBoxLines("ping", timestamp, 70, this.#ctx.ui.theme, {
44
- showIcon: values.promptIcon as boolean,
45
- showTimestamp: values.promptTimestamp as boolean,
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
- borderColor: borderColor === "accent" || borderColor === "border" || borderColor === "borderAccent" ? borderColor : "accent",
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 (!values.doneMarker) return { lines: ["Completion marker is disabled."] };
55
- const th = this.#ctx.ui.theme;
56
- const icon = values.doneMarkerIcon ? `${values.useNerdFont ? "" : "π"} ` : "";
57
- const word = (values.randomizeDoneMarker ? "Concocted" : "Worked") as string;
58
- const tail = (values.doneMarkerTokens ? " (↓ 949 tokens)" : "") as string;
59
- return { lines: ["", `${values.doneMarkerIcon ? th.fg("text", icon) : ""}${th.fg("dim", `${word} for 2m 52s${tail}`)}`, ""] };
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
  }
@@ -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
- borderColor?: "accent" | "border" | "borderAccent";
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 border = (text: string) => theme.fg(options.borderColor ?? "accent", text);
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 icon = options.showIcon === false ? "" : (options.icon ?? "");
40
- const title = icon ? `══ ${icon} ` : "";
41
- const titleLength = visibleWidth(title);
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 topLine = `${border("╔")}${icon ? `${border("══ ")}${label(icon)}${border(" ")}` : ""}${border("═".repeat(topFill))}${time ? `${border(" ")}${muted(time)}${border(" ═")}` : ""}${border("╗")}`;
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("║")}${bodyText(` ${padded} `)}${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 bottomLine = `${border("╚")}${border("═".repeat(innerWidth))}${border("╝")}`;
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 {