@underactive/pi-topping 0.2.1 → 0.3.1
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 +44 -38
- package/package.json +2 -2
- package/src/activity-meter.ts +9 -2
- package/src/format.ts +6 -1
- package/src/menu.ts +218 -76
- package/src/preview.ts +50 -36
- package/src/prompt-decorator.ts +77 -0
- package/src/session.ts +356 -26
- package/src/settings.ts +118 -159
package/src/settings.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* settings.ts
|
|
3
|
-
*
|
|
4
|
-
* Persistent, per-user settings for pi-topping, stored at
|
|
5
|
-
* `~/.pi/agent/pi-topping/settings.json`. All seven toggles
|
|
6
|
-
* default to enabled so behavior is unchanged for users who never open
|
|
7
|
-
* `/topping-settings`.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
3
|
import { dirname, join } from "node:path";
|
|
@@ -16,200 +7,168 @@ export interface DecoratorSettings {
|
|
|
16
7
|
decorations: {
|
|
17
8
|
animatedSpinner: boolean;
|
|
18
9
|
shimmer: boolean;
|
|
10
|
+
shimmerDirection: "ltr" | "rtl";
|
|
11
|
+
shimmerDirectionEnabled: boolean;
|
|
19
12
|
tokenActivityMonitor: boolean;
|
|
20
13
|
meterDirection: "ltr" | "rtl";
|
|
14
|
+
meterDirectionEnabled: boolean;
|
|
15
|
+
decorateUserPrompt: boolean;
|
|
16
|
+
borderColor: "accent" | "border" | "borderAccent";
|
|
17
|
+
borderColorEnabled: boolean;
|
|
18
|
+
spinnerColor: "accent" | "border" | "borderAccent";
|
|
19
|
+
spinnerColorEnabled: boolean;
|
|
20
|
+
meterColor: "accent" | "border" | "borderAccent";
|
|
21
|
+
meterColorEnabled: boolean;
|
|
22
|
+
meterDimmed: boolean;
|
|
23
|
+
promptIcon: boolean;
|
|
24
|
+
promptTimestamp: boolean;
|
|
25
|
+
useNerdFont: boolean;
|
|
21
26
|
};
|
|
22
27
|
features: {
|
|
23
28
|
substituteDefaultMessage: boolean;
|
|
24
29
|
elapsedTime: boolean;
|
|
25
30
|
outputTokens: boolean;
|
|
26
31
|
doneMarker: boolean;
|
|
32
|
+
doneMarkerIcon: boolean;
|
|
33
|
+
randomizeDoneMarker: boolean;
|
|
34
|
+
doneMarkerTokens: boolean;
|
|
27
35
|
};
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
31
|
-
decorations: {
|
|
32
|
-
|
|
33
|
-
shimmer: true,
|
|
34
|
-
tokenActivityMonitor: true,
|
|
35
|
-
meterDirection: "ltr",
|
|
36
|
-
},
|
|
37
|
-
features: {
|
|
38
|
-
substituteDefaultMessage: true,
|
|
39
|
-
elapsedTime: true,
|
|
40
|
-
outputTokens: true,
|
|
41
|
-
doneMarker: true,
|
|
42
|
-
},
|
|
39
|
+
decorations: { animatedSpinner: true, shimmer: true, shimmerDirection: "ltr", shimmerDirectionEnabled: true, tokenActivityMonitor: true, meterDirection: "ltr", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "accent", borderColorEnabled: true, spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, promptIcon: true, promptTimestamp: true, useNerdFont: true },
|
|
40
|
+
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true },
|
|
43
41
|
};
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* True for plain `{}`-style objects only -- excludes `null`, arrays, and
|
|
52
|
-
* primitives. Used to guard against malformed/hand-edited settings.json
|
|
53
|
-
* content (e.g. `"decorations": "oops"` or `"decorations": [1,2,3]`) before
|
|
54
|
-
* spreading it onto the defaults, so a wrong-shaped field can't leak stray
|
|
55
|
-
* properties (like numeric-string keys from spreading a string) into the
|
|
56
|
-
* merged settings object.
|
|
57
|
-
*/
|
|
58
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
59
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Merge persisted JSON values onto a defaults object, accepting both boolean
|
|
64
|
-
* fields and a known enum-typed string field (`meterDirection`). Any unknown
|
|
65
|
-
* or wrong-typed keys are silently ignored so hand-edited settings.json can't
|
|
66
|
-
* corrupt the resulting shape.
|
|
67
|
-
*/
|
|
68
|
-
function mergeDecorations(
|
|
69
|
-
defaults: DecoratorSettings["decorations"],
|
|
70
|
-
parsed: unknown,
|
|
71
|
-
): DecoratorSettings["decorations"] {
|
|
43
|
+
export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
|
|
44
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
45
|
+
function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, parsed: unknown): T {
|
|
72
46
|
const merged = { ...defaults };
|
|
73
47
|
if (!isPlainObject(parsed)) return merged;
|
|
74
48
|
for (const [key, value] of Object.entries(parsed)) {
|
|
75
|
-
if (key
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
} else if (typeof value === "boolean" && key in merged) {
|
|
80
|
-
(merged as Record<string, unknown>)[key] = value;
|
|
81
|
-
}
|
|
49
|
+
if (!(key in merged)) continue;
|
|
50
|
+
if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string>)[key] = value;
|
|
51
|
+
if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) (merged as Record<string, boolean | string>)[key] = value;
|
|
52
|
+
if (key.endsWith("Color") && (value === "accent" || value === "border" || value === "borderAccent")) (merged as Record<string, boolean | string>)[key] = value;
|
|
82
53
|
}
|
|
83
54
|
return merged;
|
|
84
55
|
}
|
|
85
56
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
merged[key as keyof T] = value as T[keyof T];
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return merged;
|
|
57
|
+
/** Map a persisted setting value to its menu cycle label (directions only; colors pass through). */
|
|
58
|
+
function toCycleValue(stored: unknown): string {
|
|
59
|
+
if (stored === "ltr") return "Left to Right";
|
|
60
|
+
if (stored === "rtl") return "Right to Left";
|
|
61
|
+
return String(stored);
|
|
95
62
|
}
|
|
96
63
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
64
|
+
/** Map a menu cycle label back to its persisted value. */
|
|
65
|
+
function fromCycleValue(value: string): string {
|
|
66
|
+
if (value === "Left to Right") return "ltr";
|
|
67
|
+
if (value === "Right to Left") return "rtl";
|
|
68
|
+
return value;
|
|
102
69
|
}
|
|
103
70
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
71
|
+
type MenuSectionName = "User Prompt" | "Working Loader Text" | "Completion Marker" | "Options";
|
|
72
|
+
type DecorationSettings = DecoratorSettings["decorations"];
|
|
73
|
+
type FeatureSettings = DecoratorSettings["features"];
|
|
74
|
+
type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
|
|
75
|
+
type MenuEntryBase = { id: string; label: string; section: MenuSectionName };
|
|
76
|
+
type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string };
|
|
77
|
+
type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleEnabledBy?: never; cycleDisabledValue?: never };
|
|
78
|
+
type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
|
|
79
|
+
export const MENU_ENTRIES: readonly MenuEntry[] = [
|
|
80
|
+
{ id: "decorateUserPrompt", label: "High-vis prompt", section: "User Prompt", group: "decorations", key: "decorateUserPrompt" },
|
|
81
|
+
{ id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "border" },
|
|
82
|
+
{ id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" },
|
|
83
|
+
{ id: "promptTimestamp", label: "Timestamp", section: "User Prompt", group: "decorations", key: "promptTimestamp" },
|
|
84
|
+
{ id: "animatedSpinner", label: "Animated spinner", section: "Working Loader Text", group: "decorations", key: "animatedSpinner" },
|
|
85
|
+
{ id: "spinnerColor", label: "Animated spinner color", section: "Working Loader Text", group: "decorations", key: "spinnerColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
|
|
86
|
+
{ id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "Working Loader Text", group: "features", key: "substituteDefaultMessage" },
|
|
87
|
+
{ id: "shimmer", label: "Text shimmer", section: "Working Loader Text", group: "decorations", key: "shimmer" },
|
|
88
|
+
{ id: "shimmerDirection", label: "Text shimmer direction", section: "Working Loader Text", group: "decorations", key: "shimmerDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "Left to Right" },
|
|
89
|
+
{ id: "tokenActivityMonitor", label: "Token activity monitor", section: "Working Loader Text", group: "decorations", key: "tokenActivityMonitor" },
|
|
90
|
+
{ id: "meterColor", label: "Token activity monitor color", section: "Working Loader Text", group: "decorations", key: "meterColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
|
|
91
|
+
{ id: "meterDirection", label: "Token activity monitor direction", section: "Working Loader Text", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "Left to Right" },
|
|
92
|
+
{ id: "meterDimmed", label: "Token activity monitor dimmed", section: "Working Loader Text", group: "decorations", key: "meterDimmed" },
|
|
93
|
+
{ id: "elapsedTime", label: "Elapsed time since prompt", section: "Working Loader Text", group: "features", key: "elapsedTime" },
|
|
94
|
+
{ id: "outputTokens", label: "Show output tokens", section: "Working Loader Text", group: "features", key: "outputTokens" },
|
|
95
|
+
{ id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
|
|
96
|
+
{ id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
|
|
97
|
+
{ id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
|
|
98
|
+
{ id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
|
|
99
|
+
{ id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
|
|
100
|
+
];
|
|
110
101
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
102
|
+
function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuSection["items"][number] {
|
|
103
|
+
const value = entry.group === "decorations" ? settings.decorations[entry.key] : settings.features[entry.key];
|
|
104
|
+
const cycleEnabled = entry.group === "decorations" && entry.cycleEnabledBy
|
|
105
|
+
? settings.decorations[entry.cycleEnabledBy]
|
|
106
|
+
: undefined;
|
|
107
|
+
return { id: entry.id, label: entry.label, cycleValues: entry.cycleValues, cycleEnabledBy: entry.cycleEnabledBy, cycleDisabledValue: entry.cycleDisabledValue, cycleEnabled, value: entry.cycleValues ? toCycleValue(value) : value };
|
|
116
108
|
}
|
|
117
109
|
|
|
118
|
-
|
|
110
|
+
function isDecorationBooleanKey(key: keyof DecorationSettings): key is DecorationBooleanKey {
|
|
111
|
+
return typeof DEFAULT_SETTINGS.decorations[key] === "boolean";
|
|
112
|
+
}
|
|
119
113
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
];
|
|
114
|
+
function setDecorationCycleValue(decorations: DecorationSettings, key: keyof DecorationSettings, value: string): void {
|
|
115
|
+
const stored = fromCycleValue(value);
|
|
116
|
+
switch (key) {
|
|
117
|
+
case "borderColor":
|
|
118
|
+
case "spinnerColor":
|
|
119
|
+
case "meterColor":
|
|
120
|
+
if (stored === "accent" || stored === "border" || stored === "borderAccent") decorations[key] = stored;
|
|
121
|
+
return;
|
|
122
|
+
case "shimmerDirection":
|
|
123
|
+
case "meterDirection":
|
|
124
|
+
if (stored === "ltr" || stored === "rtl") decorations[key] = stored;
|
|
125
|
+
return;
|
|
126
|
+
default:
|
|
127
|
+
// Fail loudly if a MENU_ENTRIES cycle entry is added without a handler here.
|
|
128
|
+
throw new Error(`Unhandled cycle setting: ${String(key)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
131
|
|
|
132
|
-
/** Build the ordered toggle-menu sections from persisted settings. */
|
|
133
132
|
export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
|
|
134
|
-
return ["
|
|
135
|
-
title,
|
|
136
|
-
items: MENU_ENTRIES.filter((entry) => entry.section === title).map((entry) => ({
|
|
137
|
-
id: entry.id,
|
|
138
|
-
label: entry.label,
|
|
139
|
-
value:
|
|
140
|
-
entry.id === "meterDirection_rtl"
|
|
141
|
-
? settings.decorations.meterDirection === "rtl"
|
|
142
|
-
: entry.group === "decorations"
|
|
143
|
-
? settings.decorations[entry.id]
|
|
144
|
-
: settings.features[entry.id],
|
|
145
|
-
})),
|
|
146
|
-
}));
|
|
133
|
+
return ["User Prompt", "Working Loader Text", "Completion Marker", "Options"].map(title => ({ title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) }));
|
|
147
134
|
}
|
|
148
135
|
|
|
149
|
-
|
|
150
|
-
export function applyMenuResult(
|
|
151
|
-
settings: DecoratorSettings,
|
|
152
|
-
values: Record<string, boolean>,
|
|
153
|
-
): DecoratorSettings {
|
|
136
|
+
export function applyMenuResult(settings: DecoratorSettings, values: Record<string, boolean | string>): DecoratorSettings {
|
|
154
137
|
const next = structuredClone(settings);
|
|
155
138
|
for (const entry of MENU_ENTRIES) {
|
|
156
139
|
const value = values[entry.id];
|
|
157
140
|
if (value === undefined) continue;
|
|
158
|
-
if (entry.
|
|
159
|
-
next.decorations.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
141
|
+
if (entry.group === "decorations" && entry.cycleValues && typeof value === "string" && entry.cycleValues.includes(value)) {
|
|
142
|
+
setDecorationCycleValue(next.decorations, entry.key, value);
|
|
143
|
+
if (entry.cycleEnabledBy) next.decorations[entry.cycleEnabledBy] = values[entry.cycleEnabledBy] !== false;
|
|
144
|
+
} else if (entry.group === "decorations" && typeof value === "boolean" && isDecorationBooleanKey(entry.key)) {
|
|
145
|
+
next.decorations[entry.key] = value;
|
|
146
|
+
} else if (entry.group === "features" && typeof value === "boolean") {
|
|
147
|
+
next.features[entry.key] = value;
|
|
164
148
|
}
|
|
165
149
|
}
|
|
166
150
|
return next;
|
|
167
151
|
}
|
|
168
152
|
|
|
169
|
-
function cloneDefaults(): DecoratorSettings {
|
|
170
|
-
return structuredClone(DEFAULT_SETTINGS);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Load settings from disk, deep-merging any persisted values onto the
|
|
175
|
-
* defaults. Returns a fresh clone of `DEFAULT_SETTINGS` if the file is
|
|
176
|
-
* missing or contains malformed JSON -- this function never throws.
|
|
177
|
-
*/
|
|
178
153
|
export function loadSettings(): DecoratorSettings {
|
|
179
|
-
const defaults = cloneDefaults();
|
|
180
|
-
let raw: string;
|
|
181
|
-
try {
|
|
182
|
-
raw = readFileSync(settingsPath(), "utf8");
|
|
183
|
-
} catch {
|
|
184
|
-
return defaults;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
154
|
try {
|
|
188
|
-
const parsed = JSON.parse(
|
|
189
|
-
if (!isPlainObject(parsed)) return
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
155
|
+
const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
|
|
156
|
+
if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
|
|
157
|
+
const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features) };
|
|
158
|
+
const gated: readonly (readonly ["borderColor" | "spinnerColor" | "meterColor" | "shimmerDirection" | "meterDirection", "borderColorEnabled" | "spinnerColorEnabled" | "meterColorEnabled" | "shimmerDirectionEnabled" | "meterDirectionEnabled", string])[] = [
|
|
159
|
+
["borderColor", "borderColorEnabled", "border"],
|
|
160
|
+
["spinnerColor", "spinnerColorEnabled", "accent"],
|
|
161
|
+
["meterColor", "meterColorEnabled", "accent"],
|
|
162
|
+
["shimmerDirection", "shimmerDirectionEnabled", "ltr"],
|
|
163
|
+
["meterDirection", "meterDirectionEnabled", "ltr"],
|
|
164
|
+
];
|
|
165
|
+
for (const [key, enabledKey, resetValue] of gated) {
|
|
166
|
+
if (!settings.decorations[enabledKey]) (settings.decorations as unknown as Record<string, string>)[key] = resetValue;
|
|
167
|
+
}
|
|
168
|
+
return settings;
|
|
169
|
+
} catch { return structuredClone(DEFAULT_SETTINGS); }
|
|
197
170
|
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Persist settings to disk, creating the parent directory if needed.
|
|
201
|
-
* Writes to a temp file and renames it into place so a crash mid-write
|
|
202
|
-
* (kill -9, OOM, disk full) can never leave `settings.json` truncated or
|
|
203
|
-
* corrupt -- `renameSync` is atomic on POSIX filesystems when source and
|
|
204
|
-
* destination are on the same directory/filesystem, which they always are
|
|
205
|
-
* here. A stale `.tmp` file left behind by a crash is harmless: the next
|
|
206
|
-
* successful `saveSettings()` call overwrites it, and `loadSettings()`
|
|
207
|
-
* never reads it.
|
|
208
|
-
*/
|
|
209
171
|
export function saveSettings(settings: DecoratorSettings): void {
|
|
210
|
-
const path = settingsPath();
|
|
211
|
-
|
|
212
|
-
const tmpPath = `${path}.tmp`;
|
|
213
|
-
writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
214
|
-
renameSync(tmpPath, path);
|
|
172
|
+
const path = settingsPath(); mkdirSync(dirname(path), { recursive: true });
|
|
173
|
+
writeFileSync(`${path}.tmp`, `${JSON.stringify(settings, null, 2)}\n`); renameSync(`${path}.tmp`, path);
|
|
215
174
|
}
|