@docyrus/docyrus 0.0.20 → 0.0.22

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.
Files changed (76) hide show
  1. package/agent-loader.js +32 -1
  2. package/agent-loader.js.map +2 -2
  3. package/main.js +325 -71
  4. package/main.js.map +4 -4
  5. package/package.json +16 -3
  6. package/resources/chrome-tools/browser-content.js +103 -0
  7. package/resources/chrome-tools/browser-cookies.js +35 -0
  8. package/resources/chrome-tools/browser-eval.js +53 -0
  9. package/resources/chrome-tools/browser-hn-scraper.js +108 -0
  10. package/resources/chrome-tools/browser-nav.js +44 -0
  11. package/resources/chrome-tools/browser-pick.js +162 -0
  12. package/resources/chrome-tools/browser-screenshot.js +34 -0
  13. package/resources/chrome-tools/browser-start.js +86 -0
  14. package/resources/pi-agent/extensions/answer.ts +532 -0
  15. package/resources/pi-agent/extensions/context.ts +578 -0
  16. package/resources/pi-agent/extensions/control.ts +1779 -0
  17. package/resources/pi-agent/extensions/diff.ts +218 -0
  18. package/resources/pi-agent/extensions/files.ts +199 -0
  19. package/resources/pi-agent/extensions/loop.ts +446 -0
  20. package/resources/pi-agent/extensions/multi-edit.ts +835 -0
  21. package/resources/pi-agent/extensions/notify.ts +88 -0
  22. package/resources/pi-agent/extensions/pi-bash-live-view/LICENSE +21 -0
  23. package/resources/pi-agent/extensions/pi-bash-live-view/README.md +19 -0
  24. package/resources/pi-agent/extensions/pi-bash-live-view/index.ts +52 -0
  25. package/resources/pi-agent/extensions/pi-bash-live-view/package.json +61 -0
  26. package/resources/pi-agent/extensions/pi-bash-live-view/pty-execute.ts +97 -0
  27. package/resources/pi-agent/extensions/pi-bash-live-view/pty-kill.ts +25 -0
  28. package/resources/pi-agent/extensions/pi-bash-live-view/pty-session.ts +143 -0
  29. package/resources/pi-agent/extensions/pi-bash-live-view/spawn-helper.ts +31 -0
  30. package/resources/pi-agent/extensions/pi-bash-live-view/terminal-emulator.ts +439 -0
  31. package/resources/pi-agent/extensions/pi-bash-live-view/truncate.ts +68 -0
  32. package/resources/pi-agent/extensions/pi-bash-live-view/widget.ts +114 -0
  33. package/resources/pi-agent/extensions/pi-mcp-adapter/CHANGELOG.md +192 -0
  34. package/resources/pi-agent/extensions/pi-mcp-adapter/LICENSE +21 -0
  35. package/resources/pi-agent/extensions/pi-mcp-adapter/README.md +296 -0
  36. package/resources/pi-agent/extensions/pi-mcp-adapter/app-bridge.bundle.js +67 -0
  37. package/resources/pi-agent/extensions/pi-mcp-adapter/cli.js +108 -0
  38. package/resources/pi-agent/extensions/pi-mcp-adapter/commands.ts +211 -0
  39. package/resources/pi-agent/extensions/pi-mcp-adapter/config.ts +227 -0
  40. package/resources/pi-agent/extensions/pi-mcp-adapter/consent-manager.ts +64 -0
  41. package/resources/pi-agent/extensions/pi-mcp-adapter/direct-tools.ts +301 -0
  42. package/resources/pi-agent/extensions/pi-mcp-adapter/errors.ts +219 -0
  43. package/resources/pi-agent/extensions/pi-mcp-adapter/glimpse-ui.ts +80 -0
  44. package/resources/pi-agent/extensions/pi-mcp-adapter/host-html-template.ts +427 -0
  45. package/resources/pi-agent/extensions/pi-mcp-adapter/index.ts +232 -0
  46. package/resources/pi-agent/extensions/pi-mcp-adapter/init.ts +319 -0
  47. package/resources/pi-agent/extensions/pi-mcp-adapter/lifecycle.ts +93 -0
  48. package/resources/pi-agent/extensions/pi-mcp-adapter/logger.ts +169 -0
  49. package/resources/pi-agent/extensions/pi-mcp-adapter/mcp-panel.ts +713 -0
  50. package/resources/pi-agent/extensions/pi-mcp-adapter/metadata-cache.ts +191 -0
  51. package/resources/pi-agent/extensions/pi-mcp-adapter/npx-resolver.ts +419 -0
  52. package/resources/pi-agent/extensions/pi-mcp-adapter/oauth-handler.ts +56 -0
  53. package/resources/pi-agent/extensions/pi-mcp-adapter/package.json +85 -0
  54. package/resources/pi-agent/extensions/pi-mcp-adapter/paths.ts +29 -0
  55. package/resources/pi-agent/extensions/pi-mcp-adapter/proxy-modes.ts +635 -0
  56. package/resources/pi-agent/extensions/pi-mcp-adapter/resource-tools.ts +17 -0
  57. package/resources/pi-agent/extensions/pi-mcp-adapter/server-manager.ts +330 -0
  58. package/resources/pi-agent/extensions/pi-mcp-adapter/state.ts +41 -0
  59. package/resources/pi-agent/extensions/pi-mcp-adapter/tool-metadata.ts +144 -0
  60. package/resources/pi-agent/extensions/pi-mcp-adapter/tool-registrar.ts +46 -0
  61. package/resources/pi-agent/extensions/pi-mcp-adapter/types.ts +367 -0
  62. package/resources/pi-agent/extensions/pi-mcp-adapter/ui-resource-handler.ts +145 -0
  63. package/resources/pi-agent/extensions/pi-mcp-adapter/ui-server.ts +623 -0
  64. package/resources/pi-agent/extensions/pi-mcp-adapter/ui-session.ts +384 -0
  65. package/resources/pi-agent/extensions/pi-mcp-adapter/ui-stream-types.ts +89 -0
  66. package/resources/pi-agent/extensions/pi-mcp-adapter/utils.ts +75 -0
  67. package/resources/pi-agent/extensions/prompt-editor.ts +1315 -0
  68. package/resources/pi-agent/extensions/prompt-url-widget.ts +158 -0
  69. package/resources/pi-agent/extensions/redraws.ts +24 -0
  70. package/resources/pi-agent/extensions/review.ts +2160 -0
  71. package/resources/pi-agent/extensions/todos.ts +2076 -0
  72. package/resources/pi-agent/extensions/tps.ts +47 -0
  73. package/resources/pi-agent/extensions/whimsical.ts +474 -0
  74. package/resources/pi-agent/skills/changelog-generator/SKILL.md +425 -0
  75. package/resources/pi-agent/skills/docyrus-chrome-devtools-cli/SKILL.md +80 -0
  76. package/resources/pi-agent/skills/docyrus-platform/references/docyrus-cli-usage.md +51 -0
