@underactive/pi-topping 0.11.1 → 0.13.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/preview.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
3
- import { ActivityMeter, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN, rateToLevel } from "./activity-meter.ts";
4
- import { DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatTokenRate, getThinkingLevelColorizer, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, SPINNER_FRAME_MS } from "./format.ts";
2
+ import type { MenuValue, PreviewSlot } from "@underactive/pi-topping-primitives/menu";
3
+ import { ActivityMeter, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN, rateToLevel } from "@underactive/pi-topping-primitives/activity";
4
+ import { dimAttribute, renderMeter, thinkingLevelColorizer } from "@underactive/pi-topping-primitives/activity-tui";
5
+ import { DEFAULT_WORKING_WORD, ELAPSED_INTERVAL_MS, formatTokenRate, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, SPINNER_FRAME_MS } from "./format.ts";
5
6
  import { buildLoaderMessage } from "./loader-message.ts";
6
7
  import { getResponseModelColorizer } from "./nvidia-green.ts";
7
8
  import { isOutputStyleId, OUTPUT_STYLES } from "./output-style.ts";
@@ -12,12 +13,24 @@ import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, w
12
13
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
13
14
  // flat 28 tps for the token readouts.
14
15
  const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
16
+ /** Width the preview assumes when rendered outside a menu (tests and probes). */
17
+ export const DEFAULT_PREVIEW_WIDTH = 76;
18
+ type Values = Record<string, MenuValue>;
19
+ type PreviewState = { elapsedMs: number; activeItemId: string | undefined; width: number };
20
+ export interface PreviewResult {
21
+ lines: string[];
22
+ nextRefreshInMs?: number;
23
+ }
24
+ /** The shared menu adds no margin; the preview indents every line by one space itself. */
25
+ function indented(result: PreviewResult): PreviewResult {
26
+ return { ...result, lines: result.lines.map(line => ` ${line}`) };
27
+ }
15
28
  const PROMPT_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "User Prompt").map(entry => entry.id));
16
29
  const MARKER_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "Completion Marker").map(entry => entry.id));
17
30
  function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
18
31
 
19
32
  /** Stateful, per-menu preview renderer that follows the active settings section. */
20
- export class PreviewRenderer {
33
+ export class PreviewRenderer implements PreviewSlot<Values> {
21
34
  // Preserve constructor-time randomness so rendering never changes the preview phrase.
22
35
  readonly #poolFraction = Math.random();
23
36
  readonly #packs: readonly WordPack[];
@@ -30,7 +43,11 @@ export class PreviewRenderer {
30
43
  this.#ctx = ctx;
31
44
  this.#packs = packs;
32
45
  }
33
- render(values: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string, width: number = DEFAULT_PREVIEW_WIDTH): PreviewResult {
46
+ render(values: Values, state: PreviewState = { elapsedMs: 0, activeItemId: undefined, width: DEFAULT_PREVIEW_WIDTH }): PreviewResult {
47
+ return indented(this.renderContent(values, state.elapsedMs, state.activeItemId, Math.max(0, state.width - 1)));
48
+ }
49
+ /** The lines before the one-space indent; `width` is the budget those lines may fill. */
50
+ private renderContent(values: Values, elapsedMs: number, activeItemId: string | undefined, width: number): PreviewResult {
34
51
  if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values, width);
35
52
  if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values, width);
36
53
  if (activeItemId === "useNerdFont") return { lines: ["", `Icon preview: ${values.useNerdFont ? "" : "π"}`, ""] };
@@ -60,7 +77,7 @@ export class PreviewRenderer {
60
77
  : "";
61
78
  return { lines: ["", this.loaderPreview(values, elapsedMs), help], nextRefreshInMs };
62
79
  }
63
- private loaderPreview(values: Record<string, boolean | string>, elapsedMs: number): string {
80
+ private loaderPreview(values: Values, elapsedMs: number): string {
64
81
  this.#meter.setDirection(fromCycleDirection(values.meterDirection));
65
82
  if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
66
83
  this.#meter.push(rateToLevel(meterRate(elapsedMs), fromCycleMeterUpperBound(values.meterUpperBoundTps)));
@@ -97,14 +114,14 @@ export class PreviewRenderer {
97
114
  spinnerAtMs: values.animatedSpinner !== false ? elapsedMs : undefined,
98
115
  word,
99
116
  shimmerAtMs: elapsedMs,
100
- renderMeter: (colorizer, dimmed) => this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, theme, colorizer, dimmed)),
117
+ renderMeter: (colorizer, dimmed) => renderMeter(this.#meter, theme, colorizer, dimmed),
101
118
  elapsedMs,
102
119
  outputTokens: Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)),
103
120
  tokenRateText: formatTokenRate(TOKEN_RATE_PER_SEC),
104
121
  responseModel: features.responseModel ? "test-model" : undefined,
105
122
  });
