decorated-pi 0.7.2 → 0.8.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 +21 -3
- package/commands/dp-settings.ts +3 -3
- package/commands/usage.ts +5 -1
- package/hooks/compaction.ts +52 -157
- package/hooks/externalize.ts +48 -35
- package/hooks/rtk.ts +24 -97
- package/hooks/skeleton.ts +57 -19
- package/hooks/smart-at.ts +69 -30
- package/hooks/thinking-label-strip.ts +79 -0
- package/hooks/wakatime.ts +17 -30
- package/index.ts +207 -170
- package/package.json +3 -1
- package/settings.ts +159 -1
- package/tools/ask/index.ts +1 -4
- package/tools/lsp/servers.ts +31 -15
- package/tools/mcp/config.ts +34 -15
- package/tools/patch/core.ts +113 -24
- package/tools/patch/encoding.ts +172 -0
- package/tsconfig.json +1 -0
- package/ui/ask.ts +72 -22
- package/ui/module-settings.ts +166 -16
- package/utils/which.ts +109 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encoding detection and conversion for patch.
|
|
3
|
+
*
|
|
4
|
+
* - chardet analyses raw bytes (BOM + histogram heuristics) → encoding name.
|
|
5
|
+
* - iconv-lite decodes/encodes between Buffer and string.
|
|
6
|
+
*
|
|
7
|
+
* For UTF-8 (the overwhelmingly common case) we bypass iconv-lite and use
|
|
8
|
+
* Node's native string<->Buffer conversion — zero behaviour change vs the
|
|
9
|
+
* pre-encoding-aware tool, and no iconv runtime cost for ASCII/UTF-8 files.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import chardet, { type Match } from "chardet";
|
|
14
|
+
import * as iconv from "iconv-lite";
|
|
15
|
+
|
|
16
|
+
export interface FileEncoding {
|
|
17
|
+
/** iconv-lite-compatible encoding name (e.g. "utf-8", "gb18030", "utf-16le"). */
|
|
18
|
+
encoding: string;
|
|
19
|
+
/** True when the file starts with a BOM that should be preserved on write. */
|
|
20
|
+
hasBOM: boolean;
|
|
21
|
+
/** True when the file is UTF-8 (with or without BOM). Native path is used. */
|
|
22
|
+
isUtf8: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// chardet emits "UTF-8" for plain UTF-8. iconv-lite accepts any case.
|
|
26
|
+
const UTF8_ALIASES = new Set(["utf-8", "utf8", "ascii"]);
|
|
27
|
+
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
28
|
+
const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
|
|
29
|
+
const UTF16BE_BOM = Buffer.from([0xfe, 0xff]);
|
|
30
|
+
|
|
31
|
+
/** Returns true if `buf` is valid UTF-8 (every byte parses, no U+FFFD
|
|
32
|
+
* replacement). Pure-ASCII and any well-formed UTF-8 qualify. */
|
|
33
|
+
function isValidUtf8(buf: Buffer): boolean {
|
|
34
|
+
// Writing then reading back would corrupt invalid sequences into U+FFFD;
|
|
35
|
+
// detect that by comparing round-trip byte lengths. The cheap, correct
|
|
36
|
+
// check is Node's built-in: toString('utf8') replaces bad sequences with
|
|
37
|
+
// U+FFFD, so we scan the decoded string for it.
|
|
38
|
+
if (buf.length === 0) return true;
|
|
39
|
+
const s = buf.toString("utf8");
|
|
40
|
+
return !s.includes("\uFFFD");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function looksLikeUtf8Bom(buf: Buffer): boolean {
|
|
44
|
+
return buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function looksLikeUtf16LeBom(buf: Buffer): boolean {
|
|
48
|
+
return buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function looksLikeUtf16BeBom(buf: Buffer): boolean {
|
|
52
|
+
return buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Detect a file's encoding by reading its bytes.
|
|
57
|
+
* Falls back to UTF-8 if detection fails or the detected encoding is not
|
|
58
|
+
* supported by iconv-lite.
|
|
59
|
+
*/
|
|
60
|
+
export function detectFileEncoding(filePath: string): FileEncoding {
|
|
61
|
+
const buf = fs.readFileSync(filePath);
|
|
62
|
+
|
|
63
|
+
// BOM takes precedence for the UTF-16/UTF-32 family (endianness matters).
|
|
64
|
+
if (looksLikeUtf8Bom(buf)) {
|
|
65
|
+
return { encoding: "utf-8", hasBOM: true, isUtf8: true };
|
|
66
|
+
}
|
|
67
|
+
if (looksLikeUtf16LeBom(buf)) {
|
|
68
|
+
return { encoding: "utf-16le", hasBOM: true, isUtf8: false };
|
|
69
|
+
}
|
|
70
|
+
if (looksLikeUtf16BeBom(buf)) {
|
|
71
|
+
return { encoding: "utf-16be", hasBOM: true, isUtf8: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Any well-formed UTF-8 (including pure ASCII) is UTF-8. This short-circuits
|
|
75
|
+
// chardet's tendency to mis-classify short or ASCII-only buffers as exotic
|
|
76
|
+
// encodings (e.g. 2-byte "x\n" as utf-32le).
|
|
77
|
+
if (isValidUtf8(buf)) {
|
|
78
|
+
return { encoding: "utf-8", hasBOM: false, isUtf8: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Not valid UTF-8 — trust chardet's heuristic for the legacy encoding.
|
|
82
|
+
// chardet mis-classifies short or mixed CJK samples (it often ties GBK,
|
|
83
|
+
// Big5, Shift_JIS, EUC-JP, EUC-KR at the same low confidence). Use the
|
|
84
|
+
// full ranked list and prefer Chinese encodings — GB18030 is a superset
|
|
85
|
+
// of GBK/GB2312 and the most common non-UTF-8 encoding for Chinese text,
|
|
86
|
+
// which is the primary use case for this tool.
|
|
87
|
+
const candidates = chardet.analyse(buf);
|
|
88
|
+
let encoding = pickLegacyEncoding(candidates);
|
|
89
|
+
let hasBOM = false;
|
|
90
|
+
|
|
91
|
+
// Verify iconv-lite actually ships a codec for this label; otherwise fall
|
|
92
|
+
// back to UTF-8 (the previous behaviour) instead of throwing mid-edit.
|
|
93
|
+
if (!iconv.encodingExists(encoding)) {
|
|
94
|
+
encoding = "utf-8";
|
|
95
|
+
hasBOM = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Last-resort safety net: if the chosen encoding still produces U+FFFD
|
|
99
|
+
// replacement chars on decode, the file is not actually in that encoding
|
|
100
|
+
// and we would corrupt it on write-back. Fall back to ISO-8859-1 (Latin1),
|
|
101
|
+
// which is a lossless 1:1 byte<->code-point mapping for 0x00–0xFF and can
|
|
102
|
+
// never introduce U+FFFD — round-tripping is byte-identical.
|
|
103
|
+
if (!UTF8_ALIASES.has(encoding) && iconv.decode(buf, encoding).includes("\uFFFD")) {
|
|
104
|
+
encoding = "iso-8859-1";
|
|
105
|
+
hasBOM = false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
encoding,
|
|
110
|
+
hasBOM,
|
|
111
|
+
isUtf8: UTF8_ALIASES.has(encoding),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Preference order for breaking chardet ties. Chinese first (GB18030 is a
|
|
116
|
+
* superset of GBK/GB2312), then other CJK, then everything else. */
|
|
117
|
+
const ENCODING_PRIORITY: string[] = [
|
|
118
|
+
"gb18030", "gbk", "gb2312", // Chinese (mainland)
|
|
119
|
+
"big5", // Chinese (traditional)
|
|
120
|
+
"shift_jis", "euc-jp", // Japanese
|
|
121
|
+
"euc-kr", "windows-949", // Korean
|
|
122
|
+
"windows-1252", "iso-8859-1", // Western (rarely reached: isValidUtf8 short-circuits)
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
function pickLegacyEncoding(candidates: Match[]): string {
|
|
126
|
+
if (candidates.length === 0) return "iso-8859-1";
|
|
127
|
+
const top = candidates[0]!.confidence;
|
|
128
|
+
// Keep only candidates tied with the top confidence (±5 tolerance —
|
|
129
|
+
// chardet's scores are coarse).
|
|
130
|
+
const tied = candidates.filter((c) => Math.abs(c.confidence - top) <= 5);
|
|
131
|
+
for (const pref of ENCODING_PRIORITY) {
|
|
132
|
+
const hit = tied.find((c) => c.name.toLowerCase() === pref);
|
|
133
|
+
if (hit) return pref;
|
|
134
|
+
}
|
|
135
|
+
// We only reach here when isValidUtf8 already failed (there are high
|
|
136
|
+
// bytes), so "ascii" / "UTF-8" from chardet are wrong. ISO-8859-1 is the
|
|
137
|
+
// safe single-byte fallback — lossless for 0x00–0xFF, no U+FFFD.
|
|
138
|
+
return "iso-8859-1";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Read a file as a string, decoding via the detected encoding. */
|
|
142
|
+
export function readFileDecoded(filePath: string, enc: FileEncoding): string {
|
|
143
|
+
const buf = fs.readFileSync(filePath);
|
|
144
|
+
if (enc.isUtf8) {
|
|
145
|
+
// Native path: identical to the old fs.readFileSync(p, "utf8").
|
|
146
|
+
// For UTF-8 with BOM, slice off the BOM so it does not leak into content
|
|
147
|
+
// matching (it would prefix the first line and break old_str lookups).
|
|
148
|
+
const slice = enc.hasBOM ? buf.subarray(3) : buf;
|
|
149
|
+
return slice.toString("utf8");
|
|
150
|
+
}
|
|
151
|
+
return iconv.decode(buf, enc.encoding, { stripBOM: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Write a string back to a file, encoding via the detected encoding. */
|
|
155
|
+
export function writeFileEncoded(
|
|
156
|
+
filePath: string,
|
|
157
|
+
content: string,
|
|
158
|
+
enc: FileEncoding,
|
|
159
|
+
): void {
|
|
160
|
+
if (enc.isUtf8) {
|
|
161
|
+
if (enc.hasBOM) {
|
|
162
|
+
const body = Buffer.from(content, "utf8");
|
|
163
|
+
const out = Buffer.concat([UTF8_BOM, body]);
|
|
164
|
+
fs.writeFileSync(filePath, out);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const buf = iconv.encode(content, enc.encoding, { addBOM: enc.hasBOM });
|
|
171
|
+
fs.writeFileSync(filePath, buf);
|
|
172
|
+
}
|
package/tsconfig.json
CHANGED
package/ui/ask.ts
CHANGED
|
@@ -28,10 +28,6 @@ export interface AskQuestion {
|
|
|
28
28
|
question: string;
|
|
29
29
|
options?: string[];
|
|
30
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
31
|
}
|
|
36
32
|
|
|
37
33
|
export interface AskAnswer {
|
|
@@ -41,7 +37,7 @@ export interface AskAnswer {
|
|
|
41
37
|
|
|
42
38
|
interface QuestionState {
|
|
43
39
|
value: string | string[];
|
|
44
|
-
cursor: number; // option cursor;
|
|
40
|
+
cursor: number; // option cursor; opts.length maps to the "Other" row
|
|
45
41
|
/** Typed text when the cursor sits on the "Other" row (single or multi). */
|
|
46
42
|
customText: string;
|
|
47
43
|
/** Multi only: whether "Other" is currently toggled into the selection. */
|
|
@@ -62,10 +58,39 @@ function parseDefault(type: AskQuestionType, options: string[] | undefined, defa
|
|
|
62
58
|
return { value: selected, cursor: 0, customText: "", customSelected: false };
|
|
63
59
|
}
|
|
64
60
|
|
|
65
|
-
/**
|
|
61
|
+
/** Returns true for single/multi choice questions, which always get an
|
|
62
|
+
* "Other" row for entering a custom answer not in the preset list. */
|
|
63
|
+
function hasOtherRow(q: AskQuestion): boolean {
|
|
64
|
+
return q.type === "single" || q.type === "multi";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** For single/multi questions, returns the effective option count
|
|
66
68
|
* (regular options plus the "Other" row). */
|
|
67
69
|
function effectiveOptionCount(q: AskQuestion): number {
|
|
68
|
-
return (q.options?.length ?? 0) + (q
|
|
70
|
+
return (q.options?.length ?? 0) + (hasOtherRow(q) ? 1 : 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
74
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
75
|
+
|
|
76
|
+
/** Extract content from a bracketed-paste sequence. Returns undefined if the
|
|
77
|
+
* data is not a complete paste. Any trailing input after the end marker is
|
|
78
|
+
* returned as `remaining` so it can be processed recursively. */
|
|
79
|
+
function extractBracketedPaste(data: string): { content: string; remaining: string } | undefined {
|
|
80
|
+
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
81
|
+
const afterStart = data.slice(BRACKETED_PASTE_START.length);
|
|
82
|
+
const endIndex = afterStart.indexOf(BRACKETED_PASTE_END);
|
|
83
|
+
if (endIndex === -1) return undefined;
|
|
84
|
+
return {
|
|
85
|
+
content: afterStart.slice(0, endIndex),
|
|
86
|
+
remaining: afterStart.slice(endIndex + BRACKETED_PASTE_END.length),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Normalize pasted text for a single-line input: strip line breaks and
|
|
91
|
+
* expand tabs to spaces (matches pi-tui's built-in Input behavior). */
|
|
92
|
+
function cleanPastedText(text: string): string {
|
|
93
|
+
return text.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " ");
|
|
69
94
|
}
|
|
70
95
|
|
|
71
96
|
class DynamicBorder implements Component {
|
|
@@ -133,31 +158,31 @@ export class AskComponent extends Container {
|
|
|
133
158
|
const state = this.currentState();
|
|
134
159
|
if (q.type === "text") return (state.value as string).trim() !== "";
|
|
135
160
|
if (q.type === "single") {
|
|
136
|
-
if (
|
|
161
|
+
if (state.cursor === (q.options?.length ?? 0)) {
|
|
137
162
|
return state.customText.trim() !== "";
|
|
138
163
|
}
|
|
139
164
|
return (state.value as string) !== "";
|
|
140
165
|
}
|
|
141
166
|
// multi: valid if any regular option is selected, or "Other" is
|
|
142
167
|
// toggled AND has non-empty custom text.
|
|
143
|
-
if (
|
|
168
|
+
if (state.customSelected) {
|
|
144
169
|
return state.customText.trim() !== "" || (state.value as string[]).length > 0;
|
|
145
170
|
}
|
|
146
171
|
return (state.value as string[]).length > 0;
|
|
147
172
|
}
|
|
148
173
|
|
|
149
174
|
/** The answer that will actually be submitted for this question. Differs
|
|
150
|
-
* from state.value when
|
|
175
|
+
* from state.value when the cursor is on the "Other" row. */
|
|
151
176
|
private committedValue(q: AskQuestion, state: QuestionState): string | string[] {
|
|
152
177
|
if (q.type === "single") {
|
|
153
|
-
if (
|
|
178
|
+
if (state.cursor === (q.options?.length ?? 0)) {
|
|
154
179
|
return state.customText.trim();
|
|
155
180
|
}
|
|
156
181
|
return state.value as string;
|
|
157
182
|
}
|
|
158
183
|
if (q.type === "multi") {
|
|
159
184
|
const base = state.value as string[];
|
|
160
|
-
if (
|
|
185
|
+
if (state.customSelected && state.customText.trim() !== "") {
|
|
161
186
|
return [...base, state.customText.trim()];
|
|
162
187
|
}
|
|
163
188
|
return base;
|
|
@@ -209,6 +234,35 @@ export class AskComponent extends Container {
|
|
|
209
234
|
handleInput(data: string): void {
|
|
210
235
|
const kb = getKeybindings();
|
|
211
236
|
|
|
237
|
+
// ── Bracketed paste ─────────────────────────────────────────────
|
|
238
|
+
// Terminals that support bracketed paste mode wrap pasted content in
|
|
239
|
+
// \x1b[200~ ... \x1b[201~. Without handling this, the leading ESC is
|
|
240
|
+
// rejected as a control character and nothing is inserted.
|
|
241
|
+
const paste = extractBracketedPaste(data);
|
|
242
|
+
if (paste) {
|
|
243
|
+
if (!this.summaryMode) {
|
|
244
|
+
const text = cleanPastedText(paste.content);
|
|
245
|
+
const q = this.currentQuestion();
|
|
246
|
+
const state = this.currentState();
|
|
247
|
+
if (q.type === "text") {
|
|
248
|
+
state.value = (state.value as string) + text;
|
|
249
|
+
} else if (hasOtherRow(q)) {
|
|
250
|
+
const opts = q.options ?? [];
|
|
251
|
+
if (state.cursor === opts.length) {
|
|
252
|
+
state.customText += text;
|
|
253
|
+
if (q.type === "multi") {
|
|
254
|
+
state.customSelected = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
this.renderView();
|
|
259
|
+
}
|
|
260
|
+
if (paste.remaining) {
|
|
261
|
+
this.handleInput(paste.remaining);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
212
266
|
// ── Summary mode ────────────────────────────────────────────────
|
|
213
267
|
if (this.summaryMode) {
|
|
214
268
|
if (data === "\r" || data === "\n" || kb.matches(data, "tui.select.confirm")) {
|
|
@@ -264,7 +318,7 @@ export class AskComponent extends Container {
|
|
|
264
318
|
} else if (q.type === "single" && q.options) {
|
|
265
319
|
const opts = q.options;
|
|
266
320
|
const totalLen = effectiveOptionCount(q);
|
|
267
|
-
const onCustomRow =
|
|
321
|
+
const onCustomRow = state.cursor === opts.length;
|
|
268
322
|
|
|
269
323
|
if (kb.matches(data, "tui.select.up")) {
|
|
270
324
|
const next = (state.cursor - 1 + totalLen) % totalLen;
|
|
@@ -290,7 +344,7 @@ export class AskComponent extends Container {
|
|
|
290
344
|
} else if (q.type === "multi" && q.options) {
|
|
291
345
|
const opts = q.options;
|
|
292
346
|
const totalLen = effectiveOptionCount(q);
|
|
293
|
-
const onCustomRow =
|
|
347
|
+
const onCustomRow = state.cursor === opts.length;
|
|
294
348
|
|
|
295
349
|
if (kb.matches(data, "tui.select.up")) {
|
|
296
350
|
state.cursor = (state.cursor - 1 + totalLen) % totalLen;
|
|
@@ -367,7 +421,7 @@ export class AskComponent extends Container {
|
|
|
367
421
|
const opts = q.options;
|
|
368
422
|
const totalLen = effectiveOptionCount(q);
|
|
369
423
|
for (let j = 0; j < totalLen; j++) {
|
|
370
|
-
const isCustomRow =
|
|
424
|
+
const isCustomRow = j === opts.length;
|
|
371
425
|
const optLabel = isCustomRow ? "Other" : opts[j];
|
|
372
426
|
const selected = isCustomRow ? false : optLabel === state.value;
|
|
373
427
|
const atCursor = j === state.cursor;
|
|
@@ -389,7 +443,7 @@ export class AskComponent extends Container {
|
|
|
389
443
|
const opts = q.options;
|
|
390
444
|
const totalLen = effectiveOptionCount(q);
|
|
391
445
|
for (let j = 0; j < totalLen; j++) {
|
|
392
|
-
const isCustomRow =
|
|
446
|
+
const isCustomRow = j === opts.length;
|
|
393
447
|
const optLabel = isCustomRow ? "Other" : opts[j];
|
|
394
448
|
const inValue = !isCustomRow && (state.value as string[]).includes(optLabel);
|
|
395
449
|
const customOn = isCustomRow && state.customSelected;
|
|
@@ -413,13 +467,9 @@ export class AskComponent extends Container {
|
|
|
413
467
|
if (q.type === "text") {
|
|
414
468
|
hint = "Enter next · Esc cancel";
|
|
415
469
|
} else if (q.type === "single") {
|
|
416
|
-
hint =
|
|
417
|
-
? "↑↓ move · Enter next · Esc cancel"
|
|
418
|
-
: "↑↓ move · Enter next · Esc cancel";
|
|
470
|
+
hint = "↑↓ move · Enter next · Esc cancel";
|
|
419
471
|
} else {
|
|
420
|
-
hint =
|
|
421
|
-
? "↑↓ move · Space toggle · Enter next · Esc cancel"
|
|
422
|
-
: "↑↓ move · Space toggle · Enter next · Esc cancel";
|
|
472
|
+
hint = "↑↓ move · Space toggle · Enter next · Esc cancel";
|
|
423
473
|
}
|
|
424
474
|
this.hintComponent.setText(this.theme.fg("dim", " " + hint));
|
|
425
475
|
}
|
package/ui/module-settings.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* category so the user can toggle each one.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { Theme as PiTheme, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { Container, SettingsList, type TUI, type SettingsListTheme, type SettingItem, type Component } from "@earendil-works/pi-tui";
|
|
11
|
-
import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "../settings.js";
|
|
11
|
+
import { getAllModuleSettings, setModuleEnabled, type ModuleSettings, getDependencyPath, setDependencyPath, isDontBother, setDontBother, getDependencyView, listDependencyViewNames } from "../settings.js";
|
|
12
|
+
import { listLspBinaryNames } from "../tools/lsp/servers.js";
|
|
13
|
+
import { listMcpBinaryNames } from "../tools/mcp/config.js";
|
|
12
14
|
|
|
13
15
|
type ModuleName =
|
|
14
16
|
| "patchOverrideEdit"
|
|
@@ -48,7 +50,7 @@ const MODULE_DESCS: Record<ModuleName, string> = {
|
|
|
48
50
|
usage: "/usage command for token stats",
|
|
49
51
|
};
|
|
50
52
|
|
|
51
|
-
type CategoryId = "
|
|
53
|
+
type CategoryId = "commands" | "hooks" | "tools";
|
|
52
54
|
|
|
53
55
|
interface CategoryDef {
|
|
54
56
|
label: string;
|
|
@@ -57,23 +59,27 @@ interface CategoryDef {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
const CATEGORIES: Record<CategoryId, CategoryDef> = {
|
|
60
|
-
|
|
61
|
-
label: "
|
|
62
|
-
description: "
|
|
63
|
-
modules: ["
|
|
62
|
+
commands: {
|
|
63
|
+
label: "Commands",
|
|
64
|
+
description: "Slash commands",
|
|
65
|
+
modules: ["atOverride", "retry", "usage"],
|
|
64
66
|
},
|
|
65
67
|
hooks: {
|
|
66
68
|
label: "Hooks",
|
|
67
69
|
description: "Agent-loop event handlers",
|
|
68
|
-
modules: ["
|
|
70
|
+
modules: ["rtk", "secretRedaction", "wakatime"],
|
|
69
71
|
},
|
|
70
|
-
|
|
71
|
-
label: "
|
|
72
|
-
description: "
|
|
73
|
-
modules: ["
|
|
72
|
+
tools: {
|
|
73
|
+
label: "Tools",
|
|
74
|
+
description: "LLM-callable tools",
|
|
75
|
+
modules: ["ask", "lsp", "mcp", "patchOverrideEdit"],
|
|
74
76
|
},
|
|
75
77
|
};
|
|
76
78
|
|
|
79
|
+
// Hard-coded display order, alphabetized by visible label. Dependencies is
|
|
80
|
+
// inserted between Commands and Hooks in ModuleSettingsComponent below.
|
|
81
|
+
const CATEGORY_ORDER: CategoryId[] = ["commands", "hooks", "tools"];
|
|
82
|
+
|
|
77
83
|
class DynamicBorder implements Component {
|
|
78
84
|
private colorFn: (str: string) => string;
|
|
79
85
|
constructor(theme: PiTheme) { this.colorFn = (str: string) => theme.fg("border", str); }
|
|
@@ -135,8 +141,6 @@ class CategorySubmenu extends Container {
|
|
|
135
141
|
(id: string, newValue: string) => {
|
|
136
142
|
setModuleEnabled(id, newValue === "on");
|
|
137
143
|
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
144
|
},
|
|
141
145
|
() => done(summaryFor(getAllModuleSettings(), category.modules)),
|
|
142
146
|
);
|
|
@@ -152,14 +156,132 @@ class CategorySubmenu extends Container {
|
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
/** Submenu for configuring binary path overrides. Each row is a binary
|
|
160
|
+
* that decorated-pi looks up at startup; Enter opens an input dialog
|
|
161
|
+
* where the user can type an absolute path (or clear it). */
|
|
162
|
+
function dependencyDisplayValue(name: string): string {
|
|
163
|
+
const view = getDependencyView(name);
|
|
164
|
+
if (view.path) return view.path;
|
|
165
|
+
if (view.resolvedPath) return view.resolvedPath;
|
|
166
|
+
if (view.resolvedState === undefined) return view.dontBother ? "(not checked, silenced)" : "(not checked)";
|
|
167
|
+
return view.dontBother ? "(not found, silenced)" : "(not found)";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
class DependencyBinarySubmenu extends Container {
|
|
171
|
+
private list: SettingsList;
|
|
172
|
+
private name: string;
|
|
173
|
+
private ui: ExtensionUIContext;
|
|
174
|
+
|
|
175
|
+
constructor(name: string, theme: PiTheme, ui: ExtensionUIContext, done: (summary?: string) => void) {
|
|
176
|
+
super();
|
|
177
|
+
this.name = name;
|
|
178
|
+
this.ui = ui;
|
|
179
|
+
|
|
180
|
+
const items: SettingItem[] = [
|
|
181
|
+
{
|
|
182
|
+
id: "path",
|
|
183
|
+
label: "Path override",
|
|
184
|
+
description: "Enter to edit; empty to clear override",
|
|
185
|
+
currentValue: dependencyDisplayValue(name),
|
|
186
|
+
values: ["edit"],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: "dontBother",
|
|
190
|
+
label: "dontBother",
|
|
191
|
+
description: "Silence missing-dependency notification for this binary",
|
|
192
|
+
currentValue: isDontBother(name) ? "on" : "off",
|
|
193
|
+
values: ["off", "on"],
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
this.list = new SettingsList(
|
|
198
|
+
items,
|
|
199
|
+
10,
|
|
200
|
+
getSettingsListTheme(theme),
|
|
201
|
+
(id: string, newValue: string) => {
|
|
202
|
+
if (id === "dontBother") {
|
|
203
|
+
setDontBother(this.name, newValue === "on");
|
|
204
|
+
this.list.updateValue("dontBother", newValue);
|
|
205
|
+
this.list.updateValue("path", dependencyDisplayValue(this.name));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.list.updateValue("path", dependencyDisplayValue(this.name));
|
|
209
|
+
void this.promptForPath();
|
|
210
|
+
},
|
|
211
|
+
() => done(dependencyDisplayValue(this.name)),
|
|
212
|
+
);
|
|
213
|
+
this.addChild(this.list);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
handleInput(data: string) {
|
|
217
|
+
this.list.handleInput(data);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async promptForPath(): Promise<void> {
|
|
221
|
+
const current = getDependencyPath(this.name) ?? "";
|
|
222
|
+
const input = await this.ui.input(
|
|
223
|
+
`Path for ${this.name} (empty to clear)`,
|
|
224
|
+
current || `/absolute/path/to/${this.name}`,
|
|
225
|
+
);
|
|
226
|
+
if (input === undefined) return;
|
|
227
|
+
setDependencyPath(this.name, input.trim() === "" ? null : input.trim());
|
|
228
|
+
this.list.updateValue("path", dependencyDisplayValue(this.name));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
render(width: number): string[] {
|
|
232
|
+
return this.list.render(width);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
class DependenciesSubmenu extends Container {
|
|
237
|
+
private list: SettingsList;
|
|
238
|
+
private binaryNames: string[];
|
|
239
|
+
|
|
240
|
+
constructor(theme: PiTheme, ui: ExtensionUIContext, done: (summary?: string) => void) {
|
|
241
|
+
super();
|
|
242
|
+
// Builtins we know about plus entries already present in config/shadow.
|
|
243
|
+
this.binaryNames = listDependencyViewNames([
|
|
244
|
+
"rtk",
|
|
245
|
+
"wakatime-cli",
|
|
246
|
+
...listLspBinaryNames(),
|
|
247
|
+
...listMcpBinaryNames(),
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const items: SettingItem[] = this.binaryNames.map((name) => ({
|
|
251
|
+
id: name,
|
|
252
|
+
label: name,
|
|
253
|
+
description: "Enter to configure path override and dontBother",
|
|
254
|
+
currentValue: dependencyDisplayValue(name),
|
|
255
|
+
submenu: (_currentValue, submenuDone) => new DependencyBinarySubmenu(name, theme, ui, submenuDone),
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
this.list = new SettingsList(
|
|
259
|
+
items,
|
|
260
|
+
10,
|
|
261
|
+
getSettingsListTheme(theme),
|
|
262
|
+
() => {},
|
|
263
|
+
() => done(summaryForDependencies()),
|
|
264
|
+
);
|
|
265
|
+
this.addChild(this.list);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
handleInput(data: string) {
|
|
269
|
+
this.list.handleInput(data);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
render(width: number): string[] {
|
|
273
|
+
return this.list.render(width);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
155
277
|
export class ModuleSettingsComponent extends Container {
|
|
156
278
|
private settingsList: SettingsList;
|
|
157
279
|
|
|
158
|
-
constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
|
|
280
|
+
constructor(tui: TUI, theme: PiTheme, ui: ExtensionUIContext, onDone: () => void) {
|
|
159
281
|
super();
|
|
160
282
|
const modules = getAllModuleSettings();
|
|
161
283
|
|
|
162
|
-
const categoryItems: SettingItem[] =
|
|
284
|
+
const categoryItems: SettingItem[] = CATEGORY_ORDER.map((id) => ({
|
|
163
285
|
id,
|
|
164
286
|
label: CATEGORIES[id].label,
|
|
165
287
|
description: CATEGORIES[id].description,
|
|
@@ -167,6 +289,17 @@ export class ModuleSettingsComponent extends Container {
|
|
|
167
289
|
submenu: (_currentValue, done) => new CategorySubmenu(id, theme, done),
|
|
168
290
|
}));
|
|
169
291
|
|
|
292
|
+
// Dependencies is a separate top-level category — it doesn't fit
|
|
293
|
+
// ModuleSettings' on/off toggle model. Insert it alphabetically between
|
|
294
|
+
// Commands and Hooks.
|
|
295
|
+
categoryItems.splice(1, 0, {
|
|
296
|
+
id: "dependencies",
|
|
297
|
+
label: "Dependencies",
|
|
298
|
+
description: "Override binary paths (rtk, wakatime-cli, LSP/MCP servers)",
|
|
299
|
+
currentValue: summaryForDependencies(),
|
|
300
|
+
submenu: (_currentValue, done) => new DependenciesSubmenu(theme, ui, done),
|
|
301
|
+
});
|
|
302
|
+
|
|
170
303
|
this.addChild(new DynamicBorder(theme));
|
|
171
304
|
|
|
172
305
|
this.settingsList = new SettingsList(
|
|
@@ -186,3 +319,20 @@ export class ModuleSettingsComponent extends Container {
|
|
|
186
319
|
this.settingsList.handleInput(data);
|
|
187
320
|
}
|
|
188
321
|
}
|
|
322
|
+
|
|
323
|
+
/** Count how many binaries have an explicit override. */
|
|
324
|
+
function summaryForDependencies(): string {
|
|
325
|
+
// Builtins we know about: rtk, wakatime-cli, LSP servers, MCP servers.
|
|
326
|
+
const known = listDependencyViewNames([
|
|
327
|
+
"rtk",
|
|
328
|
+
"wakatime-cli",
|
|
329
|
+
...listLspBinaryNames(),
|
|
330
|
+
...listMcpBinaryNames(),
|
|
331
|
+
]);
|
|
332
|
+
const overridden = known.filter((n) => getDependencyPath(n) !== null).length;
|
|
333
|
+
const silenced = known.filter((n) => isDontBother(n)).length;
|
|
334
|
+
const parts: string[] = [];
|
|
335
|
+
if (overridden) parts.push(`${overridden} overridden`);
|
|
336
|
+
if (silenced) parts.push(`${silenced} silenced`);
|
|
337
|
+
return parts.length ? parts.join(", ") : "default";
|
|
338
|
+
}
|