@underactive/pi-topping 0.4.0 → 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/preview.ts CHANGED
@@ -1,74 +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_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
5
- import { buildPromptBoxLines } from "./prompt-decorator.ts";
6
- import { LOADER_ORDER_ID, parseLoaderOrder } from "./settings.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";
7
7
  import { pickRandomWord } from "./words.ts";
8
- const METER_INTERVAL_MS = 100, METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
9
- const DEFAULT_WORKING_WORD = "Working…";
10
- const PROMPT_IDS = new Set(["decorateUserPrompt", "borderColor", "borderStyle", "promptIcon", "promptTimestamp"]);
11
- const MARKER_IDS = new Set(["doneMarker", "doneMarkerIcon", "randomizeDoneMarker", "doneMarkerTokens", "doneMarkerInputs"]);
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));
12
13
  function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
13
14
 
14
15
  /** Stateful, per-menu preview renderer that follows the active settings section. */
15
16
  export class PreviewRenderer {
16
- readonly #word = pickRandomWord(); readonly #meter = new ActivityMeter(); #lastMeterUpdate = 0;
17
+ readonly #word = pickRandomWord();
18
+ readonly #meter = new ActivityMeter();
19
+ #lastMeterUpdate = 0;
17
20
  readonly #ctx: ExtensionContext;
18
21
  constructor(ctx: ExtensionContext) { this.#ctx = ctx; }
19
22
  render(values: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string): PreviewResult {
20
23
  if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values);
21
24
  if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values);
22
25
  if (activeItemId === "useNerdFont") return { lines: [`Icon preview: ${values.useNerdFont ? "" : "π"}`] };
23
- 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 };
24
37
  }
25
38
  private loaderPreview(values: Record<string, boolean | string>, elapsedMs: number): string {
26
- this.#meter.setDirection(values.meterDirection === "Right to Left" ? "rtl" : "ltr");
27
- if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) { this.#meter.push(rateToLevel(meterRate(elapsedMs))); this.#lastMeterUpdate = elapsedMs; }
28
- const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false };
29
- const decorations = { shimmer: values.shimmer !== false, tokenActivityMonitor: values.tokenActivityMonitor !== false };
30
- const spinnerColor = (values.spinnerColorEnabled === false ? "accent" : values.spinnerColor === "border" || values.spinnerColor === "borderAccent" ? values.spinnerColor : "accent") as "accent" | "border" | "borderAccent";
31
- const spinner = values.animatedSpinner ? this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!) : "";
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
+ }
32
54
  const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
33
- if (isFullyDefaultAppearance(features, decorations)) return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, order);
34
- const word = features.substituteDefaultMessage ? this.#word : DEFAULT_WORKING_WORD;
35
- const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme, values.shimmerDirection === "Right to Left" ? "rtl" : "ltr", values.shimmerSpeed === "Slow" ? "slow" : values.shimmerSpeed === "Fast" ? "fast" : "normal") : this.#ctx.ui.theme.fg("text", word);
36
- const meterColor = (values.meterColorEnabled === false ? "accent" : values.meterColor === "border" || values.meterColor === "borderAccent" ? values.meterColor : "accent") as "accent" | "border" | "borderAccent";
37
- const meter = decorations.tokenActivityMonitor ? this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColor, values.meterDimmed !== false)) : "";
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
+ }
38
100
  return buildWorkingMessage(this.#ctx.ui.theme, {
39
101
  spinner,
40
102
  text: styledWord,
41
103
  meter,
42
- elapsed: features.elapsedTime ? formatElapsed(elapsedMs) : "",
43
- tokens: features.outputTokens ? `↓ ${formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)))} tokens` : "",
104
+ elapsed,
105
+ tokens,
106
+ tokenRate,
44
107
  }, order);
45
108
  }
46
109
  private promptPreview(values: Record<string, boolean | string>): PreviewResult {
47
- if (!values.decorateUserPrompt) return { lines: ["User prompt decoration is disabled."] };
110
+ if (values.decorateUserPrompt !== true) return { lines: ["User prompt decoration is disabled."] };
48
111
 
49
112
  const borderColor = values.borderColor;
50
113
  const borderStyle = values.borderStyle;
51
- const timestamp = values.promptTimestamp ? Date.now() : undefined;
52
- const lines = buildPromptBoxLines("ping", timestamp, 70, this.#ctx.ui.theme, {
53
- showIcon: values.promptIcon as boolean,
54
- showTimestamp: values.promptTimestamp as boolean,
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,
55
120
  icon: values.useNerdFont ? "" : "π",
56
- borderColor: borderColor === "accent" || borderColor === "border" || borderColor === "borderAccent" ? borderColor : "accent",
57
- borderStyle: borderStyle === "double" || borderStyle === "single" || borderStyle === "rounded" || borderStyle === "heavy" ? borderStyle : "double",
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",
58
125
  });