106
123
  }
107
- private packValues(values: Record<string, boolean | string>): Record<string, boolean> {
124
+ private packValues(values: Values): Record<string, boolean> {
108
125
  const enabled: Record<string, boolean> = {};
109
126
  for (const pack of this.#packs) {
110
127
  const value = values[`${WORD_PACK_MENU_PREFIX}${pack.id}`];
@@ -112,14 +129,14 @@ export class PreviewRenderer {
112
129
  }
113
130
  return enabled;
114
131
  }
115
- private packPreview(id: string, values: Record<string, boolean | string>): PreviewResult {
132
+ private packPreview(id: string, values: Values): PreviewResult {
116
133
  const pack = this.#packs.find((candidate) => candidate.id === id);
117
134
  if (!pack) return { lines: ["This word pack is unavailable."] };
118
135
  const enabled = isWordPackEnabled(id, this.packValues(values));
119
136
  const source = pack.bundled ? "Shipped example" : `Custom: ${wordPacksPath()}`;
120
137
  return { lines: [`${pack.name}: ${enabled ? "enabled" : "disabled"}`, `${pack.words.length} phrases · ${source}`, ...(pack.description ? [pack.description] : [])] };
121
138
  }
122
- private promptPreview(values: Record<string, boolean | string>, width: number): PreviewResult {
139
+ private promptPreview(values: Values, width: number): PreviewResult {
123
140
  if (values.decorateUserPrompt !== true) return { lines: ["User prompt decoration is disabled."] };
124
141
 
125
142
  const borderColor = values.borderColor;
@@ -141,7 +158,7 @@ export class PreviewRenderer {
141
158
  ? { lines }
142
159
  : { lines, nextRefreshInMs: 1_000 - (timestamp % 1_000) };
143
160
  }
144
- private markerPreview(values: Record<string, boolean | string>, width: number): PreviewResult {
161
+ private markerPreview(values: Values, width: number): PreviewResult {
145
162
  if (values.doneMarker !== true) return { lines: ["Completion marker is disabled."] };
146
163
  const theme = this.#ctx.ui.theme;
147
164
  const icon = values.doneMarkerIcon === true ? theme.fg("text", values.useNerdFont === true ? "" : "π") : "";
@@ -153,7 +170,7 @@ export class PreviewRenderer {
153
170
  let model = "";
154
171
  if (values.doneMarkerModel === true) {
155
172
  const color = isThinkingLevelColor(values.doneMarkerModelColor) ? values.doneMarkerModelColor : DEFAULT_SETTINGS.decorations.doneMarkerModelColor;
156
- const modelColored = getThinkingLevelColorizer(theme, color, this.#ctx.thinkingLevel)("test-model");
173
+ const modelColored = thinkingLevelColorizer(theme, color, this.#ctx.thinkingLevel)("test-model");
157
174
  model = values.doneMarkerModelDimmed === true ? dimAttribute(modelColored) : modelColored;
158
175
  }
159
176
  const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details, model);
@@ -1,17 +1,16 @@
1
- import type { MessageRenderer, ThemeColor } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import { getThinkingLevelColorizer, isThinkingLevel, type ThinkingLevel } from "./format.ts";
1
+ import type { EntryRenderer, MessageRenderer, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
3
+ import { thinkingLevelColorizer } from "@underactive/pi-topping-primitives/activity-tui";
4
+ import { BOX_STYLES, type BoxGlyphs } from "@underactive/pi-topping-primitives/glyphs";
5
+ import { isThinkingLevel, type ThinkingLevel } from "./format.ts";
4
6
  import { DEFAULT_SETTINGS, isBorderStyle, isPromptBorderColor, type BorderStyle, type DoneMarkerBorderColor, type DoneMarkerBorderStyle, type DoneMarkerStyle, type PromptBorderColor } from "./settings.ts";
5
7
  import { stripControlChars } from "./util.ts";
6
8
 
7
9
  export const PROMPT_BOX_TYPE = "pi-topping-prompt";
10
+ export const PROMPT_BOX_ENTRY_TYPE = "pi-topping-prompt-entry";
8
11
 
9
- const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; br: string; h: string; v: string }> = {
10
- double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" },
11
- single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
12
- rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
13
- heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
14
- };
12
+ // Typed against this repo's BorderStyle, so a style added to settings.ts without a glyph set fails the typecheck.
13
+ const BORDER_GLYPHS: Readonly<Record<BorderStyle, BoxGlyphs>> = BOX_STYLES;
15
14
 
16
15
  /**
17
16
  * False visibility options hide each corresponding part; present provider and
@@ -34,6 +33,11 @@ export interface PromptBoxDetails {
34
33
  thinkingLevel?: ThinkingLevel;
35
34
  }
36
35
 
36
+ export interface PromptBoxEntryData {
37
+ content: string;
38
+ details: PromptBoxDetails;
39
+ }
40
+
37
41
  type PromptTheme = {
38
42
  fg(color: string, text: string): string;
39
43
  getThinkingBorderColor(level: ThinkingLevel): (text: string) => string;
@@ -73,12 +77,12 @@ export function buildCompletionMarkerLine(
73
77
  if (borderStyle === "none") return truncateToWidth(content, safeWidth);
74
78
 
75
79
  const g = BORDER_GLYPHS[borderStyle];
76
- const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
77
- const prefix = `${g.bl}${g.h.repeat(2)} `;
80
+ const border = thinkingLevelColorizer(theme, borderColor, thinkingLevel);
81
+ const prefix = `${g.bottomLeft}${g.horizontal.repeat(2)} `;
78
82
 
79
83
  const suffix = markerStyle === "bookend"
80
- ? ` ${g.h.repeat(Math.max(0, safeWidth - visibleWidth(prefix) - visibleWidth(content) - 2))}${g.br}`
81
- : ` ${COMPLETION_MARKER_TRAIL_RUNS.map(length => g.h.repeat(length)).join(" ")}`;
84
+ ? ` ${g.horizontal.repeat(Math.max(0, safeWidth - visibleWidth(prefix) - visibleWidth(content) - 2))}${g.bottomRight}`
85
+ : ` ${COMPLETION_MARKER_TRAIL_RUNS.map(length => g.horizontal.repeat(length)).join(" ")}`;
82
86
  const line = `${border(prefix)}${content}${border(suffix)}`;
83
87
  return visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth) : line;
84
88
  }
@@ -97,7 +101,7 @@ export function buildPromptBoxLines(
97
101
  const g = BORDER_GLYPHS[borderStyle];
98
102
  const borderColor = isPromptBorderColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
99
103
  const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
100
- const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
104
+ const border = thinkingLevelColorizer(theme, borderColor, thinkingLevel);
101
105
  const iconColor = (text: string) => theme.fg("text", text);
102
106
  const muted = (text: string) => theme.fg("dim", text);
103
107
  const bodyText = (text: string) => theme.fg("customMessageText", text);
@@ -125,12 +129,12 @@ export function buildPromptBoxLines(
125
129
  const labelText = combinedLabel
126
130
  ? truncateToWidth(combinedLabel, Math.max(0, innerWidth - 3)).replace(/\x1b\[0m/g, "")
127
131
  : "";
128
- const titleLength = icon ? visibleWidth(`${g.h}${g.h} ${icon} `) : 0;
132
+ const titleLength = icon ? visibleWidth(`${g.horizontal}${g.horizontal} ${icon} `) : 0;
129
133
  const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
130
134
  const topFill = Math.max(0, innerWidth - titleLength - timeLength);
131
- const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${iconColor(icon)}${border(" ")}` : "";
132
- const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : "";
133
- const topLine = border(g.tl) + iconSeg + border(g.h.repeat(topFill)) + timeSeg + border(g.tr);
135
+ const iconSeg = icon ? `${border(`${g.horizontal}${g.horizontal} `)}${iconColor(icon)}${border(" ")}` : "";
136
+ const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.horizontal}`)}` : "";
137
+ const topLine = border(g.topLeft) + iconSeg + border(g.horizontal.repeat(topFill)) + timeSeg + border(g.topRight);
134
138
  const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
135
139
 
136
140
  if (content) {
@@ -138,28 +142,27 @@ export function buildPromptBoxLines(
138
142
  for (const rawLine of wrapTextWithAnsi(content, textWidth)) {
139
143
  const displayLine = rawLine || " ";
140
144
  const padded = `${displayLine}${" ".repeat(Math.max(0, textWidth - visibleWidth(displayLine)))}`;
141
- const line = `${border(g.v)}${bodyText(` ${padded} `)}${border(g.v)}`;
145
+ const line = `${border(g.vertical)}${bodyText(` ${padded} `)}${border(g.vertical)}`;
142
146
  lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
143
147
  }
144
148
  }
145
149
 
146
150
  const labelLength = labelText ? visibleWidth(labelText) + 3 : 0; // label + right-side " ═"
147
- const labelSeg = labelText ? `${border(" ")}${muted(labelText)}${border(` ${g.h}`)}` : "";
148
- const bottomLine = `${border(g.bl)}${border(g.h.repeat(Math.max(0, innerWidth - labelLength)))}${labelSeg}${border(g.br)}`;
151
+ const labelSeg = labelText ? `${border(" ")}${muted(labelText)}${border(` ${g.horizontal}`)}` : "";
152
+ const bottomLine = `${border(g.bottomLeft)}${border(g.horizontal.repeat(Math.max(0, innerWidth - labelLength)))}${labelSeg}${border(g.bottomRight)}`;
149
153
  lines.push(visibleWidth(bottomLine) > width ? truncateToWidth(bottomLine, width) : bottomLine);
150
154
  return lines;
151
155
  }
152
156
 
153
- export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
154
- const content = typeof message.content === "string" ? message.content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "") : "";
155
- const details = (message.details ?? {}) as PromptBoxDetails;
157
+ function createPromptBoxComponent(content: string, details: PromptBoxDetails, theme: Theme): Component {
158
+ const safeContent = content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "");
156
159
  let cachedWidth: number | undefined;
157
160
  let cachedLines: string[] | undefined;
158
161
  return {
159
162
  render(width: number): string[] {
160
163
  if (cachedWidth !== width) {
161
164
  cachedWidth = width;
162
- cachedLines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
165
+ cachedLines = buildPromptBoxLines(safeContent, details.submittedAt, width, theme, details);
163
166
  }
164
167
  return cachedLines!;
165
168
  },
@@ -168,4 +171,16 @@ export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _o
168
171
  cachedLines = undefined;
169
172
  },
170
173
  };
174
+ }
175
+
176
+ export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
177
+ const content = typeof message.content === "string" ? message.content : "";
178
+ const details = (message.details ?? {}) as PromptBoxDetails;
179
+ return createPromptBoxComponent(content, details, theme);
180
+ };
181
+
182
+ export const promptBoxEntryRenderer: EntryRenderer<PromptBoxEntryData> = (entry, _options, theme) => {
183
+ const data = entry.data;
184
+ if (!data || typeof data.content !== "string") return undefined;
185
+ return createPromptBoxComponent(data.content, data.details ?? {}, theme);
171
186
  };