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.
- package/README.md +18 -2
- package/commands/dp-settings.ts +3 -3
- package/hooks/compaction.ts +115 -85
- package/hooks/externalize.ts +48 -35
- package/hooks/rtk.ts +24 -97
- package/hooks/skeleton.ts +53 -18
- package/hooks/smart-at.ts +51 -7
- package/hooks/wakatime.ts +17 -30
- package/index.ts +15 -11
- package/package.json +1 -1
- package/settings.ts +159 -1
- package/tools/ask/index.ts +0 -3
- package/tools/lsp/servers.ts +31 -15
- package/tools/mcp/config.ts +34 -15
- package/tsconfig.json +1 -0
- package/ui/ask.ts +72 -22
- package/ui/module-settings.ts +166 -16
- package/utils/which.ts +109 -0
package/tools/lsp/servers.ts
CHANGED
|
@@ -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
|
|
151
|
-
if (!
|
|
152
|
-
seen.add(
|
|
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:
|
|
156
|
-
state:
|
|
157
|
-
detail:
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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(
|
package/tools/mcp/config.ts
CHANGED
|
@@ -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 {
|
|
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()]
|
|
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
|
-
|
|
228
|
-
|
|
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:
|
|
232
|
-
state:
|
|
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
|
|
240
|
-
|
|
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
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
|
+
}
|