59
126
  return timestamp === undefined
60
127
  ? { lines }
61
128
  : { lines, nextRefreshInMs: 1_000 - (timestamp % 1_000) };
62
129
  }
63
130
  private markerPreview(values: Record<string, boolean | string>): PreviewResult {
64
- if (!values.doneMarker) return { lines: ["Completion marker is disabled."] };
65
- const th = this.#ctx.ui.theme;
66
- const icon = values.doneMarkerIcon ? `${values.useNerdFont ? "" : "π"} ` : "";
67
- const word = (values.randomizeDoneMarker ? "Concocted" : "Worked") as string;
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";
68
135
  const details: string[] = [];
69
- if (values.doneMarkerTokens) details.push("↓ 949 tokens");
70
- if (values.doneMarkerInputs) details.push("2 mid-turn inputs");
71
- const tail = details.length ? ` (${details.join(" · ")})` : "";
72
- return { lines: ["", `${values.doneMarkerIcon ? th.fg("text", icon) : ""}${th.fg("dim", `${word} for 2m 52s${tail}`)}`, ""] };
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, ""] };
73
143
  }
74
144
  }
@@ -1,10 +1,9 @@
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
 
6
- export type BorderStyle = "double" | "single" | "rounded" | "heavy";
7
-
8
7
  const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; br: string; h: string; v: string }> = {
9
8
  double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" },
10
9
  single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
@@ -12,17 +11,63 @@ const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; b
12
11
  heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
13
12
  };
14
13
 
14
+ /**
15
+ * False visibility options hide each corresponding part; present provider and
16
+ * model values join with `/` in the bottom border.
17
+ */
15
18
  export interface PromptBoxDetails {
16
19
  submittedAt?: number;
17
20
  showIcon?: boolean;
18
21
  showTimestamp?: boolean;
22
+ showProvider?: boolean;
23
+ showModel?: boolean;
19
24
  icon?: string;
20
- borderColor?: "accent" | "border" | "borderAccent";
25
+ provider?: string;
26
+ model?: string;
27
+ borderColor?: ThemeColor;
21
28
  borderStyle?: BorderStyle;
22
29
  }
23
30
 
24
31
  type PromptTheme = { fg(color: string, text: string): string };
25
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
+
26
71
  /** Build the rendered lines for a decorated user prompt. */
27
72
  export function buildPromptBoxLines(
28
73
  content: string,
@@ -33,8 +78,10 @@ export function buildPromptBoxLines(
33
78
  ): string[] {
34
79
  if (width < 10) return [];
35
80
 
36
- const g = BORDER_GLYPHS[options.borderStyle ?? "double"];
37
- 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);
38
85
  const label = (text: string) => theme.fg("customMessageLabel", text);
39
86
  const muted = (text: string) => theme.fg("dim", text);
40
87
  const bodyText = (text: string) => theme.fg("customMessageText", text);
@@ -47,12 +94,27 @@ export function buildPromptBoxLines(
47
94
  second: "2-digit",
48
95
  });
49
96
  const innerWidth = width - 2;
50
- const icon = options.showIcon === false ? "" : (options.icon ?? "");
51
- const title = icon ? `══ ${icon} ` : "";
52
- 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;
53
113
  const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
54
114
  const topFill = Math.max(0, innerWidth - titleLength - timeLength);
55
- const topLine = `${border(g.tl)}${icon ? `${border(`${g.h}${g.h} `)}${label(icon)}${border(" ")}` : ""}${border(g.h.repeat(topFill))}${time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : ""}${border(g.tr)}`;
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);
56
118
  const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
57
119
 
58
120
  if (content) {
@@ -65,13 +127,15 @@ export function buildPromptBoxLines(
65
127
  }
66
128
  }
67
129
 
68
- const bottomLine = `${border(g.bl)}${border(g.h.repeat(innerWidth))}${border(g.br)}`;
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)}`;
69
133
  lines.push(visibleWidth(bottomLine) > width ? truncateToWidth(bottomLine, width) : bottomLine);
70
134
  return lines;
71
135
  }
72
136
 
73
137
  export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
74
- 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, "") : "";
75
139
  const details = (message.details ?? {}) as PromptBoxDetails;
76
140
  const linesByWidth = new Map<number, string[]>();
77
141
  return {