decorated-pi 0.6.0 → 0.7.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/ui/ask.ts ADDED
@@ -0,0 +1,443 @@
1
+ /**
2
+ * AskComponent — multi-question user input UI.
3
+ *
4
+ * Wizard-style: one question per page, committed on Enter (or Tab).
5
+ * After the last question is committed, shows a summary; Enter submits.
6
+ * Esc at any time cancels.
7
+ *
8
+ * Per-question UX:
9
+ * text — type to fill, Enter/Tab to commit & advance.
10
+ * single — ↑↓/digits to pick, Enter/Tab to commit & advance.
11
+ * First option is pre-selected so Enter alone accepts the default.
12
+ * multi — ↑↓ to move, Space/digits to toggle, Enter/Tab to commit & advance.
13
+ * Starts empty; committing with zero selections is refused.
14
+ *
15
+ * Navigation: Shift+Tab goes back to the previous question without
16
+ * discarding edits to the current one (they stay on disk until you
17
+ * commit them by advancing).
18
+ */
19
+
20
+ import type { Theme } from "@earendil-works/pi-coding-agent";
21
+ import { Container, Spacer, Text, type TUI, type Component, getKeybindings } from "@earendil-works/pi-tui";
22
+
23
+ export type AskQuestionType = "text" | "single" | "multi";
24
+
25
+ export interface AskQuestion {
26
+ id: string;
27
+ type: AskQuestionType;
28
+ question: string;
29
+ options?: string[];
30
+ default?: string;
31
+ /** When true on single/multi, an "Other" row is appended after the
32
+ * regular options. Picking it switches the row into an inline text
33
+ * input — lets the user type a custom answer not in the preset list. */
34
+ allowCustom?: boolean;
35
+ }
36
+
37
+ export interface AskAnswer {
38
+ id: string;
39
+ value: string | string[];
40
+ }
41
+
42
+ interface QuestionState {
43
+ value: string | string[];
44
+ cursor: number; // option cursor; for allowCustom, opts.length = "Other" row
45
+ /** Typed text when the cursor sits on the "Other" row (single or multi). */
46
+ customText: string;
47
+ /** Multi only: whether "Other" is currently toggled into the selection. */
48
+ customSelected: boolean;
49
+ }
50
+
51
+ function parseDefault(type: AskQuestionType, options: string[] | undefined, defaultValue: string | undefined): QuestionState {
52
+ if (type === "text") {
53
+ return { value: defaultValue ?? "", cursor: 0, customText: "", customSelected: false };
54
+ }
55
+ const opts = options ?? [];
56
+ if (type === "single") {
57
+ const idx = defaultValue ? Math.max(0, opts.indexOf(defaultValue)) : 0;
58
+ return { value: opts[idx] ?? "", cursor: idx, customText: "", customSelected: false };
59
+ }
60
+ // multi: comma-separated default, or empty
61
+ const selected = defaultValue ? defaultValue.split(",").map(s => s.trim()).filter(Boolean) : [];
62
+ return { value: selected, cursor: 0, customText: "", customSelected: false };
63
+ }
64
+
65
+ /** For single/multi with allowCustom, returns the effective option count
66
+ * (regular options plus the "Other" row). */
67
+ function effectiveOptionCount(q: AskQuestion): number {
68
+ return (q.options?.length ?? 0) + (q.allowCustom ? 1 : 0);
69
+ }
70
+
71
+ class DynamicBorder implements Component {
72
+ private colorFn: (str: string) => string;
73
+ constructor(theme: Theme) { this.colorFn = (str: string) => theme.fg("border", str); }
74
+ invalidate() {}
75
+ render(width: number): string[] { return [this.colorFn("─".repeat(Math.max(1, width)))]; }
76
+ }
77
+
78
+ export class AskComponent extends Container {
79
+ private theme: Theme;
80
+ private tui: TUI;
81
+ private questions: AskQuestion[];
82
+ private states: Map<string, QuestionState>;
83
+ private focusedIndex = 0;
84
+ private summaryMode = false;
85
+ private done: (answers?: AskAnswer[]) => void;
86
+
87
+ private titleComponent: Text;
88
+ private linesComponent: Text;
89
+ private hintComponent: Text;
90
+
91
+ constructor(tui: TUI, theme: Theme, questions: AskQuestion[], done: (answers?: AskAnswer[]) => void) {
92
+ super();
93
+ this.tui = tui;
94
+ this.theme = theme;
95
+ this.questions = questions;
96
+ this.done = done;
97
+
98
+ this.states = new Map();
99
+ for (const q of questions) {
100
+ this.states.set(q.id, parseDefault(q.type, q.options, q.default));
101
+ }
102
+
103
+ this.addChild(new DynamicBorder(theme));
104
+ this.addChild(new Spacer(1));
105
+
106
+ this.titleComponent = new Text("", 1, 0);
107
+ this.addChild(this.titleComponent);
108
+ this.addChild(new Spacer(1));
109
+
110
+ this.linesComponent = new Text("", 1, 0);
111
+ this.addChild(this.linesComponent);
112
+ this.addChild(new Spacer(1));
113
+
114
+ this.hintComponent = new Text("", 1, 0);
115
+ this.addChild(this.hintComponent);
116
+
117
+ this.addChild(new Spacer(1));
118
+ this.addChild(new DynamicBorder(theme));
119
+
120
+ this.renderView();
121
+ }
122
+
123
+ private currentQuestion(): AskQuestion {
124
+ return this.questions[this.focusedIndex];
125
+ }
126
+
127
+ private currentState(): QuestionState {
128
+ return this.states.get(this.currentQuestion().id)!;
129
+ }
130
+
131
+ private isCurrentValid(): boolean {
132
+ const q = this.currentQuestion();
133
+ const state = this.currentState();
134
+ if (q.type === "text") return (state.value as string).trim() !== "";
135
+ if (q.type === "single") {
136
+ if (q.allowCustom && state.cursor === (q.options?.length ?? 0)) {
137
+ return state.customText.trim() !== "";
138
+ }
139
+ return (state.value as string) !== "";
140
+ }
141
+ // multi: valid if any regular option is selected, or "Other" is
142
+ // toggled AND has non-empty custom text.
143
+ if (q.allowCustom && state.customSelected) {
144
+ return state.customText.trim() !== "" || (state.value as string[]).length > 0;
145
+ }
146
+ return (state.value as string[]).length > 0;
147
+ }
148
+
149
+ /** The answer that will actually be submitted for this question. Differs
150
+ * from state.value when allowCustom is in play and "Other" is chosen. */
151
+ private committedValue(q: AskQuestion, state: QuestionState): string | string[] {
152
+ if (q.type === "single") {
153
+ if (q.allowCustom && state.cursor === (q.options?.length ?? 0)) {
154
+ return state.customText.trim();
155
+ }
156
+ return state.value as string;
157
+ }
158
+ if (q.type === "multi") {
159
+ const base = state.value as string[];
160
+ if (q.allowCustom && state.customSelected && state.customText.trim() !== "") {
161
+ return [...base, state.customText.trim()];
162
+ }
163
+ return base;
164
+ }
165
+ return state.value;
166
+ }
167
+
168
+ private goToQuestion(index: number): void {
169
+ this.focusedIndex = Math.max(0, Math.min(index, this.questions.length - 1));
170
+ this.summaryMode = false;
171
+ this.renderView();
172
+ }
173
+
174
+ /** Tab forward: wrap last → first so the user can cycle through questions
175
+ * freely without committing. Summary is reached only via Enter. */
176
+ private cycleNext(): void {
177
+ this.goToQuestion((this.focusedIndex + 1) % this.questions.length);
178
+ }
179
+
180
+ /** Shift+Tab backward: wrap first → last. */
181
+ private cyclePrev(): void {
182
+ this.goToQuestion((this.focusedIndex - 1 + this.questions.length) % this.questions.length);
183
+ }
184
+
185
+ private showSummary(): void {
186
+ this.summaryMode = true;
187
+ this.renderView();
188
+ }
189
+
190
+ /** Commit current question. Returns true if accepted (advanced or showed summary). */
191
+ private commitCurrent(): boolean {
192
+ if (!this.isCurrentValid()) return false;
193
+ if (this.focusedIndex >= this.questions.length - 1) {
194
+ this.showSummary();
195
+ } else {
196
+ this.goToQuestion(this.focusedIndex + 1);
197
+ }
198
+ return true;
199
+ }
200
+
201
+ private submit(): void {
202
+ const answers: AskAnswer[] = this.questions.map(q => ({
203
+ id: q.id,
204
+ value: this.committedValue(q, this.states.get(q.id)!),
205
+ }));
206
+ this.done(answers);
207
+ }
208
+
209
+ handleInput(data: string): void {
210
+ const kb = getKeybindings();
211
+
212
+ // ── Summary mode ────────────────────────────────────────────────
213
+ if (this.summaryMode) {
214
+ if (data === "\r" || data === "\n" || kb.matches(data, "tui.select.confirm")) {
215
+ this.submit();
216
+ return;
217
+ }
218
+ if (data === "\x1b" || data === "escape" || kb.matches(data, "tui.select.cancel")) {
219
+ this.done(undefined);
220
+ return;
221
+ }
222
+ if (data === "shift_tab" || data === "\x1b[Z") {
223
+ this.goToQuestion(this.questions.length - 1);
224
+ return;
225
+ }
226
+ return; // swallow everything else in summary
227
+ }
228
+
229
+ // ── Global wizard navigation ────────────────────────────────────
230
+ if (data === "\x1b" || data === "escape" || kb.matches(data, "tui.select.cancel")) {
231
+ this.done(undefined);
232
+ return;
233
+ }
234
+ if (data === "shift_tab" || data === "\x1b[Z") {
235
+ this.cyclePrev();
236
+ return;
237
+ }
238
+ // Tab navigates forward without committing — lets the user peek
239
+ // at later questions or skip back via Shift+Tab to fix earlier ones.
240
+ if (data === "\t" || data === "tab") {
241
+ this.cycleNext();
242
+ return;
243
+ }
244
+ // Enter commits the current question and advances. Refused if
245
+ // invalid (empty text or multi with zero selections).
246
+ if (data === "\r" || data === "\n" || kb.matches(data, "tui.select.confirm")) {
247
+ this.commitCurrent();
248
+ return;
249
+ }
250
+
251
+ // ── Per-question input ──────────────────────────────────────────
252
+ const q = this.currentQuestion();
253
+ const state = this.currentState();
254
+
255
+ if (q.type === "text") {
256
+ if (data === "\x7f" || data === "backspace") {
257
+ state.value = (state.value as string).slice(0, -1);
258
+ } else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
259
+ // Accept any printable character: ASCII, Latin-1, CJK, emoji
260
+ // surrogate pairs. The earlier `>= " " && <= "~"` filter
261
+ // rejected every code point above U+007F (i.e. all Chinese).
262
+ state.value = (state.value as string) + data;
263
+ }
264
+ } else if (q.type === "single" && q.options) {
265
+ const opts = q.options;
266
+ const totalLen = effectiveOptionCount(q);
267
+ const onCustomRow = q.allowCustom === true && state.cursor === opts.length;
268
+
269
+ if (kb.matches(data, "tui.select.up")) {
270
+ const next = (state.cursor - 1 + totalLen) % totalLen;
271
+ state.cursor = next;
272
+ state.value = next < opts.length ? opts[next] : "";
273
+ } else if (kb.matches(data, "tui.select.down")) {
274
+ const next = (state.cursor + 1) % totalLen;
275
+ state.cursor = next;
276
+ state.value = next < opts.length ? opts[next] : "";
277
+ } else if (data >= "1" && data <= "9") {
278
+ const idx = parseInt(data, 10) - 1;
279
+ if (idx < totalLen) {
280
+ state.cursor = idx;
281
+ state.value = idx < opts.length ? opts[idx] : "";
282
+ }
283
+ } else if (onCustomRow) {
284
+ if (data === "\x7f" || data === "backspace") {
285
+ state.customText = state.customText.slice(0, -1);
286
+ } else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
287
+ state.customText += data;
288
+ }
289
+ }
290
+ } else if (q.type === "multi" && q.options) {
291
+ const opts = q.options;
292
+ const totalLen = effectiveOptionCount(q);
293
+ const onCustomRow = q.allowCustom === true && state.cursor === opts.length;
294
+
295
+ if (kb.matches(data, "tui.select.up")) {
296
+ state.cursor = (state.cursor - 1 + totalLen) % totalLen;
297
+ } else if (kb.matches(data, "tui.select.down")) {
298
+ state.cursor = (state.cursor + 1) % totalLen;
299
+ } else if (data === " " || data === "space") {
300
+ if (onCustomRow) {
301
+ state.customSelected = !state.customSelected;
302
+ } else {
303
+ const selected = new Set(state.value as string[]);
304
+ const opt = opts[state.cursor];
305
+ if (selected.has(opt)) selected.delete(opt);
306
+ else selected.add(opt);
307
+ state.value = Array.from(selected);
308
+ }
309
+ } else if (data >= "1" && data <= "9") {
310
+ const idx = parseInt(data, 10) - 1;
311
+ if (idx < totalLen) {
312
+ state.cursor = idx;
313
+ if (idx < opts.length) {
314
+ const selected = new Set(state.value as string[]);
315
+ const opt = opts[idx];
316
+ if (selected.has(opt)) selected.delete(opt);
317
+ else selected.add(opt);
318
+ state.value = Array.from(selected);
319
+ }
320
+ }
321
+ } else if (onCustomRow && data.length > 0 && data.charCodeAt(0) >= 0x20) {
322
+ // Typing on the "Other" row auto-selects it and feeds customText.
323
+ // Backspace never enters this branch (handled below).
324
+ if (data !== "\x7f" && data !== "backspace") {
325
+ state.customSelected = true;
326
+ state.customText += data;
327
+ } else {
328
+ state.customText = state.customText.slice(0, -1);
329
+ }
330
+ }
331
+ }
332
+
333
+ this.renderView();
334
+ }
335
+
336
+ private formatAnswer(q: AskQuestion, state: QuestionState): string {
337
+ const value = this.committedValue(q, state);
338
+ if (q.type === "multi") return (value as string[]).join(", ");
339
+ return value as string;
340
+ }
341
+
342
+ private renderView(): void {
343
+ if (this.summaryMode) {
344
+ this.renderSummary();
345
+ } else {
346
+ this.renderQuestion();
347
+ }
348
+ this.tui.requestRender();
349
+ }
350
+
351
+ private renderQuestion(): void {
352
+ const q = this.currentQuestion();
353
+ const state = this.currentState();
354
+ const title = this.theme.fg("accent", this.theme.bold("Ask")) +
355
+ this.theme.fg("dim", ` (${this.focusedIndex + 1}/${this.questions.length})`);
356
+ this.titleComponent.setText(title);
357
+
358
+ const lines: string[] = [];
359
+ lines.push(q.question);
360
+ lines.push("");
361
+
362
+ if (q.type === "text") {
363
+ const value = state.value as string;
364
+ const cursor = this.theme.fg("accent", "_");
365
+ lines.push(value + cursor);
366
+ } else if (q.type === "single" && q.options) {
367
+ const opts = q.options;
368
+ const totalLen = effectiveOptionCount(q);
369
+ for (let j = 0; j < totalLen; j++) {
370
+ const isCustomRow = q.allowCustom === true && j === opts.length;
371
+ const optLabel = isCustomRow ? "Other" : opts[j];
372
+ const selected = isCustomRow ? false : optLabel === state.value;
373
+ const atCursor = j === state.cursor;
374
+ // "Other" row gets ● when focused (it's the chosen answer in the
375
+ // sense that customText will be submitted); regular rows use the
376
+ // value match.
377
+ const icon = isCustomRow ? (atCursor ? "●" : "○") : (selected ? "●" : "○");
378
+ let line: string;
379
+ if (isCustomRow) {
380
+ const text = state.customText;
381
+ const cmark = atCursor ? "_" : "";
382
+ line = ` ${icon} ${optLabel}: ${text}${cmark}`;
383
+ } else {
384
+ line = ` ${icon} ${optLabel}`;
385
+ }
386
+ lines.push(atCursor ? this.theme.fg("accent", line) : line);
387
+ }
388
+ } else if (q.type === "multi" && q.options) {
389
+ const opts = q.options;
390
+ const totalLen = effectiveOptionCount(q);
391
+ for (let j = 0; j < totalLen; j++) {
392
+ const isCustomRow = q.allowCustom === true && j === opts.length;
393
+ const optLabel = isCustomRow ? "Other" : opts[j];
394
+ const inValue = !isCustomRow && (state.value as string[]).includes(optLabel);
395
+ const customOn = isCustomRow && state.customSelected;
396
+ const atCursor = j === state.cursor;
397
+ const icon = inValue || customOn ? "●" : "○";
398
+ let line: string;
399
+ if (isCustomRow) {
400
+ const text = state.customText;
401
+ const cmark = atCursor ? "_" : "";
402
+ line = ` ${icon} ${optLabel}: ${text}${cmark}`;
403
+ } else {
404
+ line = ` ${icon} ${optLabel}`;
405
+ }
406
+ lines.push(atCursor ? this.theme.fg("accent", line) : line);
407
+ }
408
+ }
409
+
410
+ this.linesComponent.setText(lines.join("\n"));
411
+
412
+ let hint: string;
413
+ if (q.type === "text") {
414
+ hint = "Enter next · Esc cancel";
415
+ } else if (q.type === "single") {
416
+ hint = q.allowCustom
417
+ ? "↑↓ move · Enter next · Esc cancel"
418
+ : "↑↓ move · Enter next · Esc cancel";
419
+ } else {
420
+ hint = q.allowCustom
421
+ ? "↑↓ move · Space toggle · Enter next · Esc cancel"
422
+ : "↑↓ move · Space toggle · Enter next · Esc cancel";
423
+ }
424
+ this.hintComponent.setText(this.theme.fg("dim", " " + hint));
425
+ }
426
+
427
+ private renderSummary(): void {
428
+ this.titleComponent.setText(this.theme.fg("accent", this.theme.bold("Review")));
429
+
430
+ const lines: string[] = [];
431
+ for (let i = 0; i < this.questions.length; i++) {
432
+ const q = this.questions[i];
433
+ const state = this.states.get(q.id)!;
434
+ const num = this.theme.fg("dim", `${i + 1}.`);
435
+ lines.push(`${num} ${q.question}`);
436
+ lines.push(` ${this.formatAnswer(q, state)}`);
437
+ if (i < this.questions.length - 1) lines.push("");
438
+ }
439
+
440
+ this.linesComponent.setText(lines.join("\n"));
441
+ this.hintComponent.setText(this.theme.fg("dim", " Enter submit · Shift+Tab back · Esc cancel"));
442
+ }
443
+ }
@@ -1,32 +1,77 @@
1
1
  /**
2
2
  * Module Settings UI — used by /dp-settings command.
3
- * Lists all modules with on/off toggle.
3
+ *
4
+ * Modules are grouped into three categories. The main view shows one
5
+ * row per category; Enter opens a submenu listing the modules in that
6
+ * category so the user can toggle each one.
4
7
  */
