@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/README.md +45 -18
- package/package.json +1 -1
- package/src/activity-meter.ts +11 -8
- package/src/format.ts +110 -41
- package/src/menu.ts +15 -17
- package/src/preview.ts +107 -37
- package/src/prompt-decorator.ts +76 -12
- package/src/session.ts +150 -61
- package/src/settings.ts +104 -38
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
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
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));
|
|
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();
|
|
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
|
-
|
|
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
|
|
27
|
-
if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
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))
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
43
|
-
tokens
|
|
104
|
+
elapsed,
|
|
105
|
+
tokens,
|
|
106
|
+
tokenRate,
|
|
44
107
|
}, order);
|
|
45
108
|
}
|
|
46
109
|
private promptPreview(values: Record<string, boolean | string>): PreviewResult {
|
|
47
|
-
if (
|
|
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,
|
|
53
|
-
showIcon: values.promptIcon
|
|
54
|
-
showTimestamp: values.promptTimestamp
|
|
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
|
-
|
|
57
|
-
|
|
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 (
|
|
65
|
-
const
|
|
66
|
-
const icon = values.doneMarkerIcon ?
|
|
67
|
-
const word =
|
|
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
|
|
72
|
-
|
|
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
|
}
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
37
|
-
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);
|
|
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
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
|
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
|
|
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 {
|