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