5
8
 
6
- import type { ExtensionAPI, ExtensionContext, Theme as PiTheme } from "@earendil-works/pi-coding-agent";
7
- import { Container, SettingsList, Spacer, Text, type TUI, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
9
+ import type { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
10
+ import { Container, SettingsList, type TUI, type SettingsListTheme, type SettingItem, type Component } from "@earendil-works/pi-tui";
8
11
  import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "../settings.js";
9
12
 
10
- const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
11
- patch: "patch",
12
- safety: "Secret Redaction",
13
+ type ModuleName =
14
+ | "patchOverrideEdit"
15
+ | "secretRedaction"
16
+ | "lsp"
17
+ | "atOverride"
18
+ | "mcp"
19
+ | "wakatime"
20
+ | "rtk"
21
+ | "ask"
22
+ | "retry"
23
+ | "usage";
24
+
25
+ const MODULE_LABELS: Record<ModuleName, string> = {
26
+ patchOverrideEdit: "patchOverrideEdit",
27
+ secretRedaction: "secretRedaction",
13
28
  lsp: "LSP",
14
- "smart-at": "@ overload",
29
+ atOverride: "@ overload",
15
30
  mcp: "MCP",
16
31
  wakatime: "WakaTime",
17
32
  "rtk": "RTK",
18
- "codegraph": "Codegraph",
33
+ ask: "Ask",
34
+ retry: "Retry",
35
+ usage: "Usage",
19
36
  };
20
37
 
21
- const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
22
- patch: "Replace edit/write with patch tool (targeted string replacement)",
23
- safety: "Redact secrets from read / bash output before they enter model context",
38
+ const MODULE_DESCS: Record<ModuleName, string> = {
39
+ patchOverrideEdit: "Replace Pi native edit/write with patch tool (targeted string replacement)",
40
+ secretRedaction: "Redact secrets from read / bash output before they enter model context",
24
41
  lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
25
- "smart-at": "Project-aware file search replacing default autocomplete",
26
- mcp: "MCP client for context7 and exa (zero-config)",
42
+ atOverride: "Project-aware file search replacing default autocomplete",
43
+ mcp: "MCP client with builtin servers (context7, exa, codegraph)",
27
44
  wakatime: "Send coding activity heartbeats to WakaTime",
28
45
  "rtk": "Rewrite bash through system RTK when available",
29
- "codegraph": "Codegraph MCP server for code structure queries",
46
+ ask: "Interactive ask tool for user clarification (blocks loop until answered)",
47
+ retry: "/retry command to continue after interruption",
48
+ usage: "/usage command for token stats",
49
+ };
50
+
51
+ type CategoryId = "tools" | "hooks" | "commands";
52
+
53
+ interface CategoryDef {
54
+ label: string;
55
+ description: string;
56
+ modules: ModuleName[];
57
+ }
58
+
59
+ const CATEGORIES: Record<CategoryId, CategoryDef> = {
60
+ tools: {
61
+ label: "Tools",
62
+ description: "LLM-callable tools",
63
+ modules: ["patchOverrideEdit", "ask", "lsp", "mcp"],
64
+ },
65
+ hooks: {
66
+ label: "Hooks",
67
+ description: "Agent-loop event handlers",
68
+ modules: ["secretRedaction", "rtk", "wakatime"],
69
+ },
70
+ commands: {
71
+ label: "Commands",
72
+ description: "Slash commands",
73
+ modules: ["atOverride", "retry", "usage"],
74
+ },
30
75
  };
31
76
 
32
77
  class DynamicBorder implements Component {
@@ -46,29 +91,89 @@ function getSettingsListTheme(theme: PiTheme): SettingsListTheme {
46
91
  };
47
92
  }
48
93
 
94
+ const MODULE_TO_CATEGORY: Record<ModuleName, CategoryId> = {
95
+ patchOverrideEdit: "tools",
96
+ ask: "tools",
97
+ lsp: "tools",
98
+ mcp: "tools",
99
+ secretRedaction: "hooks",
100
+ "rtk": "hooks",
101
+ wakatime: "hooks",
102
+ atOverride: "commands",
103
+ retry: "commands",
104
+ usage: "commands",
105
+ };
106
+
107
+ function summaryFor(modules: Required<ModuleSettings>, ids: ModuleName[]): string {
108
+ const onCount = ids.reduce((sum, id) => {
109
+ const cat = MODULE_TO_CATEGORY[id];
110
+ return sum + ((modules[cat] as Record<string, boolean>)[id] ? 1 : 0);
111
+ }, 0);
112
+ return `${onCount}/${ids.length} on`;
113
+ }
114
+
115
+ class CategorySubmenu extends Container {
116
+ private list: SettingsList;
117
+
118
+ constructor(categoryId: CategoryId, theme: PiTheme, done: (summary?: string) => void) {
119
+ super();
120
+ const modules = getAllModuleSettings();
121
+ const category = CATEGORIES[categoryId];
122
+
123
+ const items: SettingItem[] = category.modules.map((id) => ({
124
+ id,
125
+ label: MODULE_LABELS[id],
126
+ description: MODULE_DESCS[id],
127
+ currentValue: (modules[MODULE_TO_CATEGORY[id]] as Record<string, boolean>)[id] ? "on" : "off",
128
+ values: ["on", "off"],
129
+ }));
130
+
131
+ this.list = new SettingsList(
132
+ items,
133
+ 10,
134
+ getSettingsListTheme(theme),
135
+ (id: string, newValue: string) => {
136
+ setModuleEnabled(id, newValue === "on");
137
+ this.list.updateValue(id, newValue);
138
+ // Stay open so the user can toggle multiple items; the parent
139
+ // summary updates when the submenu is closed with Esc.
140
+ },
141
+ () => done(summaryFor(getAllModuleSettings(), category.modules)),
142
+ );
143
+ this.addChild(this.list);
144
+ }
145
+
146
+ handleInput(data: string) {
147
+ this.list.handleInput(data);
148
+ }
149
+
150
+ render(width: number): string[] {
151
+ return this.list.render(width);
152
+ }
153
+ }
154
+
49
155
  export class ModuleSettingsComponent extends Container {
50
156
  private settingsList: SettingsList;
51
157
 
52
158
  constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
53
159
  super();
54
160
  const modules = getAllModuleSettings();
55
- const keys = Object.keys(MODULE_LABELS) as (keyof ModuleSettings)[];
56
- const items = keys.filter(k => modules[k] !== undefined).map(k => ({
57
- id: k,
58
- label: MODULE_LABELS[k],
59
- description: MODULE_DESCS[k],
60
- currentValue: modules[k] ? "on" : "off",
61
- values: ["on", "off"],
161
+
162
+ const categoryItems: SettingItem[] = (Object.keys(CATEGORIES) as CategoryId[]).map((id) => ({
163
+ id,
164
+ label: CATEGORIES[id].label,
165
+ description: CATEGORIES[id].description,
166
+ currentValue: summaryFor(modules, CATEGORIES[id].modules),
167
+ submenu: (_currentValue, done) => new CategorySubmenu(id, theme, done),
62
168
  }));
63
169
 
64
170
  this.addChild(new DynamicBorder(theme));
65
171
 
66
172
  this.settingsList = new SettingsList(
67
- items, 10, getSettingsListTheme(theme),
68
- (id: string, newValue: string) => {
69
- setModuleEnabled(id as keyof ModuleSettings, newValue === "on");
70
- tui.requestRender();
71
- },
173
+ categoryItems,
174
+ 10,
175
+ getSettingsListTheme(theme),
176
+ () => {},
72
177
  () => onDone(),
73
178
  { enableSearch: true },
74
179
  );
package/ui/usage.ts CHANGED
@@ -35,8 +35,8 @@ const SLICE_LABELS: Array<{ key: keyof UsageReport & string; label: string }> =
35
35
  ];
36
36
 
37
37
  const COL_HEADERS: Record<ColumnId, string> = {
38
- input: "↑Prompt",
39
- output: "↓Out",
38
+ input: "Input",
39
+ output: "Output",
40
40
  cacheRead: "CacheR",
41
41
  cacheWrite: "CacheW",
42
42
  hitRate: "CacheHit",
@@ -303,8 +303,8 @@ export class UsageReportComponent extends Container {
303
303
  );
304
304
  } else {
305
305
  lines.push("");
306
- lines.push(t.fg("dim", " ↑Prompt = all input tokens (user input + context) · CacheR = cached hits · CacheW = created cache"));
307
- lines.push(t.fg("dim", " ↓Out = model output tokens · CacheHit = CacheR / ↑Prompt"));
306
+ lines.push(t.fg("dim", " Input = all input tokens (user input + context) · Output = model output tokens"));
307
+ lines.push(t.fg("dim", " CacheR = cached hits · CacheW = created cache · CacheHit = CacheR / Input"));
308
308
 
309
309
  this.linesComponent.setText(lines.join("\n"));
310
310
  }
@@ -1,6 +0,0 @@
1
- {
2
- "pid": 3967873,
3
- "version": "0.9.9",
4
- "socketPath": "/home/liuchang/workspace/pi-extensions/decorated-pi/.codegraph/daemon.sock",
5
- "startedAt": 1781237871587
6
- }