@@ -0,0 +1,1315 @@
1
+ import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ThinkingLevel } from "@mariozechner/pi-coding-agent";
2
+ import { CustomEditor, ModelSelectorComponent, SettingsManager } from "@mariozechner/pi-coding-agent";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import fs from "node:fs/promises";
6
+ import type { Dirent } from "node:fs";
7
+
8
+ // =============================================================================
9
+ // Modes
10
+ // =============================================================================
11
+
12
+ type ModeName = string;
13
+
14
+ type ModeSpec = {
15
+ provider?: string;
16
+ modelId?: string;
17
+ thinkingLevel?: ThinkingLevel;
18
+ /**
19
+ * Optional theme color token to use for the editor border.
20
+ * If unset, the border color is derived from the (current) thinking level.
21
+ */
22
+ color?: string;
23
+ };
24
+
25
+ type ModesFile = {
26
+ version: 1;
27
+ currentMode: ModeName;
28
+ modes: Record<ModeName, ModeSpec>;
29
+ };
30
+
31
+ // Only "default" is a forced/built-in mode. Others are just initial suggestions and can be renamed/deleted.
32
+ const DEFAULT_MODE_ORDER = ["default"] as const;
33
+ const CUSTOM_MODE_NAME = "custom" as const;
34
+
35
+ function expandUserPath(p: string): string {
36
+ if (p === "~") return os.homedir();
37
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
38
+ return p;
39
+ }
40
+
41
+ function getGlobalAgentDir(): string {
42
+ // Mirror pi-coding-agent's getAgentDir() behavior (best-effort).
43
+ // For the canonical implementation see pi-mono/packages/coding-agent/src/config.ts
44
+ const env = process.env.PI_CODING_AGENT_DIR;
45
+ if (env) return expandUserPath(env);
46
+ return path.join(os.homedir(), ".pi", "agent");
47
+ }
48
+
49
+ function getGlobalModesPath(): string {
50
+ return path.join(getGlobalAgentDir(), "modes.json");
51
+ }
52
+
53
+ function getProjectModesPath(cwd: string): string {
54
+ return path.join(cwd, ".pi", "modes.json");
55
+ }
56
+
57
+ async function fileExists(p: string): Promise<boolean> {
58
+ try {
59
+ await fs.stat(p);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ async function ensureDirForFile(filePath: string): Promise<void> {
67
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
68
+ }
69
+
70
+ async function getMtimeMs(p: string): Promise<number | null> {
71
+ try {
72
+ const st = await fs.stat(p);
73
+ return st.mtimeMs;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function sleep(ms: number): Promise<void> {
80
+ return new Promise((resolve) => setTimeout(resolve, ms));
81
+ }
82
+
83
+ function getLockPathForFile(filePath: string): string {
84
+ // Lock file next to the json so it works across processes.
85
+ return `${filePath}.lock`;
86
+ }
87
+
88
+ async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
89
+ const lockPath = getLockPathForFile(filePath);
90
+ await ensureDirForFile(lockPath);
91
+
92
+ const start = Date.now();
93
+ while (true) {
94
+ try {
95
+ const handle = await fs.open(lockPath, "wx");
96
+ try {
97
+ // Best-effort metadata for debugging stale locks.
98
+ await handle.writeFile(
99
+ JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + "\n",
100
+ "utf8"
101
+ );
102
+ } catch {
103
+ // ignore
104
+ }
105
+
106
+ try {
107
+ return await fn();
108
+ } finally {
109
+ await handle.close().catch(() => {});
110
+ await fs.unlink(lockPath).catch(() => {});
111
+ }
112
+ } catch (err: any) {
113
+ if (err?.code !== "EEXIST") throw err;
114
+
115
+ // If the lock looks stale (crash), break it.
116
+ try {
117
+ const st = await fs.stat(lockPath);
118
+ if (Date.now() - st.mtimeMs > 30_000) {
119
+ await fs.unlink(lockPath);
120
+ continue;
121
+ }
122
+ } catch {
123
+ // ignore
124
+ }
125
+
126
+ if (Date.now() - start > 5_000) {
127
+ // Don't hang the UI forever.
128
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
129
+ }
130
+ await sleep(40 + Math.random() * 80);
131
+ }
132
+ }
133
+ }
134
+
135
+ async function atomicWriteUtf8(filePath: string, content: string): Promise<void> {
136
+ await ensureDirForFile(filePath);
137
+
138
+ const dir = path.dirname(filePath);
139
+ const base = path.basename(filePath);
140
+ const tmpPath = path.join(dir, `.${base}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`);
141
+
142
+ await fs.writeFile(tmpPath, content, "utf8");
143
+
144
+ try {
145
+ // POSIX: atomic replace.
146
+ await fs.rename(tmpPath, filePath);
147
+ } catch (err: any) {
148
+ // Windows: rename can't overwrite.
149
+ if (err?.code === "EEXIST" || err?.code === "EPERM") {
150
+ await fs.unlink(filePath).catch(() => {});
151
+ await fs.rename(tmpPath, filePath);
152
+ } else {
153
+ // best-effort cleanup
154
+ await fs.unlink(tmpPath).catch(() => {});
155
+ throw err;
156
+ }
157
+ }
158
+ }
159
+
160
+ function cloneModesFile(file: ModesFile): ModesFile {
161
+ // JSON-based clone is fine here (small, plain data structure).
162
+ return JSON.parse(JSON.stringify(file)) as ModesFile;
163
+ }
164
+
165
+ type ModeSpecPatch = {
166
+ provider?: string | null;
167
+ modelId?: string | null;
168
+ thinkingLevel?: ThinkingLevel | null;
169
+ color?: string | null;
170
+ };
171
+
172
+ type ModesPatch = {
173
+ currentMode?: ModeName;
174
+ modes?: Record<ModeName, ModeSpecPatch | null>;
175
+ };
176
+
177
+ function computeModesPatch(base: ModesFile, next: ModesFile, includeCurrentMode: boolean): ModesPatch | null {
178
+ const patch: ModesPatch = {};
179
+
180
+ if (includeCurrentMode && base.currentMode !== next.currentMode) {
181
+ patch.currentMode = next.currentMode;
182
+ }
183
+
184
+ const keys = new Set([...Object.keys(base.modes), ...Object.keys(next.modes)]);
185
+ const modesPatch: Record<ModeName, ModeSpecPatch | null> = {};
186
+
187
+ for (const k of keys) {
188
+ const a = base.modes[k];
189
+ const b = next.modes[k];
190
+
191
+ if (!b) {
192
+ if (a) modesPatch[k] = null;
193
+ continue;
194
+ }
195
+ if (!a) {
196
+ modesPatch[k] = { ...b };
197
+ continue;
198
+ }
199
+
200
+ const diff: ModeSpecPatch = {};
201
+ const fields: (keyof ModeSpec)[] = ["provider", "modelId", "thinkingLevel", "color"];
202
+ for (const f of fields) {
203
+ const av = a[f];
204
+ const bv = b[f];
205
+ if (av !== bv) {
206
+ (diff as any)[f] = bv === undefined ? null : bv;
207
+ }
208
+ }
209
+ if (Object.keys(diff).length > 0) {
210
+ modesPatch[k] = diff;
211
+ }
212
+ }
213
+
214
+ if (Object.keys(modesPatch).length > 0) {
215
+ patch.modes = modesPatch;
216
+ }
217
+
218
+ if (!patch.modes && patch.currentMode === undefined) return null;
219
+ return patch;
220
+ }
221
+
222
+ function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
223
+ if (patch.currentMode !== undefined) {
224
+ target.currentMode = patch.currentMode;
225
+ }
226
+
227
+ if (!patch.modes) return;
228
+ for (const [mode, specPatch] of Object.entries(patch.modes)) {
229
+ if (specPatch === null) {
230
+ delete target.modes[mode];
231
+ continue;
232
+ }
233
+
234
+ const targetSpec: Record<string, unknown> = ((target.modes[mode] ??= {}) as any) ?? {};
235
+ for (const [k, v] of Object.entries(specPatch)) {
236
+ if (v === null || v === undefined) {
237
+ delete targetSpec[k];
238
+ } else {
239
+ targetSpec[k] = v;
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ function normalizeThinkingLevel(level: unknown): ThinkingLevel | undefined {
246
+ if (typeof level !== "string") return undefined;
247
+ const v = level as ThinkingLevel;
248
+ // Keep the list local to avoid importing internal enums.
249
+ const allowed: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
250
+ return allowed.includes(v) ? v : undefined;
251
+ }
252
+
253
+ function sanitizeModeSpec(spec: unknown): ModeSpec {
254
+ const obj = (spec && typeof spec === "object" ? spec : {}) as Record<string, unknown>;
255
+ return {
256
+ provider: typeof obj.provider === "string" ? obj.provider : undefined,
257
+ modelId: typeof obj.modelId === "string" ? obj.modelId : undefined,
258
+ thinkingLevel: normalizeThinkingLevel(obj.thinkingLevel),
259
+ color: typeof obj.color === "string" ? obj.color : undefined,
260
+ };
261
+ }
262
+
263
+ function createDefaultModes(ctx: ExtensionContext, pi: ExtensionAPI): ModesFile {
264
+ const currentModel = ctx.model;
265
+ const currentThinking = pi.getThinkingLevel();
266
+
267
+ const base: ModeSpec = {
268
+ provider: currentModel?.provider,
269
+ modelId: currentModel?.id,
270
+ thinkingLevel: currentThinking,
271
+ };
272
+
273
+ return {
274
+ version: 1,
275
+ currentMode: "default",
276
+ modes: {
277
+ // Forced default mode
278
+ default: { ...base },
279
+ // Convenience mode (user can delete/rename)
280
+ fast: { ...base, thinkingLevel: "off" },
281
+ },
282
+ };
283
+ }
284
+
285
+ function ensureDefaultModeEntries(file: ModesFile, ctx: ExtensionContext, pi: ExtensionAPI): void {
286
+ for (const name of DEFAULT_MODE_ORDER) {
287
+ if (!file.modes[name]) {
288
+ const defaults = createDefaultModes(ctx, pi);
289
+ file.modes[name] = defaults.modes[name];
290
+ }
291
+ }
292
+
293
+ // "custom" is an overlay mode; never treat it as a valid persisted current mode.
294
+ if (file.currentMode === CUSTOM_MODE_NAME) {
295
+ file.currentMode = "" as any;
296
+ }
297
+
298
+ if (!file.currentMode || !(file.currentMode in file.modes) || file.currentMode === CUSTOM_MODE_NAME) {
299
+ const first = Object.keys(file.modes).find((k) => k !== CUSTOM_MODE_NAME);
300
+ file.currentMode = file.modes.default ? "default" : first || "default";
301
+ }
302
+ }
303
+
304
+ async function loadModesFile(filePath: string, ctx: ExtensionContext, pi: ExtensionAPI): Promise<ModesFile> {
305
+ try {
306
+ const raw = await fs.readFile(filePath, "utf8");
307
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
308
+ const currentMode = typeof parsed.currentMode === "string" ? parsed.currentMode : "default";
309
+ const modesRaw = parsed.modes && typeof parsed.modes === "object" ? (parsed.modes as Record<string, unknown>) : {};
310
+ const modes: Record<string, ModeSpec> = {};
311
+ for (const [k, v] of Object.entries(modesRaw)) {
312
+ modes[k] = sanitizeModeSpec(v);
313
+ }
314
+ const file: ModesFile = {
315
+ version: 1,
316
+ currentMode,
317
+ modes,
318
+ };
319
+ ensureDefaultModeEntries(file, ctx, pi);
320
+ return file;
321
+ } catch {
322
+ return createDefaultModes(ctx, pi);
323
+ }
324
+ }
325
+
326
+ async function saveModesFile(filePath: string, data: ModesFile): Promise<void> {
327
+ await atomicWriteUtf8(filePath, JSON.stringify(data, null, 2) + "\n");
328
+ }
329
+
330
+ function orderedModeNames(modes: Record<string, ModeSpec>): string[] {
331
+ // Preserve insertion order from the JSON file.
332
+ // Object key iteration order is stable in modern JS runtimes.
333
+ // NOTE: "custom" is an overlay mode and must not be selectable/persisted.
334
+ return Object.keys(modes).filter((name) => name !== CUSTOM_MODE_NAME);
335
+ }
336
+
337
+ function getModeBorderColor(ctx: ExtensionContext, pi: ExtensionAPI, mode: string): (text: string) => string {
338
+ const theme = ctx.ui.theme;
339
+ const spec = runtime.data.modes[mode];
340
+
341
+ // Explicit color override in JSON.
342
+ if (spec?.color) {
343
+ try {
344
+ // Validate early so we don't crash during render.
345
+ theme.getFgAnsi(spec.color as any);
346
+ return (text: string) => theme.fg(spec.color as any, text);
347
+ } catch {
348
+ // fall through to thinking-based colors
349
+ }
350
+ }
351
+
352
+ // Default: derive from the current thinking level.
353
+ return theme.getThinkingBorderColor(pi.getThinkingLevel());
354
+ }
355
+
356
+ function formatModeLabel(mode: string): string {
357
+ return mode;
358
+ }
359
+
360
+ async function resolveModesPath(cwd: string): Promise<string> {
361
+ const projectPath = getProjectModesPath(cwd);
362
+ if (await fileExists(projectPath)) return projectPath;
363
+ return getGlobalModesPath();
364
+ }
365
+
366
+ function inferModeFromSelection(ctx: ExtensionContext, pi: ExtensionAPI, data: ModesFile): string | null {
367
+ const provider = ctx.model?.provider;
368
+ const modelId = ctx.model?.id;
369
+ const thinkingLevel = pi.getThinkingLevel();
370
+ if (!provider || !modelId) return null;
371
+
372
+ // Only consider persisted/real modes (exclude the overlay "custom").
373
+ const names = orderedModeNames(data.modes);
374
+
375
+ const supportsThinking = Boolean(ctx.model?.reasoning);
376
+
377
+ // 1) If thinking is supported, require an exact match so modes can differ by thinking level.
378
+ if (supportsThinking) {
379
+ for (const name of names) {
380
+ const spec = data.modes[name];
381
+ if (!spec) continue;
382
+ if (spec.provider !== provider || spec.modelId !== modelId) continue;
383
+ if ((spec.thinkingLevel ?? undefined) !== thinkingLevel) continue;
384
+ return name;
385
+ }
386
+ return null;
387
+ }
388
+
389
+ // 2) If thinking is NOT supported by the model, the effective level will always be "off".
390
+ // In that case, treat thinkingLevel differences in modes.json as non-distinguishing.
391
+ const candidates: string[] = [];
392
+ for (const name of names) {
393
+ const spec = data.modes[name];
394
+ if (!spec) continue;
395
+ if (spec.provider !== provider || spec.modelId !== modelId) continue;
396
+ candidates.push(name);
397
+ }
398
+ if (candidates.length === 0) return null;
399
+
400
+ // Prefer a candidate that explicitly matches the effective thinking level.
401
+ for (const name of candidates) {
402
+ const spec = data.modes[name];
403
+ if (!spec) continue;
404
+ if ((spec.thinkingLevel ?? "off") === thinkingLevel) return name;
405
+ }
406
+
407
+ // Next prefer a candidate with no thinkingLevel configured.
408
+ for (const name of candidates) {
409
+ const spec = data.modes[name];
410
+ if (!spec) continue;
411
+ if (!spec.thinkingLevel) return name;
412
+ }
413
+
414
+ return candidates[0] ?? null;
415
+ }
416
+
417
+ type ModeRuntime = {
418
+ filePath: string;
419
+ fileMtimeMs: number | null;
420
+ /**
421
+ * Snapshot of what we last loaded/synced from disk. Used to compute patches so
422
+ * multiple running pi processes don't clobber each other's mode edits.
423
+ */
424
+ baseline: ModesFile | null;
425
+ data: ModesFile;
426
+
427
+ /**
428
+ * Last non-overlay mode. Used as cycle base while in the overlay "custom" mode.
429
+ */
430
+ lastRealMode: string;
431
+
432
+ /**
433
+ * The effective current mode. Can temporarily be "custom" (overlay),
434
+ * which is *not* persisted and not selectable via /mode.
435
+ */
436
+ currentMode: string;
437
+ // guard against feedback loops when we switch model ourselves
438
+ applying: boolean;
439
+ };
440
+
441
+ const runtime: ModeRuntime = {
442
+ filePath: "",
443
+ fileMtimeMs: null,
444
+ baseline: null,
445
+ data: { version: 1, currentMode: "default", modes: {} },
446
+ lastRealMode: "default",
447
+ currentMode: "default",
448
+ applying: false,
449
+ };
450
+
451
+ // Updated by setEditor() when the custom editor is instantiated.
452
+ let requestEditorRender: (() => void) | undefined;
453
+
454
+ async function ensureRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
455
+ const filePath = await resolveModesPath(ctx.cwd);
456
+
457
+ const mtimeMs = await getMtimeMs(filePath);
458
+ const filePathChanged = runtime.filePath !== filePath;
459
+ const fileChanged = filePathChanged || runtime.fileMtimeMs !== mtimeMs;
460
+
461
+ if (fileChanged) {
462
+ runtime.filePath = filePath;
463
+ runtime.fileMtimeMs = mtimeMs;
464
+
465
+ const loaded = await loadModesFile(filePath, ctx, pi);
466
+ // Normalize/ensure defaults *before* we snapshot baseline so later persistence
467
+ // only reflects explicit user actions ("store").
468
+ ensureDefaultModeEntries(loaded, ctx, pi);
469
+ runtime.data = loaded;
470
+ runtime.baseline = cloneModesFile(runtime.data);
471
+
472
+ // Reset overlay when switching projects.
473
+ if (filePathChanged && runtime.currentMode !== CUSTOM_MODE_NAME) {
474
+ runtime.currentMode = runtime.data.currentMode;
475
+ runtime.lastRealMode = runtime.currentMode;
476
+ }
477
+ }
478
+
479
+ // If we're not in the overlay "custom" mode, ensure currentMode is valid.
480
+ if (runtime.currentMode !== CUSTOM_MODE_NAME) {
481
+ if (!runtime.currentMode || !(runtime.currentMode in runtime.data.modes)) {
482
+ runtime.currentMode = runtime.data.currentMode;
483
+ }
484
+ if (!runtime.lastRealMode || !(runtime.lastRealMode in runtime.data.modes)) {
485
+ runtime.lastRealMode = runtime.currentMode;
486
+ }
487
+ }
488
+ }
489
+
490
+ async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
491
+ if (!runtime.filePath) return;
492
+
493
+ // Do not persist currentMode; multiple running pi sessions would fight over it.
494
+ // Instead we infer the mode on startup from the active model + thinking level.
495
+ runtime.baseline ??= cloneModesFile(runtime.data);
496
+ const patch = computeModesPatch(runtime.baseline, runtime.data, false);
497
+ if (!patch) return;
498
+
499
+ await withFileLock(runtime.filePath, async () => {
500
+ // Merge our local patch into the latest on disk to avoid clobbering other agents.
501
+ const latest = await loadModesFile(runtime.filePath, ctx, pi);
502
+ applyModesPatch(latest, patch);
503
+ ensureDefaultModeEntries(latest, ctx, pi);
504
+ await saveModesFile(runtime.filePath, latest);
505
+
506
+ runtime.data = latest;
507
+ runtime.baseline = cloneModesFile(latest);
508
+ runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
509
+ });
510
+ }
511
+
512
+ // We cannot reliably read the *current* model immediately after pi.setModel() in the same tick,
513
+ // because ctx.model is a snapshot-ish view that is updated via the model_select event.
514
+ // Track the last observed model ourselves and use it for overlays / storing.
515
+ let lastObservedModel: { provider?: string; modelId?: string } = {};
516
+
517
+ function getCurrentSelectionSpec(pi: ExtensionAPI, _ctx: ExtensionContext): ModeSpec {
518
+ return {
519
+ provider: lastObservedModel.provider,
520
+ modelId: lastObservedModel.modelId,
521
+ thinkingLevel: pi.getThinkingLevel(),
522
+ };
523
+ }
524
+
525
+ async function storeSelectionIntoMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string, selection: ModeSpec): Promise<void> {
526
+ // "custom" is an overlay; it is not persisted.
527
+ if (mode === CUSTOM_MODE_NAME) return;
528
+
529
+ await ensureRuntime(pi, ctx);
530
+
531
+ const existingTarget = runtime.data.modes[mode] ?? {};
532
+ const next: ModeSpec = { ...existingTarget };
533
+
534
+ // Only overwrite fields that we can actually observe.
535
+ if (selection.provider && selection.modelId) {
536
+ next.provider = selection.provider;
537
+ next.modelId = selection.modelId;
538
+ }
539
+ if (selection.thinkingLevel) next.thinkingLevel = selection.thinkingLevel;
540
+
541
+ runtime.data.modes[mode] = next;
542
+ await persistRuntime(pi, ctx);
543
+ }
544
+
545
+ async function applyMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
546
+ await ensureRuntime(pi, ctx);
547
+
548
+ // "custom" is a runtime-only overlay mode.
549
+ if (mode === CUSTOM_MODE_NAME) {
550
+ runtime.currentMode = CUSTOM_MODE_NAME;
551
+ customOverlay = getCurrentSelectionSpec(pi, ctx);
552
+ if (ctx.hasUI) requestEditorRender?.();
553
+ return;
554
+ }
555
+
556
+ const spec = runtime.data.modes[mode];
557
+ if (!spec) {
558
+ if (ctx.hasUI) {
559
+ ctx.ui.notify(`Unknown mode: ${mode}`, "warning");
560
+ }
561
+ return;
562
+ }
563
+
564
+ runtime.currentMode = mode;
565
+ runtime.lastRealMode = mode;
566
+ customOverlay = null;
567
+
568
+ runtime.applying = true;
569
+ let modelAppliedOk = true;
570
+ try {
571
+ // Apply model
572
+ if (spec.provider && spec.modelId) {
573
+ const m = ctx.modelRegistry.find(spec.provider, spec.modelId);
574
+ if (m) {
575
+ const ok = await pi.setModel(m);
576
+ modelAppliedOk = ok;
577
+ if (!ok && ctx.hasUI) {
578
+ ctx.ui.notify(`No API key available for ${spec.provider}/${spec.modelId}`, "warning");
579
+ }
580
+ } else {
581
+ modelAppliedOk = false;
582
+ if (ctx.hasUI) {
583
+ ctx.ui.notify(`Mode "${mode}" references unknown model ${spec.provider}/${spec.modelId}`, "warning");
584
+ }
585
+ }
586
+ }
587
+
588
+ // Apply thinking level
589
+ if (spec.thinkingLevel) {
590
+ pi.setThinkingLevel(spec.thinkingLevel);
591
+ }
592
+ } finally {
593
+ runtime.applying = false;
594
+ }
595
+
596
+ // If we couldn't apply the requested model (e.g. missing API key), switch to overlay.
597
+ // We do *not* treat thinking-level clamping as a failure: clamping is expected when
598
+ // switching between models with different thinking capabilities.
599
+ if (!modelAppliedOk) {
600
+ runtime.currentMode = CUSTOM_MODE_NAME;
601
+ customOverlay = getCurrentSelectionSpec(pi, ctx);
602
+ }
603
+
604
+ if (ctx.hasUI) {
605
+ requestEditorRender?.();
606
+ }
607
+ }
608
+
609
+ const MODE_UI_CONFIGURE = "Configure modes…";
610
+ const MODE_UI_ADD = "Add mode…";
611
+ const MODE_UI_BACK = "Back";
612
+
613
+ const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
614
+ const THINKING_UNSET_LABEL = "(don't change)";
615
+
616
+ function isDefaultModeName(name: string): boolean {
617
+ return (DEFAULT_MODE_ORDER as readonly string[]).includes(name);
618
+ }
619
+
620
+ function isReservedModeName(name: string): boolean {
621
+ return name === CUSTOM_MODE_NAME || name === MODE_UI_CONFIGURE || name === MODE_UI_ADD || name === MODE_UI_BACK;
622
+ }
623
+
624
+ function normalizeModeNameInput(name: string | undefined): string {
625
+ return (name ?? "").trim();
626
+ }
627
+
628
+ function validateModeNameOrError(
629
+ name: string,
630
+ existing: Record<string, ModeSpec>,
631
+ opts?: { allowExisting?: boolean },
632
+ ): string | null {
633
+ if (!name) return "Mode name cannot be empty";
634
+ if (/\s/.test(name)) return "Mode name cannot contain whitespace";
635
+ if (isReservedModeName(name)) return `Mode name \"${name}\" is reserved`;
636
+ if (!opts?.allowExisting && existing[name]) return `Mode \"${name}\" already exists`;
637
+ return null;
638
+ }
639
+
640
+ async function handleModeChoiceUI(pi: ExtensionAPI, ctx: ExtensionContext, choice: string): Promise<void> {
641
+ // Special behavior: when we're in "custom" and select another mode,
642
+ // offer to either *use* it (switch) or *store* the current custom selection into it.
643
+ if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) {
644
+ const action = await ctx.ui.select(`Mode \"${choice}\"`, ["use", "store"]);
645
+ if (!action) return;
646
+
647
+ if (action === "use") {
648
+ await applyMode(pi, ctx, choice);
649
+ return;
650
+ }
651
+
652
+ // "store": overwrite target mode with the current overlay selection (keep target color if set)
653
+ await ensureRuntime(pi, ctx);
654
+ const overlay = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
655
+ await storeSelectionIntoMode(pi, ctx, choice, overlay);
656
+ await applyMode(pi, ctx, choice);
657
+ ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into \"${choice}\"`, "info");
658
+ return;
659
+ }
660
+
661
+ await applyMode(pi, ctx, choice);
662
+ }
663
+
664
+ async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
665
+ if (!ctx.hasUI) return;
666
+
667
+ while (true) {
668
+ await ensureRuntime(pi, ctx);
669
+ const names = orderedModeNames(runtime.data.modes);
670
+ const choice = await ctx.ui.select(`Mode (current: ${runtime.currentMode})`, [...names, MODE_UI_CONFIGURE]);
671
+ if (!choice) return;
672
+
673
+ if (choice === MODE_UI_CONFIGURE) {
674
+ await configureModesUI(pi, ctx);
675
+ continue;
676
+ }
677
+
678
+ await handleModeChoiceUI(pi, ctx, choice);
679
+ return;
680
+ }
681
+ }
682
+
683
+ async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
684
+ if (!ctx.hasUI) return;
685
+
686
+ while (true) {
687
+ await ensureRuntime(pi, ctx);
688
+ const names = orderedModeNames(runtime.data.modes);
689
+ const choice = await ctx.ui.select("Configure modes", [...names, MODE_UI_ADD, MODE_UI_BACK]);
690
+ if (!choice || choice === MODE_UI_BACK) return;
691
+
692
+ if (choice === MODE_UI_ADD) {
693
+ const created = await addModeUI(pi, ctx);
694
+ if (created) {
695
+ await editModeUI(pi, ctx, created);
696
+ }
697
+ continue;
698
+ }
699
+
700
+ await editModeUI(pi, ctx, choice);
701
+ }
702
+ }
703
+
704
+ async function addModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string | undefined> {
705
+ if (!ctx.hasUI) return undefined;
706
+ await ensureRuntime(pi, ctx);
707
+
708
+ while (true) {
709
+ const raw = await ctx.ui.input("New mode name", "e.g. docs, review, planning");
710
+ if (raw === undefined) return undefined;
711
+
712
+ const name = normalizeModeNameInput(raw);
713
+ const err = validateModeNameOrError(name, runtime.data.modes);
714
+ if (err) {
715
+ ctx.ui.notify(err, "warning");
716
+ continue;
717
+ }
718
+
719
+ // Default new modes to the current selection so they behave as expected immediately.
720
+ const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
721
+ runtime.data.modes[name] = {
722
+ provider: selection.provider,
723
+ modelId: selection.modelId,
724
+ thinkingLevel: selection.thinkingLevel,
725
+ };
726
+ await persistRuntime(pi, ctx);
727
+ ctx.ui.notify(`Added mode \"${name}\"`, "info");
728
+ return name;
729
+ }
730
+ }
731
+
732
+ async function editModeUI(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
733
+ if (!ctx.hasUI) return;
734
+
735
+ let modeName = mode;
736
+
737
+ while (true) {
738
+ await ensureRuntime(pi, ctx);
739
+ const spec = runtime.data.modes[modeName];
740
+ if (!spec) return;
741
+
742
+ const modelLabel = spec.provider && spec.modelId ? `${spec.provider}/${spec.modelId}` : "(no model)";
743
+ const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL;
744
+
745
+ const actions = ["Change name", "Change model", "Change thinking level"];
746
+ if (!isDefaultModeName(modeName)) actions.push("Delete mode");
747
+ actions.push(MODE_UI_BACK);
748
+
749
+ const action = await ctx.ui.select(
750
+ `Edit mode \"${modeName}\" model: ${modelLabel} thinking: ${thinkingLabel}`,
751
+ actions,
752
+ );
753
+ if (!action || action === MODE_UI_BACK) return;
754
+
755
+ if (action === "Change name") {
756
+ const renamed = await renameModeUI(pi, ctx, modeName);
757
+ if (renamed) modeName = renamed;
758
+ continue;
759
+ }
760
+
761
+ if (action === "Change model") {
762
+ const selected = await pickModelForModeUI(ctx, spec);
763
+ if (!selected) continue;
764
+ spec.provider = selected.provider;
765
+ spec.modelId = selected.modelId;
766
+ runtime.data.modes[modeName] = spec;
767
+ await persistRuntime(pi, ctx);
768
+ ctx.ui.notify(`Updated model for \"${modeName}\"`, "info");
769
+
770
+ if (runtime.currentMode === modeName) {
771
+ await applyMode(pi, ctx, modeName);
772
+ }
773
+ continue;
774
+ }
775
+
776
+ if (action === "Change thinking level") {
777
+ const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel);
778
+ if (level === undefined) continue;
779
+
780
+ if (level === null) {
781
+ delete spec.thinkingLevel;
782
+ } else {
783
+ spec.thinkingLevel = level;
784
+ }
785
+
786
+ runtime.data.modes[modeName] = spec;
787
+ await persistRuntime(pi, ctx);
788
+ ctx.ui.notify(`Updated thinking level for \"${modeName}\"`, "info");
789
+
790
+ if (runtime.currentMode === modeName) {
791
+ await applyMode(pi, ctx, modeName);
792
+ }
793
+ continue;
794
+ }
795
+
796
+ if (action === "Delete mode") {
797
+ const ok = await ctx.ui.confirm("Delete mode", `Delete mode \"${modeName}\"?`);
798
+ if (!ok) continue;
799
+
800
+ delete runtime.data.modes[modeName];
801
+ await persistRuntime(pi, ctx);
802
+
803
+ if (runtime.currentMode === modeName) {
804
+ runtime.currentMode = CUSTOM_MODE_NAME;
805
+ customOverlay = getCurrentSelectionSpec(pi, ctx);
806
+ }
807
+ if (runtime.lastRealMode === modeName) {
808
+ runtime.lastRealMode = "default";
809
+ }
810
+ requestEditorRender?.();
811
+ ctx.ui.notify(`Deleted mode \"${modeName}\"`, "info");
812
+ return;
813
+ }
814
+ }
815
+ }
816
+
817
+ function renameModesRecord(modes: Record<string, ModeSpec>, oldName: string, newName: string): Record<string, ModeSpec> {
818
+ const out: Record<string, ModeSpec> = {};
819
+ for (const [k, v] of Object.entries(modes)) {
820
+ if (k === oldName) out[newName] = v;
821
+ else out[k] = v;
822
+ }
823
+ return out;
824
+ }
825
+
826
+ async function renameModeUI(pi: ExtensionAPI, ctx: ExtensionContext, oldName: string): Promise<string | undefined> {
827
+ if (!ctx.hasUI) return undefined;
828
+
829
+ if (isDefaultModeName(oldName)) {
830
+ ctx.ui.notify(`Cannot rename default mode \"${oldName}\"`, "warning");
831
+ return oldName;
832
+ }
833
+
834
+ await ensureRuntime(pi, ctx);
835
+
836
+ while (true) {
837
+ const raw = await ctx.ui.input(`Rename mode \"${oldName}\"`, oldName);
838
+ if (raw === undefined) return undefined;
839
+
840
+ const newName = normalizeModeNameInput(raw);
841
+ if (!newName || newName === oldName) return oldName;
842
+
843
+ const err = validateModeNameOrError(newName, runtime.data.modes);
844
+ if (err) {
845
+ ctx.ui.notify(err, "warning");
846
+ continue;
847
+ }
848
+
849
+ runtime.data.modes = renameModesRecord(runtime.data.modes, oldName, newName);
850
+ await persistRuntime(pi, ctx);
851
+
852
+ if (runtime.currentMode === oldName) runtime.currentMode = newName;
853
+ if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName;
854
+ requestEditorRender?.();
855
+
856
+ ctx.ui.notify(`Renamed \"${oldName}\" → \"${newName}\"`, "info");
857
+ return newName;
858
+ }
859
+ }
860
+
861
+ async function pickModelForModeUI(
862
+ ctx: ExtensionContext,
863
+ spec: ModeSpec,
864
+ ): Promise<{ provider: string; modelId: string } | undefined> {
865
+ if (!ctx.hasUI) return undefined;
866
+
867
+ const settingsManager = SettingsManager.inMemory();
868
+ const currentModel = spec.provider && spec.modelId ? ctx.modelRegistry.find(spec.provider, spec.modelId) : ctx.model;
869
+
870
+ const scopedModels: Array<{ model: any; thinkingLevel: string }> = [];
871
+
872
+ return ctx.ui.custom<{ provider: string; modelId: string } | undefined>((tui, _theme, _keybindings, done) => {
873
+ const selector = new ModelSelectorComponent(
874
+ tui,
875
+ currentModel,
876
+ settingsManager,
877
+ ctx.modelRegistry as any,
878
+ scopedModels as any,
879
+ (model) => done({ provider: model.provider, modelId: model.id }),
880
+ () => done(undefined),
881
+ );
882
+ return selector;
883
+ });
884
+ }
885
+
886
+ async function pickThinkingLevelForModeUI(
887
+ ctx: ExtensionContext,
888
+ current: ThinkingLevel | undefined,
889
+ ): Promise<ThinkingLevel | null | undefined> {
890
+ if (!ctx.hasUI) return undefined;
891
+
892
+ const defaultValue = current ?? "off";
893
+ const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL];
894
+ // Prefer the current selection by ordering it first.
895
+ const ordered = [defaultValue, ...options.filter((x) => x !== defaultValue)];
896
+
897
+ const choice = await ctx.ui.select("Thinking level", ordered);
898
+ if (!choice) return undefined;
899
+ if (choice === THINKING_UNSET_LABEL) return null;
900
+ if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) return choice as ThinkingLevel;
901
+ return undefined;
902
+ }
903
+
904
+ async function cycleMode(pi: ExtensionAPI, ctx: ExtensionContext, direction: 1 | -1 = 1): Promise<void> {
905
+ if (!ctx.hasUI) return;
906
+ await ensureRuntime(pi, ctx);
907
+ const names = orderedModeNames(runtime.data.modes);
908
+ if (names.length === 0) return;
909
+
910
+ // If we're currently in the overlay mode, cycle relative to the last real mode.
911
+ const baseMode = runtime.currentMode === CUSTOM_MODE_NAME ? runtime.lastRealMode : runtime.currentMode;
912
+ const idx = Math.max(0, names.indexOf(baseMode));
913
+ const next = names[(idx + direction + names.length) % names.length] ?? names[0]!;
914
+ await applyMode(pi, ctx, next);
915
+ }
916
+
917
+ // =============================================================================
918
+ // Prompt history
919
+ // =============================================================================
920
+
921
+ const MAX_HISTORY_ENTRIES = 100;
922
+ const MAX_RECENT_PROMPTS = 30;
923
+
924
+ interface PromptEntry {
925
+ text: string;
926
+ timestamp: number;
927
+ }
928
+
929
+ class PromptEditor extends CustomEditor {
930
+ public modeLabelProvider?: () => string;
931
+ /**
932
+ * Color function for the mode label. If unset, the label inherits the border color.
933
+ * We use this to keep the label consistent (e.g. same as the footer/status bar).
934
+ */
935
+ public modeLabelColor?: (text: string) => string;
936
+ private lockedBorder = false;
937
+ private _borderColor?: (text: string) => string;
938
+
939
+ constructor(
940
+ tui: ConstructorParameters<typeof CustomEditor>[0],
941
+ theme: ConstructorParameters<typeof CustomEditor>[1],
942
+ keybindings: ConstructorParameters<typeof CustomEditor>[2],
943
+ ) {
944
+ super(tui, theme, keybindings);
945
+ delete (this as { borderColor?: (text: string) => string }).borderColor;
946
+ Object.defineProperty(this, "borderColor", {
947
+ get: () => this._borderColor ?? ((text: string) => text),
948
+ set: (value: (text: string) => string) => {
949
+ if (this.lockedBorder) return;
950
+ this._borderColor = value;
951
+ },
952
+ configurable: true,
953
+ enumerable: true,
954
+ });
955
+ }
956
+
957
+ lockBorderColor() {
958
+ this.lockedBorder = true;
959
+ }
960
+
961
+ render(width: number): string[] {
962
+ const lines = super.render(width);
963
+ const mode = this.modeLabelProvider?.();
964
+ if (!mode) return lines;
965
+
966
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
967
+ const topPlain = stripAnsi(lines[0] ?? "");
968
+
969
+ // If the editor is scrolled, the built-in editor renders a scroll indicator on the top border.
970
+ // Preserve it, but still show the mode label.
971
+ const scrollPrefixMatch = topPlain.match(/^(─── ↑ \d+ more )/);
972
+ const prefix = scrollPrefixMatch?.[1] ?? "──";
973
+
974
+ let label = formatModeLabel(mode);
975
+
976
+ // Compute how much room we have for the label core (without truncating the prefix).
977
+ const labelLeftSpace = prefix.endsWith(" ") ? "" : " ";
978
+ const labelRightSpace = " ";
979
+ const minRightBorder = 1; // keep at least one border cell on the right
980
+ const maxLabelLen = Math.max(0, width - prefix.length - labelLeftSpace.length - labelRightSpace.length - minRightBorder);
981
+ if (maxLabelLen <= 0) return lines;
982
+ if (label.length > maxLabelLen) label = label.slice(0, maxLabelLen);
983
+
984
+ const labelChunk = `${labelLeftSpace}${label}${labelRightSpace}`;
985
+
986
+ const remaining = width - prefix.length - labelChunk.length;
987
+ if (remaining < 0) return lines;
988
+
989
+ const right = "─".repeat(Math.max(0, remaining));
990
+
991
+ const labelColor = this.modeLabelColor ?? ((text: string) => this.borderColor(text));
992
+ lines[0] = this.borderColor(prefix) + labelColor(labelChunk) + this.borderColor(right);
993
+ return lines;
994
+ }
995
+
996
+ public requestRenderNow(): void {
997
+ this.tui.requestRender();
998
+ }
999
+ }
1000
+
1001
+ function extractText(content: Array<{ type: string; text?: string }>): string {
1002
+ return content
1003
+ .filter((item) => item.type === "text" && typeof item.text === "string")
1004
+ .map((item) => item.text ?? "")
1005
+ .join("")
1006
+ .trim();
1007
+ }
1008
+
1009
+ function collectUserPromptsFromEntries(entries: Array<any>): PromptEntry[] {
1010
+ const prompts: PromptEntry[] = [];
1011
+
1012
+ for (const entry of entries) {
1013
+ if (entry?.type !== "message") continue;
1014
+ const message = entry?.message;
1015
+ if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1016
+ const text = extractText(message.content);
1017
+ if (!text) continue;
1018
+ const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1019
+ prompts.push({ text, timestamp });
1020
+ }
1021
+
1022
+ return prompts;
1023
+ }
1024
+
1025
+ function getSessionDirForCwd(cwd: string): string {
1026
+ const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
1027
+ return path.join(getGlobalAgentDir(), "sessions", safePath);
1028
+ }
1029
+
1030
+ async function readTail(filePath: string, maxBytes = 256 * 1024): Promise<string> {
1031
+ let fileHandle: fs.FileHandle | undefined;
1032
+ try {
1033
+ const stats = await fs.stat(filePath);
1034
+ const size = stats.size;
1035
+ const start = Math.max(0, size - maxBytes);
1036
+ const length = size - start;
1037
+ if (length <= 0) return "";
1038
+
1039
+ const buffer = Buffer.alloc(length);
1040
+ fileHandle = await fs.open(filePath, "r");
1041
+ const { bytesRead } = await fileHandle.read(buffer, 0, length, start);
1042
+ if (bytesRead === 0) return "";
1043
+ let chunk = buffer.subarray(0, bytesRead).toString("utf8");
1044
+ if (start > 0) {
1045
+ const firstNewline = chunk.indexOf("\n");
1046
+ if (firstNewline !== -1) {
1047
+ chunk = chunk.slice(firstNewline + 1);
1048
+ }
1049
+ }
1050
+ return chunk;
1051
+ } catch {
1052
+ return "";
1053
+ } finally {
1054
+ await fileHandle?.close();
1055
+ }
1056
+ }
1057
+
1058
+ async function loadPromptHistoryForCwd(cwd: string, excludeSessionFile?: string): Promise<PromptEntry[]> {
1059
+ const sessionDir = getSessionDirForCwd(path.resolve(cwd));
1060
+ const resolvedExclude = excludeSessionFile ? path.resolve(excludeSessionFile) : undefined;
1061
+ const prompts: PromptEntry[] = [];
1062
+
1063
+ let entries: Dirent[] = [];
1064
+ try {
1065
+ entries = await fs.readdir(sessionDir, { withFileTypes: true });
1066
+ } catch {
1067
+ return prompts;
1068
+ }
1069
+
1070
+ const files = await Promise.all(
1071
+ entries
1072
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
1073
+ .map(async (entry) => {
1074
+ const filePath = path.join(sessionDir, entry.name);
1075
+ try {
1076
+ const stats = await fs.stat(filePath);
1077
+ return { filePath, mtimeMs: stats.mtimeMs };
1078
+ } catch {
1079
+ return undefined;
1080
+ }
1081
+ }),
1082
+ );
1083
+
1084
+ const sortedFiles = files
1085
+ .filter((file): file is { filePath: string; mtimeMs: number } => Boolean(file))
1086
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
1087
+
1088
+ for (const file of sortedFiles) {
1089
+ if (resolvedExclude && path.resolve(file.filePath) === resolvedExclude) continue;
1090
+
1091
+ const tail = await readTail(file.filePath);
1092
+ if (!tail) continue;
1093
+ const lines = tail.split("\n").filter(Boolean);
1094
+ for (const line of lines) {
1095
+ let entry: any;
1096
+ try {
1097
+ entry = JSON.parse(line);
1098
+ } catch {
1099
+ continue;
1100
+ }
1101
+ if (entry?.type !== "message") continue;
1102
+ const message = entry?.message;
1103
+ if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1104
+ const text = extractText(message.content);
1105
+ if (!text) continue;
1106
+ const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1107
+ prompts.push({ text, timestamp });
1108
+ if (prompts.length >= MAX_RECENT_PROMPTS) break;
1109
+ }
1110
+ if (prompts.length >= MAX_RECENT_PROMPTS) break;
1111
+ }
1112
+
1113
+ return prompts;
1114
+ }
1115
+
1116
+ function buildHistoryList(currentSession: PromptEntry[], previousSessions: PromptEntry[]): PromptEntry[] {
1117
+ const all = [...currentSession, ...previousSessions];
1118
+ all.sort((a, b) => a.timestamp - b.timestamp);
1119
+
1120
+ const seen = new Set<string>();
1121
+ const deduped: PromptEntry[] = [];
1122
+ for (const prompt of all) {
1123
+ const key = `${prompt.timestamp}:${prompt.text}`;
1124
+ if (seen.has(key)) continue;
1125
+ seen.add(key);
1126
+ deduped.push(prompt);
1127
+ }
1128
+
1129
+ return deduped.slice(-MAX_HISTORY_ENTRIES);
1130
+ }
1131
+
1132
+ // Overlay mode state ("custom"). Not selectable, not cycled into.
1133
+ let customOverlay: ModeSpec | null = null;
1134
+
1135
+ let loadCounter = 0;
1136
+
1137
+ function historiesMatch(a: PromptEntry[], b: PromptEntry[]): boolean {
1138
+ if (a.length !== b.length) return false;
1139
+ for (let i = 0; i < a.length; i += 1) {
1140
+ if (a[i]?.text !== b[i]?.text || a[i]?.timestamp !== b[i]?.timestamp) return false;
1141
+ }
1142
+ return true;
1143
+ }
1144
+
1145
+ function setEditor(pi: ExtensionAPI, ctx: ExtensionContext, history: PromptEntry[]) {
1146
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
1147
+ const editor = new PromptEditor(tui, theme, keybindings);
1148
+ requestEditorRender = () => editor.requestRenderNow();
1149
+ editor.modeLabelProvider = () => runtime.currentMode;
1150
+ // Keep the mode label color stable (match footer/status bar).
1151
+ editor.modeLabelColor = (text: string) => ctx.ui.theme.fg("dim", text);
1152
+ const borderColor = (text: string) => {
1153
+ const isBashMode = editor.getText().trimStart().startsWith("!");
1154
+ if (isBashMode) {
1155
+ return ctx.ui.theme.getBashModeBorderColor()(text);
1156
+ }
1157
+ return getModeBorderColor(ctx, pi, runtime.currentMode)(text);
1158
+ };
1159
+
1160
+ editor.borderColor = borderColor;
1161
+ editor.lockBorderColor();
1162
+ for (const prompt of history) {
1163
+ editor.addToHistory?.(prompt.text);
1164
+ }
1165
+ return editor;
1166
+ });
1167
+ }
1168
+
1169
+ function applyEditor(pi: ExtensionAPI, ctx: ExtensionContext) {
1170
+ if (!ctx.hasUI) return;
1171
+
1172
+ const sessionFile = ctx.sessionManager.getSessionFile();
1173
+ const currentEntries = ctx.sessionManager.getBranch();
1174
+ const currentPrompts = collectUserPromptsFromEntries(currentEntries);
1175
+ const immediateHistory = buildHistoryList(currentPrompts, []);
1176
+
1177
+ const currentLoad = ++loadCounter;
1178
+ const initialText = ctx.ui.getEditorText();
1179
+ setEditor(pi, ctx, immediateHistory);
1180
+
1181
+ void (async () => {
1182
+ const previousPrompts = await loadPromptHistoryForCwd(ctx.cwd, sessionFile ?? undefined);
1183
+ if (currentLoad !== loadCounter) return;
1184
+ if (ctx.ui.getEditorText() !== initialText) return;
1185
+ const history = buildHistoryList(currentPrompts, previousPrompts);
1186
+ if (historiesMatch(history, immediateHistory)) return;
1187
+ setEditor(pi, ctx, history);
1188
+ })();
1189
+ }
1190
+
1191
+ // =============================================================================
1192
+ // Extension Export
1193
+ // =============================================================================
1194
+
1195
+ export default function (pi: ExtensionAPI) {
1196
+ pi.registerCommand("mode", {
1197
+ description: "Select prompt mode",
1198
+ handler: async (args, ctx) => {
1199
+ const tokens = args
1200
+ .split(/\s+/)
1201
+ .map((x) => x.trim())
1202
+ .filter(Boolean);
1203
+
1204
+ // /mode
1205
+ if (tokens.length === 0) {
1206
+ await selectModeUI(pi, ctx);
1207
+ return;
1208
+ }
1209
+
1210
+ // /mode store [name]
1211
+ if (tokens[0] === "store") {
1212
+ await ensureRuntime(pi, ctx);
1213
+
1214
+ let target = tokens[1];
1215
+ if (!target) {
1216
+ if (!ctx.hasUI) return;
1217
+ const names = orderedModeNames(runtime.data.modes);
1218
+ target = await ctx.ui.select("Store current selection into mode", names);
1219
+ if (!target) return;
1220
+ }
1221
+
1222
+ if (target === CUSTOM_MODE_NAME) {
1223
+ if (ctx.hasUI) ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning");
1224
+ return;
1225
+ }
1226
+
1227
+ const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
1228
+ await storeSelectionIntoMode(pi, ctx, target, selection);
1229
+ if (ctx.hasUI) ctx.ui.notify(`Stored current selection into "${target}"`, "info");
1230
+ return;
1231
+ }
1232
+
1233
+ // /mode <name>
1234
+ await applyMode(pi, ctx, tokens[0]!);
1235
+ },
1236
+ });
1237
+
1238
+ pi.registerShortcut("ctrl+shift+m", {
1239
+ description: "Select prompt mode",
1240
+ handler: async (ctx) => {
1241
+ await selectModeUI(pi, ctx);
1242
+ },
1243
+ });
1244
+
1245
+ pi.registerShortcut("ctrl+space", {
1246
+ description: "Cycle prompt mode",
1247
+ handler: async (ctx) => {
1248
+ await cycleMode(pi, ctx, 1);
1249
+ },
1250
+ });
1251
+
1252
+ pi.on("session_start", async (_event, ctx) => {
1253
+ lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1254
+ await ensureRuntime(pi, ctx);
1255
+ customOverlay = null;
1256
+
1257
+ const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1258
+ if (inferred) {
1259
+ runtime.currentMode = inferred;
1260
+ runtime.lastRealMode = inferred;
1261
+ } else {
1262
+ // No exact match → treat as overlay.
1263
+ runtime.currentMode = CUSTOM_MODE_NAME;
1264
+ customOverlay = getCurrentSelectionSpec(pi, ctx);
1265
+ }
1266
+
1267
+ applyEditor(pi, ctx);
1268
+ });
1269
+
1270
+ pi.on("session_switch", async (_event, ctx) => {
1271
+ lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1272
+ await ensureRuntime(pi, ctx);
1273
+ customOverlay = null;
1274
+
1275
+ const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1276
+ if (inferred) {
1277
+ runtime.currentMode = inferred;
1278
+ runtime.lastRealMode = inferred;
1279
+ } else {
1280
+ runtime.currentMode = CUSTOM_MODE_NAME;
1281
+ customOverlay = getCurrentSelectionSpec(pi, ctx);
1282
+ }
1283
+
1284
+ applyEditor(pi, ctx);
1285
+ });
1286
+
1287
+
1288
+ pi.on("model_select", async (event: ModelSelectEvent, ctx) => {
1289
+ // Always track the last observed model for overlay/store correctness.
1290
+ lastObservedModel = { provider: event.model.provider, modelId: event.model.id };
1291
+
1292
+ // Skip mode switching triggered by applyMode() itself, otherwise we'd jump to "custom"
1293
+ // while we are in the middle of applying a mode.
1294
+ if (runtime.applying) return;
1295
+
1296
+ // Manual model changes always go into the overlay "custom" mode.
1297
+ await ensureRuntime(pi, ctx);
1298
+ if (runtime.currentMode !== CUSTOM_MODE_NAME) {
1299
+ runtime.lastRealMode = runtime.currentMode;
1300
+ }
1301
+ runtime.currentMode = CUSTOM_MODE_NAME;
1302
+
1303
+ customOverlay = {
1304
+ provider: event.model.provider,
1305
+ modelId: event.model.id,
1306
+ thinkingLevel: pi.getThinkingLevel(),
1307
+ };
1308
+
1309
+ // Do not persist/select custom.
1310
+ if (ctx.hasUI) {
1311
+ requestEditorRender?.();
1312
+ }
1313
+ });
1314
+
1315
+ }