decorated-pi 0.4.0 → 0.5.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.
@@ -26,8 +26,12 @@ export interface ProviderCache {
26
26
  }
27
27
 
28
28
  export interface McpServerEntry {
29
- url: string;
29
+ url?: string;
30
+ command?: string;
31
+ args?: string[];
32
+ env?: Record<string, string>;
30
33
  enabled?: boolean;
34
+ description?: string;
31
35
  }
32
36
 
33
37
  export interface ModuleSettings {
@@ -36,11 +40,15 @@ export interface ModuleSettings {
36
40
  "smart-at"?: boolean;
37
41
  patch?: boolean;
38
42
  mcp?: boolean;
43
+ wakatime?: boolean;
44
+ "rtk"?: boolean;
39
45
  }
40
46
 
41
47
  export interface DecoratedPiConfig {
42
48
  imageModelKey?: string | null;
43
49
  compactModelKey?: string | null;
50
+ mcpBrokerModelKey?: string | null;
51
+ mcpDescriptions?: Record<string, string>;
44
52
  providers?: Record<string, ProviderCache>;
45
53
  modules?: ModuleSettings;
46
54
  mcpServers?: Record<string, McpServerEntry>;
@@ -93,6 +101,28 @@ export function removeProvider(name: string) {
93
101
  }
94
102
  }
95
103
 
104
+ // ─── Project-level config ────────────────────────────────────────────────
105
+
106
+ function projectConfigPath(cwd: string): string {
107
+ return path.join(cwd, ".pi", "agent", "decorated-pi.json");
108
+ }
109
+
110
+ function loadProjectConfig(cwd: string): DecoratedPiConfig {
111
+ try {
112
+ const p = projectConfigPath(cwd);
113
+ if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8"));
114
+ } catch {}
115
+ return {};
116
+ }
117
+
118
+ function saveProjectConfig(cwd: string, partial: Partial<DecoratedPiConfig>) {
119
+ const p = projectConfigPath(cwd);
120
+ const dir = path.dirname(p);
121
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
122
+ const current = loadProjectConfig(cwd);
123
+ fs.writeFileSync(p, JSON.stringify({ ...current, ...partial }, null, 2), "utf-8");
124
+ }
125
+
96
126
  // ─── Getter ─────────────────────────────────────────────────────────────────
97
127
 
98
128
  export function getImageModelKey(): string | null {
@@ -113,6 +143,36 @@ export function setCompactModelKey(key: string | null) {
113
143
  saveConfig({ compactModelKey: key });
114
144
  }
115
145
 
146
+ export function getMcpBrokerModelKey(): string | null {
147
+ return loadConfig().mcpBrokerModelKey ?? null;
148
+ }
149
+
150
+ export function setMcpBrokerModelKey(key: string | null) {
151
+ saveConfig({ mcpBrokerModelKey: key });
152
+ }
153
+
154
+ export function getMcpDescription(name: string, cwd?: string): string | undefined {
155
+ if (cwd) {
156
+ const projectCfg = loadProjectConfig(cwd);
157
+ if (projectCfg.mcpDescriptions?.[name]) return projectCfg.mcpDescriptions[name];
158
+ }
159
+ return loadConfig().mcpDescriptions?.[name];
160
+ }
161
+
162
+ export function setMcpDescription(name: string, description: string, cwd?: string) {
163
+ if (cwd) {
164
+ const cfg = loadProjectConfig(cwd);
165
+ const descriptions = { ...cfg.mcpDescriptions };
166
+ descriptions[name] = description;
167
+ saveProjectConfig(cwd, { mcpDescriptions: descriptions });
168
+ return;
169
+ }
170
+ const cfg = loadConfig();
171
+ const descriptions = { ...cfg.mcpDescriptions };
172
+ descriptions[name] = description;
173
+ saveConfig({ mcpDescriptions: descriptions });
174
+ }
175
+
116
176
  // ─── Module Switches ──────────────────────────────────────────────────────────
117
177
 
118
178
  const DEFAULT_MODULES: Required<ModuleSettings> = {
@@ -121,6 +181,8 @@ const DEFAULT_MODULES: Required<ModuleSettings> = {
121
181
  "smart-at": true,
122
182
  patch: true,
123
183
  mcp: true,
184
+ wakatime: true,
185
+ "rtk": true,
124
186
  };
125
187
 
126
188
  export function isModuleEnabled(name: keyof ModuleSettings): boolean {
@@ -9,7 +9,8 @@
9
9
  import type { ExtensionAPI, ExtensionContext, Theme as PiTheme } from "@earendil-works/pi-coding-agent";
10
10
  import { ModelPickerComponent } from "./model-integration.js";
11
11
  import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "./settings.js";
12
- import { getMcpStatus } from "./mcp/index.js";
12
+ import { getMcpStatus, refreshServerCache, updateConfigEnabled } from "./mcp/index.js";
13
+ import { toggleMcpServerEnabled } from "./mcp/builtin.js";
13
14
  import { Container, SettingsList, Spacer, Text, type TUI, type SettingsListTheme, type Component, getKeybindings } from "@earendil-works/pi-tui";
14
15
 
15
16
  // ─── Border component (matches native DynamicBorder) ────────────────────────
@@ -44,7 +45,7 @@ function getSettingsListTheme(theme: PiTheme): SettingsListTheme {
44
45
 
45
46
  function setupDpModelCommand(pi: ExtensionAPI) {
46
47
  pi.registerCommand("dp-model", {
47
- description: "Configure image and compact models",
48
+ description: "Configure image, compact, and MCP broker models",
48
49
  handler: async (_args, ctx) => {
49
50
  if (ctx.hasUI) {
50
51
  await ctx.ui.custom<void>(
@@ -66,6 +67,8 @@ const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
66
67
  lsp: "LSP",
67
68
  "smart-at": "@ overload",
68
69
  mcp: "MCP",
70
+ wakatime: "WakaTime",
71
+ "rtk": "RTK",
69
72
  };
70
73
 
71
74
  const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
@@ -74,6 +77,8 @@ const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
74
77
  lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
75
78
  "smart-at": "Project-aware file search replacing default autocomplete",
76
79
  mcp: "MCP client for context7 and exa (zero-config)",
80
+ wakatime: "Send coding activity heartbeats to WakaTime",
81
+ "rtk": "Rewrite bash through system RTK when available",
77
82
  };
78
83
 
79
84
  class ModuleSettingsComponent extends Container {
@@ -133,109 +138,244 @@ function setupDpSettingsCommand(pi: ExtensionAPI) {
133
138
  // ─── /mcp ──────────────────────────────────────────────────────────────────
134
139
 
135
140
  class McpStatusComponent extends Container {
136
- private textComponent: Text;
141
+ private linesComponent: Text;
142
+ private hintComponent: Text;
143
+ private notifyComponent: Text;
137
144
  private tui: TUI;
138
145
  private theme: PiTheme;
139
146
  private done: () => void;
140
- private timer: ReturnType<typeof setInterval> | null = null;
147
+ private registry: any;
148
+ private selected = 0;
149
+ private servers: ReturnType<typeof getMcpStatus> = [];
150
+ private notifyText = "";
151
+ private notifyTimer: ReturnType<typeof setTimeout> | null = null;
152
+ private refreshing = new Set<string>();
153
+ private autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
141
154
 
142
- constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
155
+ private cwd: string;
156
+
157
+ constructor(tui: TUI, theme: PiTheme, registry: any, onDone: () => void, cwd: string) {
143
158
  super();
144
159
  this.tui = tui;
145
160
  this.theme = theme;
146
161
  this.done = onDone;
162
+ this.registry = registry;
163
+ this.cwd = cwd;
147
164
 
148
165
  this.addChild(new DynamicBorder(theme));
149
166
  this.addChild(new Spacer(1));
150
167
 
151
- this.textComponent = new Text("", 1, 0);
152
- this.addChild(this.textComponent);
168
+ this.linesComponent = new Text("", 1, 0);
169
+ this.addChild(this.linesComponent);
153
170
 
154
171
  this.addChild(new Spacer(1));
155
- this.addChild(new Text(this.theme.fg("dim", "Press q to close."), 1, 0));
172
+
173
+ this.notifyComponent = new Text("", 1, 0);
174
+ this.addChild(this.notifyComponent);
175
+
176
+ this.hintComponent = new Text("", 1, 0);
177
+ this.addChild(this.hintComponent);
178
+
156
179
  this.addChild(new Spacer(1));
157
180
  this.addChild(new DynamicBorder(theme));
158
181
 
159
- this.refresh();
160
-
161
- this.timer = setInterval(() => {
162
- this.refresh();
163
- const allSettled = getMcpStatus().every((s) => s.state !== "connecting");
164
- if (allSettled && this.timer) {
165
- clearInterval(this.timer);
166
- this.timer = null;
182
+ this.updateServers();
183
+ this.renderView();
184
+
185
+ // Auto-refresh while any server is still connecting
186
+ this.autoRefreshTimer = setInterval(() => {
187
+ this.updateServers();
188
+ this.renderView();
189
+ const allSettled = this.servers.every((s) => s.state !== "connecting");
190
+ if (allSettled && this.autoRefreshTimer) {
191
+ clearInterval(this.autoRefreshTimer);
192
+ this.autoRefreshTimer = null;
167
193
  }
168
194
  }, 500);
169
195
  }
170
196
 
171
- private refresh() {
172
- const servers = getMcpStatus();
197
+ private updateServers() {
198
+ this.servers = getMcpStatus();
199
+ if (this.selected >= this.servers.length) {
200
+ this.selected = Math.max(0, this.servers.length - 1);
201
+ }
202
+ }
173
203
 
174
- if (servers.length === 0) {
175
- this.textComponent.setText("No MCP servers configured.");
204
+ private renderView() {
205
+ if (this.servers.length === 0) {
206
+ this.linesComponent.setText("No MCP servers configured.");
207
+ this.hintComponent.setText(this.theme.fg("dim", "q close"));
176
208
  this.tui.requestRender();
177
209
  return;
178
210
  }
179
211
 
180
- const connected = servers.filter((s) => s.state === "connected");
181
- const connecting = servers.filter((s) => s.state === "connecting");
182
- const failed = servers.filter((s) => s.state === "failed");
183
-
184
- const lines: string[] = [
185
- `MCP servers (${servers.length}):`,
186
- "",
187
- ];
188
-
189
- for (const s of connected) {
190
- lines.push(this.theme.fg("accent", `• ${s.name}`) + ` (${s.source})`);
191
- lines.push(` URL: ${s.url}`);
192
- lines.push(` Tools: ${s.toolCount}`);
193
- for (const tool of s.tools) {
194
- const desc = tool.description ? ` — ${tool.description.slice(0, 60)}` : "";
195
- lines.push(` - ${tool.name}${desc}`);
212
+ const lines: string[] = [`MCP servers (${this.servers.length}):`, ""];
213
+
214
+ const namePad = Math.max(...this.servers.map((s) => s.name.length), 12);
215
+
216
+ for (let i = 0; i < this.servers.length; i++) {
217
+ const s = this.servers[i];
218
+ const isSelected = i === this.selected;
219
+ const isDisabled = s.state === "disabled";
220
+ const cursor = isSelected ? this.theme.fg("accent", "→ ") : " ";
221
+
222
+ let statusIcon: string;
223
+ let statusColor: (s: string) => string;
224
+ if (s.state === "connected") {
225
+ statusIcon = "🟢";
226
+ statusColor = (str: string) => this.theme.fg("accent", str);
227
+ } else if (s.state === "connecting") {
228
+ statusIcon = "🟡";
229
+ statusColor = (str: string) => this.theme.fg("warning", str);
230
+ } else if (s.state === "disabled") {
231
+ statusIcon = "⚪";
232
+ statusColor = (str: string) => this.theme.fg("dim", str);
233
+ } else {
234
+ statusIcon = "🔴";
235
+ statusColor = (str: string) => this.theme.fg("error", str);
196
236
  }
197
- lines.push("");
198
- }
199
237
 
200
- for (const s of connecting) {
201
- lines.push(this.theme.fg("accent", `• ${s.name}`) + ` (${s.source})`);
202
- lines.push(` URL: ${s.url}`);
203
- lines.push(` Status: ${this.theme.fg("warning", "connecting...")}`);
204
- lines.push("");
205
- }
238
+ const name = isDisabled
239
+ ? this.theme.fg("dim", s.name)
240
+ : isSelected
241
+ ? this.theme.fg("accent", s.name)
242
+ : s.name;
243
+ const namePadding = " ".repeat(Math.max(0, namePad - s.name.length));
244
+ const desc = s.description ? ` — ${s.description.slice(0, 50)}` : "";
245
+ const descDim = isDisabled ? this.theme.fg("dim", desc) : desc;
246
+ lines.push(
247
+ `${cursor}${name}${namePadding} ${statusIcon} ${statusColor(s.state)}${descDim}`
248
+ );
206
249
 
207
- for (const s of failed) {
208
- lines.push(this.theme.fg("accent", `• ${s.name}`) + ` (${s.source})`);
209
- lines.push(` URL: ${s.url}`);
210
- lines.push(` Status: ${this.theme.fg("error", "failed")} ${s.error ?? "unknown error"}`);
211
- lines.push("");
250
+ if (isSelected) {
251
+ lines.push(` ${this.theme.fg("dim", s.url)}`);
252
+ if (s.error) {
253
+ lines.push(` ${this.theme.fg("error", `Error: ${s.error}`)}`);
254
+ }
255
+ if (s.tools.length > 0) {
256
+ lines.push(` ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}:`);
257
+ for (const tool of s.tools.slice(0, 6)) {
258
+ const td = tool.description
259
+ ? ` — ${tool.description.slice(0, 55)}`
260
+ : "";
261
+ lines.push(` ${tool.name}${td}`);
262
+ }
263
+ if (s.tools.length > 6) {
264
+ lines.push(` ... and ${s.tools.length - 6} more`);
265
+ }
266
+ }
267
+ lines.push("");
268
+ }
212
269
  }
213
270
 
214
- this.textComponent.setText(lines.join("\n"));
271
+ this.linesComponent.setText(lines.join("\n"));
272
+
273
+ const s = this.servers[this.selected];
274
+ const toggleHint = s?.state === "disabled" ? "space enable" : "space disable";
275
+ const hintParts = ["↑↓ navigate", toggleHint, "r refresh", "q close"];
276
+ this.hintComponent.setText(this.theme.fg("dim", hintParts.join(" | ")));
277
+ this.notifyComponent.setText(
278
+ this.notifyText ? this.theme.fg("warning", this.notifyText) : ""
279
+ );
280
+
215
281
  this.tui.requestRender();
216
282
  }
217
283
 
284
+ private showNotify(text: string) {
285
+ this.notifyText = text;
286
+ this.renderView();
287
+ if (this.notifyTimer) clearTimeout(this.notifyTimer);
288
+ this.notifyTimer = setTimeout(() => {
289
+ this.notifyText = "";
290
+ this.renderView();
291
+ }, 3000);
292
+ }
293
+
294
+ private clearNotify() {
295
+ if (this.notifyTimer) {
296
+ clearTimeout(this.notifyTimer);
297
+ this.notifyTimer = null;
298
+ }
299
+ this.notifyText = "";
300
+ }
301
+
218
302
  handleInput(data: string) {
219
303
  const kb = getKeybindings();
304
+
305
+ // Navigation
306
+ if (kb.matches(data, "tui.select.up")) {
307
+ this.selected = Math.max(0, this.selected - 1);
308
+ this.clearNotify();
309
+ this.renderView();
310
+ return;
311
+ }
312
+ if (kb.matches(data, "tui.select.down")) {
313
+ this.selected = Math.min(this.servers.length - 1, this.selected + 1);
314
+ this.clearNotify();
315
+ this.renderView();
316
+ return;
317
+ }
318
+
319
+ // Quit
220
320
  if (
221
321
  data === "q" ||
222
322
  data === "\r" ||
223
323
  data === "\n" ||
224
324
  kb.matches(data, "tui.select.cancel")
225
325
  ) {
226
- if (this.timer) {
227
- clearInterval(this.timer);
228
- this.timer = null;
229
- }
326
+ if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; }
327
+ if (this.notifyTimer) { clearTimeout(this.notifyTimer); this.notifyTimer = null; }
230
328
  this.done();
329
+ return;
330
+ }
331
+
332
+ if (this.servers.length === 0) return;
333
+ const s = this.servers[this.selected];
334
+
335
+ // Toggle enable/disable
336
+ if (data === " ") {
337
+ const scope = s.source === "project" ? "project" : "global";
338
+ const newEnabled = s.state === "disabled";
339
+ const ok = toggleMcpServerEnabled(s.name, newEnabled, scope, this.cwd || undefined);
340
+ if (ok) {
341
+ updateConfigEnabled(s.name, newEnabled);
342
+ }
343
+ this.showNotify(
344
+ ok
345
+ ? `${newEnabled ? "Enabled" : "Disabled"} "${s.name}". Use /reload to apply.`
346
+ : `Failed to toggle "${s.name}".`
347
+ );
348
+ this.updateServers();
349
+ this.renderView();
350
+ return;
351
+ }
352
+
353
+ // Refresh cache (reconnect + update)
354
+ if (data === "r" || data === "r") {
355
+ if (this.refreshing.has(s.name)) return;
356
+ this.refreshing.add(s.name);
357
+ const targetName = s.name;
358
+ const targetIndex = this.selected;
359
+ this.showNotify(`Refreshing "${targetName}"...`);
360
+ (async () => {
361
+ const result = await refreshServerCache(targetName, this.registry);
362
+ this.refreshing.delete(targetName);
363
+ // Only update UI if user hasn't navigated away
364
+ if (this.selected === targetIndex) {
365
+ this.updateServers();
366
+ this.renderView();
367
+ this.showNotify(result.ok ? `Refreshed "${targetName}".` : `Refresh failed: ${result.error}`);
368
+ }
369
+ })();
370
+ return;
231
371
  }
372
+
373
+
232
374
  }
233
375
 
234
376
  dispose() {
235
- if (this.timer) {
236
- clearInterval(this.timer);
237
- this.timer = null;
238
- }
377
+ if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; }
378
+ if (this.notifyTimer) { clearTimeout(this.notifyTimer); this.notifyTimer = null; }
239
379
  }
240
380
  }
241
381
 
@@ -245,7 +385,7 @@ function setupMcpCommand(pi: ExtensionAPI) {
245
385
  handler: async (_args, ctx) => {
246
386
  if (ctx.hasUI) {
247
387
  await ctx.ui.custom<void>(
248
- (tui, theme, _kb, done) => new McpStatusComponent(tui, theme, () => done(undefined))
388
+ (tui, theme, _kb, done) => new McpStatusComponent(tui, theme, ctx.modelRegistry, () => done(undefined), ctx.cwd)
249
389
  );
250
390
  return;
251
391
  }
@@ -283,7 +423,6 @@ function setupMcpCommand(pi: ExtensionAPI) {
283
423
  // ─── /retry ────────────────────────────────────────────────────────────────
284
424
 
285
425
  function setupRetryCommand(pi: ExtensionAPI) {
286
- let shouldInjectRetryNote = false;
287
426
  let retryInProgress = false;
288
427
 
289
428
  pi.registerCommand("retry", {
@@ -296,7 +435,6 @@ function setupRetryCommand(pi: ExtensionAPI) {
296
435
  if (!ctx.isIdle()) ctx.abort();
297
436
 
298
437
  retryInProgress = true;
299
- shouldInjectRetryNote = true;
300
438
  pi.sendMessage(
301
439
  { customType: "retry-trigger", content: "Continue.", display: false },
302
440
  { triggerTurn: true }
@@ -304,12 +442,6 @@ function setupRetryCommand(pi: ExtensionAPI) {
304
442
  },
305
443
  });
306
444
 
307
- pi.on("before_agent_start", async (event) => {
308
- if (!shouldInjectRetryNote) return;
309
- shouldInjectRetryNote = false;
310
- return { systemPrompt: event.systemPrompt + "\n\nThe previous turn was interrupted by the system." };
311
- });
312
-
313
445
  pi.on("agent_start", () => { retryInProgress = false; });
314
446
  }
315
447
 
@@ -34,6 +34,7 @@ import { spawnSync } from "child_process";
34
34
  import { existsSync } from "node:fs";
35
35
  import { join } from "node:path";
36
36
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
37
+ import type { DependencyStatus } from "./rtk";
37
38
 
38
39
  // ═══════════════════════════════════════════════════════════
39
40
  // 类型
@@ -118,6 +119,31 @@ function computePenalty(filePath: string, isDir: boolean, gitIgnored: boolean):
118
119
 
119
120
  const SPAWN_OPTS = { timeout: 5000, encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024 };
120
121
 
122
+ function isGitWorkTree(cwd: string): boolean {
123
+ return spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { ...SPAWN_OPTS, cwd }).status === 0;
124
+ }
125
+
126
+ function commandExists(command: string): boolean {
127
+ const result = spawnSync(
128
+ process.platform === "win32" ? "where" : (process.env.SHELL || "sh"),
129
+ process.platform === "win32" ? [command] : ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`],
130
+ SPAWN_OPTS,
131
+ );
132
+ return result.status === 0;
133
+ }
134
+
135
+ export function getSmartAtDependencyStatuses(cwd: string): DependencyStatus[] {
136
+ const isGit = isGitWorkTree(cwd);
137
+ return [{
138
+ module: "smart-at",
139
+ label: "fd",
140
+ state: commandExists("fd") ? "ok" : (isGit ? "n/a" : "missing"),
141
+ detail: isGit
142
+ ? "Only needed outside Git repositories."
143
+ : "Install fd for non-Git project file discovery.",
144
+ }];
145
+ }
146
+
121
147
  function collectCandidates(cwd: string): FileCandidate[] {
122
148
  const candidates: FileCandidate[] = [];
123
149
 
@@ -133,7 +159,7 @@ function collectCandidates(cwd: string): FileCandidate[] {
133
159
  const opts = { ...SPAWN_OPTS, cwd };
134
160
 
135
161
  // ── 检测是否 git 仓库 ──
136
- const isGit = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], opts).status === 0;
162
+ const isGit = isGitWorkTree(cwd);
137
163
 
138
164
  if (isGit) {
139
165
  collectGit(candidates, opts, isChildOfHardExclude);