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.
@@ -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
@@ -19,6 +19,7 @@
19
19
  "commands/**/*.ts",
20
20
  "providers/**/*.ts",
21
21
  "ui/**/*.ts",
22
+ "utils/**/*.ts",
22
23
  "test/**/*.ts"
23
24
  ],
24
25
  "exclude": ["node_modules", "dist"]
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; for allowCustom, opts.length = "Other" row
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
- /** For single/multi with allowCustom, returns the effective option count
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.allowCustom ? 1 : 0);
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 (q.allowCustom && state.cursor === (q.options?.length ?? 0)) {
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 (q.allowCustom && state.customSelected) {
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 allowCustom is in play and "Other" is chosen. */
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 (q.allowCustom && state.cursor === (q.options?.length ?? 0)) {
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 (q.allowCustom && state.customSelected && state.customText.trim() !== "") {
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 = q.allowCustom === true && state.cursor === opts.length;
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 = q.allowCustom === true && state.cursor === opts.length;
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 = q.allowCustom === true && j === opts.length;
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 = q.allowCustom === true && j === opts.length;
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 = q.allowCustom
417
- ? "↑↓ move · Enter next · Esc cancel"
418
- : "↑↓ move · Enter next · Esc cancel";
470
+ hint = "↑↓ move · Enter next · Esc cancel";
419
471
  } else {
420
- hint = q.allowCustom
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
  }
@@ -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 = "tools" | "hooks" | "commands";
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
- tools: {
61
- label: "Tools",
62
- description: "LLM-callable tools",
63
- modules: ["patchOverrideEdit", "ask", "lsp", "mcp"],
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: ["secretRedaction", "rtk", "wakatime"],
70
+ modules: ["rtk", "secretRedaction", "wakatime"],
69
71
  },
70
- commands: {
71
- label: "Commands",
72
- description: "Slash commands",
73
- modules: ["atOverride", "retry", "usage"],
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[] = (Object.keys(CATEGORIES) as CategoryId[]).map((id) => ({
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
+ }