decorated-pi 0.6.0 → 0.7.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 +45 -15
- package/commands/dp-settings.ts +6 -1
- package/hooks/mcp.ts +52 -0
- package/hooks/session-title.ts +25 -1
- package/hooks/smart-at.ts +114 -264
- package/index.ts +33 -22
- package/package.json +13 -5
- package/settings.ts +170 -31
- package/tools/ask/index.ts +93 -0
- package/tools/mcp/builtin/codegraph.ts +10 -34
- package/tools/mcp/builtin/index.ts +2 -2
- package/tools/mcp/config.ts +93 -21
- package/tools/patch/core.ts +7 -1
- package/tools/patch/index.ts +3 -2
- package/ui/ask.ts +443 -0
- package/ui/module-settings.ts +131 -26
- package/ui/usage.ts +4 -4
- package/.codegraph/daemon.pid +0 -6
- package/AGENTS.md +0 -92
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
|
+
}
|
package/ui/module-settings.ts
CHANGED
|
@@ -1,32 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module Settings UI — used by /dp-settings command.
|
|
3
|
-
*
|
|
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 {
|
|
7
|
-
import { Container, SettingsList,
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
29
|
+
atOverride: "@ overload",
|
|
15
30
|
mcp: "MCP",
|
|
16
31
|
wakatime: "WakaTime",
|
|
17
32
|
"rtk": "RTK",
|
|
18
|
-
|
|
33
|
+
ask: "Ask",
|
|
34
|
+
retry: "Retry",
|
|
35
|
+
usage: "Usage",
|
|
19
36
|
};
|
|
20
37
|
|
|
21
|
-
const MODULE_DESCS: Record<
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
mcp: "MCP client
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
const
|
|
57
|
-
id
|
|
58
|
-
label:
|
|
59
|
-
description:
|
|
60
|
-
currentValue: modules[
|
|
61
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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: "
|
|
39
|
-
output: "
|
|
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", "
|
|
307
|
-
lines.push(t.fg("dim", "
|
|
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
|
}
|