decorated-pi 0.7.2 → 0.7.3

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.
@@ -2,9 +2,9 @@
2
2
  * LSP Server Config — language detection, server commands, workspace roots.
3
3
  */
4
4
  import { existsSync, readdirSync, type Dirent } from "node:fs";
5
- import { spawnSync } from "node:child_process";
6
5
  import { dirname, extname, isAbsolute, join, resolve } from "node:path";
7
6
  import type { DependencyStatus } from "../../hooks/skeleton.js";
7
+ import { getDependencyPath, resolveDependency } from "../../settings.js";
8
8
 
9
9
  // ─── File extension → language mapping ────────────────────────────────────
10
10
 
@@ -97,6 +97,16 @@ export function listSupportedLanguages(): string[] {
97
97
  return Object.keys(LANGUAGE_SERVERS).sort();
98
98
  }
99
99
 
100
+ /** Unique binary names used by builtin LSP servers. Used by the
101
+ * /dp-settings Dependencies UI to know which binaries are configurable. */
102
+ export function listLspBinaryNames(): string[] {
103
+ const seen = new Set<string>();
104
+ for (const lang of Object.keys(LANGUAGE_SERVERS)) {
105
+ seen.add(LANGUAGE_SERVERS[lang].command);
106
+ }
107
+ return [...seen].sort();
108
+ }
109
+
100
110
  export function getServerConfig(
101
111
  language: string,
102
112
  cwd = process.cwd(),
@@ -104,6 +114,12 @@ export function getServerConfig(
104
114
  const base = LANGUAGE_SERVERS[language];
105
115
  if (!base) return undefined;
106
116
 
117
+ const override = getDependencyPath(base.command);
118
+ if (override) {
119
+ const resolvedOverride = resolveDependency(base.command, { extendPath: [] });
120
+ return { ...base, command: resolvedOverride ?? override, is_project_local: false };
121
+ }
122
+
107
123
  const resolved = resolveLocalBinary(base.command, cwd);
108
124
  return { ...base, command: resolved.command, is_project_local: resolved.is_project_local };
109
125
  }
@@ -147,14 +163,16 @@ export function collectLspDependencyStatuses(cwd: string): DependencyStatus[] {
147
163
  const statuses: DependencyStatus[] = [];
148
164
  const seen = new Set<string>();
149
165
  for (const language of listSupportedLanguages()) {
150
- const cfg = getServerConfig(language, cwd);
151
- if (!cfg || seen.has(cfg.command)) continue;
152
- seen.add(cfg.command);
166
+ const base = LANGUAGE_SERVERS[language];
167
+ if (!base || seen.has(base.command)) continue;
168
+ seen.add(base.command);
169
+ const path = resolveLspBinary(base.command, cwd);
153
170
  statuses.push({
154
171
  module: "lsp",
155
- label: cfg.command,
156
- state: commandExists(cfg.command) ? "ok" : "missing",
157
- detail: cfg.install_hint,
172
+ label: base.command,
173
+ state: path ? "ok" : "missing",
174
+ detail: base.install_hint,
175
+ path: path ?? undefined,
158
176
  });
159
177
  }
160
178
  return statuses;
@@ -174,14 +192,12 @@ export function findWorkspaceRoot(
174
192
 
175
193
  // ─── Internal helpers ─────────────────────────────────────────────────────
176
194
 
177
- function commandExists(command: string): boolean {
178
- if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
179
- return existsSync(command);
180
- }
181
- const result = process.platform === "win32"
182
- ? spawnSync("where", [command], { encoding: "utf-8" })
183
- : spawnSync(process.env.SHELL || "sh", ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`], { encoding: "utf-8" });
184
- return result.status === 0;
195
+ export function lspDependencyExtendPath(cwd = process.cwd()): string[] {
196
+ return [...ancestorDirs(cwd)].map((dir) => join(dir, "node_modules", ".bin"));
197
+ }
198
+
199
+ export function resolveLspBinary(command: string, cwd = process.cwd()): string | null {
200
+ return resolveDependency(command, { extendPath: lspDependencyExtendPath(cwd) });
185
201
  }
186
202
 
187
203
  function resolveLocalBinary(
@@ -15,11 +15,11 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
- import { spawnSync } from "node:child_process";
19
- import { isModuleEnabled } from "../../settings.js";
18
+ import { isModuleEnabled, resolveDependency } from "../../settings.js";
20
19
  import type { DependencyStatus } from "../../hooks/skeleton.js";
21
20
  import { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
22
21
 
22
+
23
23
  export { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
24
24
 
25
25
  export interface McpServerConfig {
@@ -33,6 +33,8 @@ export interface McpServerConfig {
33
33
  source: "builtin" | "global" | "project";
34
34
  /** Optional predicate: return false if this server cannot be used in the given project. */
35
35
  canUseInProject?: (cwd: string) => boolean;
36
+ /** Binary/config key before dependency path resolution. */
37
+ dependencyName?: string;
36
38
  }
37
39
 
38
40
  function globalMcpJsonPath(): string {
@@ -217,33 +219,50 @@ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
217
219
  }
218
220
  }
219
221
 
220
- return [...byName.values()].filter((s) => s.url || s.command);
222
+ return [...byName.values()]
223
+ .filter((s) => s.url || s.command)
224
+ .map((s) => {
225
+ // Apply user-configured binary path override (dependencies[cmd].path)
226
+ // last, so it wins over builtin/global/project configs. Only applies
227
+ // to servers that use a command (not URL-based servers).
228
+ if (!s.command) return s;
229
+ const dependencyName = s.command;
230
+ const resolved = resolveMcpBinary(dependencyName);
231
+ return { ...s, dependencyName, command: resolved ?? s.command };
232
+ });
233
+ }
234
+
235
+ /** Unique binary names used by builtin MCP servers that have a `command`
236
+ * (URL-based servers are excluded — they don't need a binary). */
237
+ export function listMcpBinaryNames(): string[] {
238
+ const seen = new Set<string>();
239
+ for (const s of BUILTIN_MCP_SERVERS) {
240
+ if (s.command) seen.add(s.command);
241
+ }
242
+ return [...seen].sort();
221
243
  }
222
244
 
223
245
  export function collectMcpDependencyStatuses(cwd: string): DependencyStatus[] {
224
246
  const seen = new Set<string>();
225
247
  const statuses: DependencyStatus[] = [];
226
248
  for (const cfg of resolveMcpConfigs(cwd)) {
227
- if (!cfg.enabled || !cfg.command || seen.has(cfg.command)) continue;
228
- seen.add(cfg.command);
249
+ const depName = cfg.dependencyName ?? cfg.command;
250
+ if (!cfg.enabled || !cfg.command || !depName || seen.has(depName)) continue;
251
+ seen.add(depName);
252
+ const resolved = resolveMcpBinary(depName);
229
253
  statuses.push({
230
254
  module: `mcp:${cfg.name}`,
231
- label: cfg.command,
232
- state: commandExists(cfg.command) ? "ok" : "missing",
255
+ label: depName,
256
+ state: resolved ? "ok" : "missing",
233
257
  detail: `Install the MCP server command for \"${cfg.name}\" or update its config.`,
258
+ path: resolved ?? undefined,
234
259
  });
235
260
  }
236
261
  return statuses;
237
262
  }
238
263
 
239
- function commandExists(command: string): boolean {
240
- if (path.isAbsolute(command) || command.includes("/") || command.includes("\\")) {
241
- return fs.existsSync(command);
242
- }
243
- const result = process.platform === "win32"
244
- ? spawnSync("where", [command], { encoding: "utf-8" })
245
- : spawnSync(process.env.SHELL || "sh", ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`], { encoding: "utf-8" });
246
- return result.status === 0;
264
+ export function resolveMcpBinary(command: string): string | null {
265
+ return resolveDependency(command);
247
266
  }
248
267
 
249
268
  function readMcpJsonSafe(filePath: string): Record<string, any> | null {
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
+ }