@openlines/opl-pi-sht 0.1.21 → 0.2.1
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 +8 -4
- package/configs/opl-modes.json.sample +24 -28
- package/extensions/opl-browser/browser.ts +4 -4
- package/extensions/opl-footer/README.md +9 -2
- package/extensions/opl-footer/git-status.ts +42 -0
- package/extensions/opl-footer/index.ts +26 -8
- package/extensions/opl-footer/segments/cost.ts +2 -2
- package/extensions/opl-footer/segments/helpers.ts +0 -2
- package/extensions/opl-footer/segments/mode-switcher.ts +4 -2
- package/extensions/opl-footer/theme.ts +24 -5
- package/extensions/opl-init/README.md +14 -11
- package/extensions/opl-init/index.ts +204 -148
- package/extensions/opl-input/README.md +5 -3
- package/extensions/opl-input/index.ts +14 -7
- package/extensions/opl-input/mode-style.ts +1 -1
- package/extensions/opl-input/utils.ts +16 -5
- package/extensions/opl-modes/README.md +19 -13
- package/extensions/opl-modes/config.ts +64 -16
- package/extensions/opl-modes/index.ts +156 -70
- package/extensions/opl-modes/state.ts +40 -18
- package/extensions/opl-modes/types.ts +2 -0
- package/extensions/opl-modes/utils.ts +123 -12
- package/extensions/opl-simplebench/benchmark.ts +24 -7
- package/extensions/opl-simplebench/index.ts +8 -5
- package/extensions/opl-simplebench/research.ts +4 -1
- package/extensions/opl-simplebench/util/providers.ts +6 -1
- package/extensions/opl-todo/index.ts +2 -2
- package/package.json +3 -3
|
@@ -45,14 +45,14 @@ import {
|
|
|
45
45
|
getModeDefinition,
|
|
46
46
|
executeHandoffAllowed,
|
|
47
47
|
withPlanComplete,
|
|
48
|
+
planCompleteAllowed,
|
|
48
49
|
LAZY_TOOLS,
|
|
49
50
|
LOADER_TOOL_NAME,
|
|
50
51
|
applyLazyPolicy,
|
|
51
52
|
lazyToolsToEnable,
|
|
52
53
|
} from "./config.js";
|
|
53
54
|
import {
|
|
54
|
-
|
|
55
|
-
isDestructive,
|
|
55
|
+
bashBlockReason,
|
|
56
56
|
extractPlanText,
|
|
57
57
|
isPlanLike,
|
|
58
58
|
ensurePlanDir,
|
|
@@ -69,6 +69,8 @@ import {
|
|
|
69
69
|
setRefining,
|
|
70
70
|
getActivePlanFile,
|
|
71
71
|
setActivePlanFile,
|
|
72
|
+
getRestoringModel,
|
|
73
|
+
setRestoringModel,
|
|
72
74
|
transition,
|
|
73
75
|
enterPlanWithFile,
|
|
74
76
|
restore,
|
|
@@ -120,22 +122,51 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
120
122
|
// ─── Saved model for restoring ─────────────────────────────────────────────
|
|
121
123
|
let savedModel: Model<any> | null = null;
|
|
122
124
|
const queueModelChange = createLatestModelQueue();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
// Consecutive restore failures for the same model, so an un-authable restore point is
|
|
126
|
+
// retried once and then released instead of blocking future captures forever.
|
|
127
|
+
let restoreAttempts = 0;
|
|
128
|
+
|
|
129
|
+
function saveAndSetActiveTools(toolNames: string[], resting = false): void {
|
|
130
|
+
if (resting) {
|
|
131
|
+
// OFF is the resting baseline: drop any snapshot from the mode being exited, otherwise a
|
|
132
|
+
// later tool-less mode would restore the pre-OFF set and widen past the pinned baseline.
|
|
133
|
+
savedToolNames = null;
|
|
134
|
+
} else if (savedToolNames === null) {
|
|
135
|
+
// Snapshot what is actually active, not every registered tool: entering a mode must
|
|
136
|
+
// never widen the set the user (or Pi settings) narrowed before it.
|
|
137
|
+
savedToolNames = pi.getActiveTools();
|
|
128
138
|
}
|
|
129
139
|
pi.setActiveTools(applyLazyPolicy(toolNames));
|
|
130
140
|
}
|
|
131
141
|
|
|
142
|
+
/** Plan-mode tool list: honors a modes.plan.tools override, not just the shared default. */
|
|
143
|
+
function planModeTools(): string[] {
|
|
144
|
+
return getModeDefinition("plan")?.tools ?? PLAN_MODE_TOOLS;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pi's setActiveTools() silently drops names it does not know, so a typo in
|
|
149
|
+
* modes.<name>.tools quietly shrinks the mode. Say so once per name per session.
|
|
150
|
+
*/
|
|
151
|
+
const warnedTools = new Set<string>();
|
|
152
|
+
function warnUnknownTools(ctx: ExtensionContext, mode: string, tools?: string[]): void {
|
|
153
|
+
if (!ctx.hasUI || !tools) return;
|
|
154
|
+
const known = new Set(pi.getAllTools().map((t) => t.name));
|
|
155
|
+
for (const name of tools) {
|
|
156
|
+
if (known.has(name) || warnedTools.has(name)) continue;
|
|
157
|
+
warnedTools.add(name);
|
|
158
|
+
ctx.ui.notify(`[mode-switcher] modes.${mode}.tools: unknown tool "${name}" is ignored by Pi`, "warning");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
132
162
|
/** Queue model changes so a superseded async switch cannot finish after its restore. */
|
|
133
|
-
function setQueuedModel(ctx: ExtensionContext, model: Model<any>, label: string): Promise<
|
|
163
|
+
function setQueuedModel(ctx: ExtensionContext, model: Model<any>, label: string): Promise<boolean | undefined> {
|
|
134
164
|
return queueModelChange(async () => {
|
|
135
165
|
const success = await pi.setModel(model);
|
|
136
166
|
if (!success && ctx.hasUI) ctx.ui.notify(`[mode-switcher] No API key for model ${label}`, "error");
|
|
137
167
|
updateStatus(ctx);
|
|
138
|
-
|
|
168
|
+
return success;
|
|
169
|
+
});
|
|
139
170
|
}
|
|
140
171
|
|
|
141
172
|
async function switchToModel(ctx: ExtensionContext, modelRef: { provider: string; id: string }): Promise<void> {
|
|
@@ -158,18 +189,41 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
158
189
|
async function applyModeModel(ctx: ExtensionContext, modeDef: { model?: { provider: string; id: string } } | undefined): Promise<void> {
|
|
159
190
|
const modelRef = modeDef?.model;
|
|
160
191
|
if (!modelRef) return;
|
|
161
|
-
|
|
162
|
-
|
|
192
|
+
const current = ctx.model ?? null;
|
|
193
|
+
const alreadyOnModeModel = current !== null && current.provider === modelRef.provider && current.id === modelRef.id;
|
|
194
|
+
// Capture the restore point once per mode, and never when the session is already on
|
|
195
|
+
// the mode's own model (after /reload or /resume inside a mode) or when a restore
|
|
196
|
+
// point is already persisted — otherwise the real previous model is lost.
|
|
197
|
+
if (savedModel === null && getRestoringModel() === null && !alreadyOnModeModel) {
|
|
198
|
+
rememberRestoreModel(current);
|
|
163
199
|
}
|
|
164
200
|
await switchToModel(ctx, modelRef);
|
|
165
201
|
}
|
|
166
202
|
|
|
203
|
+
/** Record the model to return to, in memory and in the session blob. */
|
|
204
|
+
function rememberRestoreModel(model: Model<any> | null): void {
|
|
205
|
+
savedModel = model;
|
|
206
|
+
if (model) restoreAttempts = 0;
|
|
207
|
+
setRestoringModel(model ? { provider: model.provider, id: model.id } : null, pi);
|
|
208
|
+
}
|
|
209
|
+
|
|
167
210
|
/** Restore whatever model was active before applyModeModel last changed it. */
|
|
168
211
|
function restoreModelIfSaved(ctx: ExtensionContext): void {
|
|
169
|
-
|
|
170
|
-
const toRestore = savedModel;
|
|
171
|
-
|
|
172
|
-
|
|
212
|
+
const ref = savedModel ?? getRestoringModel();
|
|
213
|
+
const toRestore = ref ? (savedModel ?? ctx.modelRegistry.find(ref.provider, ref.id)) : null;
|
|
214
|
+
rememberRestoreModel(null);
|
|
215
|
+
// The model left the registry (provider removed): drop the point so the next entry captures a fresh one.
|
|
216
|
+
if (!toRestore) return;
|
|
217
|
+
void setQueuedModel(ctx, toRestore, `${toRestore.provider}/${toRestore.id}`).then((outcome) => {
|
|
218
|
+
// false = the model exists but has no auth yet (fixed by /login): keep it for one retry.
|
|
219
|
+
// undefined = superseded by a newer switch, which now owns the restore.
|
|
220
|
+
if (outcome === false && ref && restoreAttempts < 1) {
|
|
221
|
+
restoreAttempts++;
|
|
222
|
+
setRestoringModel({ provider: ref.provider, id: ref.id }, pi);
|
|
223
|
+
} else {
|
|
224
|
+
restoreAttempts = 0;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
173
227
|
}
|
|
174
228
|
|
|
175
229
|
/**
|
|
@@ -182,8 +236,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
182
236
|
function applyOffModel(ctx: ExtensionContext): void {
|
|
183
237
|
const offModeDef = getModeDefinition("off");
|
|
184
238
|
if (offModeDef?.model) {
|
|
185
|
-
|
|
186
|
-
switchToModel(ctx, offModeDef.model);
|
|
239
|
+
rememberRestoreModel(null);
|
|
240
|
+
void switchToModel(ctx, offModeDef.model);
|
|
187
241
|
} else {
|
|
188
242
|
restoreModelIfSaved(ctx);
|
|
189
243
|
}
|
|
@@ -195,12 +249,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
195
249
|
}
|
|
196
250
|
|
|
197
251
|
function restoreAllTools(): void {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
} else {
|
|
202
|
-
pi.setActiveTools(applyLazyPolicy(toolsWithoutPlanComplete(pi.getAllTools().map((t) => t.name))));
|
|
203
|
-
}
|
|
252
|
+
const names = savedToolNames ?? pi.getActiveTools();
|
|
253
|
+
pi.setActiveTools(applyLazyPolicy(toolsWithoutPlanComplete(names)));
|
|
254
|
+
savedToolNames = null;
|
|
204
255
|
}
|
|
205
256
|
|
|
206
257
|
// ─── Plan file helpers ─────────────────────────────────────────────────────
|
|
@@ -227,6 +278,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
227
278
|
function updateStatus(ctx: ExtensionContext): void {
|
|
228
279
|
const mode = getMode();
|
|
229
280
|
const modeDef = getModeDefinition(mode);
|
|
281
|
+
warnUnknownTools(ctx, mode, modeDef?.tools);
|
|
230
282
|
|
|
231
283
|
if (!ctx.hasUI) return;
|
|
232
284
|
|
|
@@ -302,7 +354,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
302
354
|
function enterPlanMode(ctx: ExtensionContext): void {
|
|
303
355
|
transition("plan", pi);
|
|
304
356
|
const modeDef = getModeDefinition("plan");
|
|
305
|
-
saveAndSetActiveTools(
|
|
357
|
+
saveAndSetActiveTools(planModeTools());
|
|
306
358
|
applyModeModel(ctx, modeDef);
|
|
307
359
|
updateStatus(ctx);
|
|
308
360
|
if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
@@ -314,7 +366,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
314
366
|
|
|
315
367
|
async function enterExecuteMode(ctx: ExtensionContext): Promise<void> {
|
|
316
368
|
transition("execute", pi);
|
|
317
|
-
const baseNames = savedToolNames ?? pi.
|
|
369
|
+
const baseNames = savedToolNames ?? pi.getActiveTools();
|
|
318
370
|
pi.setActiveTools(applyLazyPolicy([...toolsWithoutPlanComplete(baseNames), "plan_complete"]));
|
|
319
371
|
savedToolNames = null;
|
|
320
372
|
const modeDef = getModeDefinition("execute");
|
|
@@ -337,7 +389,14 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
337
389
|
*/
|
|
338
390
|
function enterOffMode(ctx: ExtensionContext, message?: string, silent = false): void {
|
|
339
391
|
transition("off", pi);
|
|
340
|
-
|
|
392
|
+
const offModeDef = getModeDefinition("off");
|
|
393
|
+
// OFF gates tools only when the user declares modes.off.tools; otherwise it restores
|
|
394
|
+
// whatever was active before the mode.
|
|
395
|
+
if (offModeDef?.tools) {
|
|
396
|
+
saveAndSetActiveTools(withPlanComplete("off", offModeDef.tools), true);
|
|
397
|
+
} else {
|
|
398
|
+
restoreAllTools();
|
|
399
|
+
}
|
|
341
400
|
applyOffModel(ctx);
|
|
342
401
|
updateStatus(ctx);
|
|
343
402
|
if (!silent && ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
@@ -358,6 +417,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
358
417
|
saveAndSetActiveTools(withPlanComplete(name, modeDef.tools));
|
|
359
418
|
} else {
|
|
360
419
|
restoreAllTools();
|
|
420
|
+
// Inherited (unlisted) tool set: allowPlanComplete still needs the tool active,
|
|
421
|
+
// otherwise the mode can never finish through the completion path.
|
|
422
|
+
if (modeDef?.allowPlanComplete) pi.setActiveTools(withPlanComplete(name, pi.getActiveTools()));
|
|
361
423
|
}
|
|
362
424
|
applyModeModel(ctx, modeDef);
|
|
363
425
|
updateStatus(ctx);
|
|
@@ -422,7 +484,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
422
484
|
const filename = choice.slice("execute:".length);
|
|
423
485
|
if (current !== "off") enterOffMode(ctx, undefined, true);
|
|
424
486
|
enterPlanWithFile(filename, pi);
|
|
425
|
-
saveAndSetActiveTools(
|
|
487
|
+
saveAndSetActiveTools(planModeTools());
|
|
426
488
|
await enterExecuteMode(ctx);
|
|
427
489
|
pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
|
|
428
490
|
} else if (getModeDefinition(choice)) {
|
|
@@ -459,14 +521,16 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
459
521
|
const modeDef = getModeDefinition(mode);
|
|
460
522
|
|
|
461
523
|
if (modeDef?.tools) {
|
|
462
|
-
|
|
524
|
+
// Honor allowPlanComplete here too, otherwise a resume drops the tool a mode was given.
|
|
525
|
+
// OFF's declared list is the resting baseline, so it must not keep a stale snapshot.
|
|
526
|
+
saveAndSetActiveTools(withPlanComplete(mode, modeDef.tools), mode === "off");
|
|
463
527
|
} else if (mode === "execute") {
|
|
464
|
-
const allNames = pi.
|
|
528
|
+
const allNames = pi.getActiveTools();
|
|
465
529
|
pi.setActiveTools(applyLazyPolicy([...toolsWithoutPlanComplete(allNames), "plan_complete"]));
|
|
466
530
|
} else {
|
|
467
|
-
// Default:
|
|
468
|
-
const allNames = pi.
|
|
469
|
-
pi.setActiveTools(applyLazyPolicy(toolsWithoutPlanComplete(allNames)));
|
|
531
|
+
// Default: the active set minus plan_complete, re-added when the mode allows it.
|
|
532
|
+
const allNames = pi.getActiveTools();
|
|
533
|
+
pi.setActiveTools(applyLazyPolicy(withPlanComplete(mode, toolsWithoutPlanComplete(allNames))));
|
|
470
534
|
}
|
|
471
535
|
|
|
472
536
|
// Apply the mode's model BEFORE sending any resume follow-up message, so
|
|
@@ -552,10 +616,10 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
552
616
|
// plan_complete is only callable in execute mode
|
|
553
617
|
const mode = getMode();
|
|
554
618
|
const modeDef = getModeDefinition(mode);
|
|
555
|
-
if (event.toolName === "plan_complete" && !
|
|
619
|
+
if (event.toolName === "plan_complete" && !planCompleteAllowed(mode)) {
|
|
556
620
|
return {
|
|
557
621
|
block: true,
|
|
558
|
-
reason: `[mode-switcher] plan_complete only available in execute mode. Current mode: ${mode}`,
|
|
622
|
+
reason: `[mode-switcher] plan_complete is only available in execute mode or a mode with allowPlanComplete. Current mode: ${mode}`,
|
|
559
623
|
};
|
|
560
624
|
}
|
|
561
625
|
|
|
@@ -564,29 +628,11 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
564
628
|
if (!modeDef?.safePatterns && !modeDef?.destructivePatterns) return {};
|
|
565
629
|
|
|
566
630
|
const command = event.input.command as string;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const matchesSafe = modeDef.safePatterns.some((pattern) => pattern.test(command));
|
|
571
|
-
if (!matchesSafe) {
|
|
572
|
-
return {
|
|
573
|
-
block: true,
|
|
574
|
-
reason: `[mode-switcher] Command blocked — not in safe pattern list for ${mode} mode: ${command}`,
|
|
575
|
-
};
|
|
576
|
-
}
|
|
631
|
+
const reason = bashBlockReason(command, modeDef.safePatterns, modeDef.destructivePatterns);
|
|
632
|
+
if (reason) {
|
|
633
|
+
return { block: true, reason: `[mode-switcher] Command blocked — ${reason} [${mode} mode]` };
|
|
577
634
|
}
|
|
578
635
|
|
|
579
|
-
// Check destructive patterns (if defined, must not match any) — also against
|
|
580
|
-
// a quote/backslash-stripped skeleton so r"m"/r\m obfuscation can't dodge \brm\b.
|
|
581
|
-
if (modeDef.destructivePatterns && modeDef.destructivePatterns.length > 0) {
|
|
582
|
-
if (isDestructive(command, modeDef.destructivePatterns)) {
|
|
583
|
-
return {
|
|
584
|
-
block: true,
|
|
585
|
-
reason: `[mode-switcher] Command blocked — destructive pattern in ${mode} mode: ${command}`,
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
636
|
return {};
|
|
591
637
|
});
|
|
592
638
|
|
|
@@ -596,15 +642,18 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
596
642
|
name: "plan_complete",
|
|
597
643
|
label: "Plan Complete",
|
|
598
644
|
description:
|
|
599
|
-
"Signal that all plan steps have been executed. ONLY callable in EXECUTE mode. Call this once after finishing the final step. This exits
|
|
645
|
+
"Signal that all plan steps have been executed. ONLY callable in EXECUTE mode or in a mode configured with allowPlanComplete. Call this once after finishing the final step. This exits the mode. Do NOT call this outside those modes.",
|
|
600
646
|
parameters: Type.Object({}),
|
|
601
647
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
602
|
-
if (getMode()
|
|
603
|
-
return {
|
|
648
|
+
if (!planCompleteAllowed(getMode())) {
|
|
649
|
+
return {
|
|
650
|
+
content: [{ type: "text", text: "plan_complete is only available in execute mode or a mode with allowPlanComplete." }],
|
|
651
|
+
details: undefined,
|
|
652
|
+
};
|
|
604
653
|
}
|
|
605
654
|
cleanupPlanFile();
|
|
606
655
|
enterOffMode(ctx, "Plan implemented. Plan mode OFF.");
|
|
607
|
-
return { content: [{ type: "text", text: "
|
|
656
|
+
return { content: [{ type: "text", text: "Plan mode exited." }], details: undefined };
|
|
608
657
|
},
|
|
609
658
|
});
|
|
610
659
|
|
|
@@ -612,7 +661,9 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
612
661
|
|
|
613
662
|
pi.on("tool_result", (event: ToolResultEvent, ctx: ExtensionContext) => {
|
|
614
663
|
if (event.toolName !== "plan_complete") return;
|
|
615
|
-
|
|
664
|
+
// The tool body has usually already returned to OFF, which makes this a no-op; the check
|
|
665
|
+
// stays mode-based (not execute-only) so a custom allowPlanComplete mode is handled alike.
|
|
666
|
+
if (getMode() !== "execute" && !planCompleteAllowed(getMode())) return;
|
|
616
667
|
cleanupPlanFile();
|
|
617
668
|
enterOffMode(ctx, "Plan implemented. Plan mode OFF.");
|
|
618
669
|
});
|
|
@@ -623,7 +674,17 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
623
674
|
// If plan_complete was never called, exit execute mode automatically.
|
|
624
675
|
// (If it was called, mode is already "off" here — this check is a no-op.)
|
|
625
676
|
if (getMode() === "execute") {
|
|
626
|
-
|
|
677
|
+
// An aborted turn (ESC) is a pause, not a finished execution: stay in execute mode
|
|
678
|
+
// so the plan can be resumed instead of silently dropping out mid-plan. Pi emits an
|
|
679
|
+
// assistant message with stopReason "aborted" even when the abort lands before the
|
|
680
|
+
// first token, so there is always a message to read here.
|
|
681
|
+
const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant") as
|
|
682
|
+
| { stopReason?: string }
|
|
683
|
+
| undefined;
|
|
684
|
+
// Only a turn that actually finished counts as done: "aborted"/"error" (and a missing
|
|
685
|
+
// assistant message) are pauses, so execute mode survives ESC and provider failures.
|
|
686
|
+
const reason = lastAssistant?.stopReason;
|
|
687
|
+
if (reason && reason !== "aborted" && reason !== "error") enterOffMode(ctx, "Execution complete. Mode OFF.");
|
|
627
688
|
return;
|
|
628
689
|
}
|
|
629
690
|
|
|
@@ -716,7 +777,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
716
777
|
}
|
|
717
778
|
const filename = `${PLAN_FILE_PREFIX}${sanitized}.md`;
|
|
718
779
|
enterPlanWithFile(filename, pi);
|
|
719
|
-
saveAndSetActiveTools(
|
|
780
|
+
saveAndSetActiveTools(planModeTools());
|
|
720
781
|
updateStatus(ctx);
|
|
721
782
|
const createTitle = titleFromFilename(filename);
|
|
722
783
|
if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
@@ -734,8 +795,8 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
734
795
|
if (getMode() !== "plan") return;
|
|
735
796
|
|
|
736
797
|
const refineCount = getRefineCount();
|
|
737
|
-
const refineLabel = refineCount
|
|
738
|
-
? `Refine (${refineCount} cycles
|
|
798
|
+
const refineLabel = refineCount > 0
|
|
799
|
+
? `Refine (${refineCount} ${refineCount === 1 ? "cycle" : "cycles"} so far)`
|
|
739
800
|
: "Refine";
|
|
740
801
|
const options: SelectItem[] = [
|
|
741
802
|
{ value: "execute", label: "Execute" },
|
|
@@ -785,7 +846,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
785
846
|
/** Load an existing plan file and show the action menu. */
|
|
786
847
|
function loadPlanAndShowMenu(ctx: ExtensionContext, filename: string, displayName: string): void {
|
|
787
848
|
enterPlanWithFile(filename, pi);
|
|
788
|
-
saveAndSetActiveTools(
|
|
849
|
+
saveAndSetActiveTools(planModeTools());
|
|
789
850
|
updateStatus(ctx);
|
|
790
851
|
if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
791
852
|
ctx.ui.notify(USER_CONFIG.labels.plan.notifyLoaded.replace("{title}", displayName), "info");
|
|
@@ -819,10 +880,15 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
819
880
|
return container;
|
|
820
881
|
});
|
|
821
882
|
|
|
822
|
-
// ─── Command: /mode [chat|plan|off]
|
|
883
|
+
// ─── Command: /mode [chat|plan|off|normal|<custom>] ──────────────────────
|
|
884
|
+
|
|
885
|
+
/** Names /mode accepts as an argument: the built-ins plus every registered custom mode. */
|
|
886
|
+
function modeArgumentNames(): string[] {
|
|
887
|
+
return ["off", "normal", "chat", "plan", ...Array.from(MODE_REGISTRY.keys()).filter((n) => n !== "off" && n !== "chat" && n !== "plan" && n !== "execute")];
|
|
888
|
+
}
|
|
823
889
|
|
|
824
890
|
pi.registerCommand("mode", {
|
|
825
|
-
description: "Mode switcher: /mode (open picker) · /mode chat · /mode plan · /mode normal",
|
|
891
|
+
description: "Mode switcher: /mode (open picker) · /mode chat · /mode plan · /mode normal · /mode <custom>",
|
|
826
892
|
handler: async (args: string, ctx) => {
|
|
827
893
|
const input = args.trim().toLowerCase();
|
|
828
894
|
|
|
@@ -860,7 +926,27 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
860
926
|
return;
|
|
861
927
|
}
|
|
862
928
|
|
|
863
|
-
|
|
929
|
+
// Any other registered mode: config-declared custom modes (modes.<name>) and built-in
|
|
930
|
+
// overrides, so /mode review works the same as picking it from the picker.
|
|
931
|
+
if (getModeDefinition(input)) {
|
|
932
|
+
if (input === "execute") {
|
|
933
|
+
if (ctx.hasUI) ctx.ui.notify("Execute mode needs an active plan — use /execute", "info");
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (getModeDefinition(input)?.enabled === false) {
|
|
937
|
+
if (ctx.hasUI) ctx.ui.notify(`Mode "${input}" is disabled (modes.${input}.enabled: false)`, "warning");
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (getMode() === input) {
|
|
941
|
+
if (ctx.hasUI) ctx.ui.notify(`Already in ${input} mode`, "info");
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (getMode() !== "off") enterOffMode(ctx, undefined, true);
|
|
945
|
+
enterCustomMode(ctx, input);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (ctx.hasUI) ctx.ui.notify(`Unknown mode: "${input}". Use: ${modeArgumentNames().join(", ")}`, "warning");
|
|
864
950
|
},
|
|
865
951
|
});
|
|
866
952
|
|
|
@@ -958,7 +1044,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
958
1044
|
}
|
|
959
1045
|
if (getMode() !== "off") enterOffMode(ctx, undefined, true);
|
|
960
1046
|
enterPlanWithFile(filename, pi);
|
|
961
|
-
saveAndSetActiveTools(
|
|
1047
|
+
saveAndSetActiveTools(planModeTools());
|
|
962
1048
|
updateStatus(ctx);
|
|
963
1049
|
if (ctx.hasUI && !USER_CONFIG.ui.hideNotify) {
|
|
964
1050
|
ctx.ui.notify(USER_CONFIG.labels.plan.notifyWithTitle.replace("{title}", titleFromFilename(filename)), USER_CONFIG.labels.plan.notifyType as "info" | "warning" | "error");
|
|
@@ -983,7 +1069,7 @@ export default function modeSwitcher(pi: ExtensionAPI) {
|
|
|
983
1069
|
async function startExecute(filename: string): Promise<void> {
|
|
984
1070
|
if (current !== "off") enterOffMode(ctx, undefined, true);
|
|
985
1071
|
enterPlanWithFile(filename, pi);
|
|
986
|
-
saveAndSetActiveTools(
|
|
1072
|
+
saveAndSetActiveTools(planModeTools());
|
|
987
1073
|
await enterExecuteMode(ctx);
|
|
988
1074
|
pi.sendUserMessage("Execute the plan steps now.", { deliverAs: "followUp" });
|
|
989
1075
|
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/** OFF/CHAT/PLAN/EXECUTE unified state machine with session persistence via appendEntry blob. */
|
|
2
2
|
|
|
3
3
|
import { ENTRY_TYPE, getModeDefinition } from "./config.js";
|
|
4
|
-
import type { AgentMode, AgentModeBlob, ModeAppearanceConfig } from "./types.js";
|
|
4
|
+
import type { AgentMode, AgentModeBlob, ModeAppearanceConfig, ModeModelConfig } from "./types.js";
|
|
5
5
|
|
|
6
6
|
/** Mutable state — shared within the extension module. */
|
|
7
7
|
const state: {
|
|
8
8
|
mode: AgentMode;
|
|
9
9
|
activePlanFile: string | null;
|
|
10
|
+
restoringModel: ModeModelConfig | null;
|
|
10
11
|
refining: boolean;
|
|
11
12
|
refineCount: number;
|
|
12
13
|
} = {
|
|
13
14
|
mode: "off",
|
|
14
15
|
activePlanFile: null,
|
|
16
|
+
restoringModel: null,
|
|
15
17
|
refining: false,
|
|
16
18
|
refineCount: 0,
|
|
17
19
|
};
|
|
@@ -29,8 +31,9 @@ function syncGlobalThis(): void {
|
|
|
29
31
|
mode: m,
|
|
30
32
|
appearance: getModeDefinition(m)?.appearance,
|
|
31
33
|
} satisfies { mode: AgentMode; appearance?: ModeAppearanceConfig };
|
|
32
|
-
// Legacy globals for backward compat with chat-input and footer segments
|
|
33
|
-
|
|
34
|
+
// Legacy globals for backward compat with chat-input and footer segments. Only the
|
|
35
|
+
// plan-family modes light up __planMode, so a custom mode cannot read as "Plan mode ON".
|
|
36
|
+
(globalThis as Record<string, unknown>).__planMode = { mode: m === "plan" || m === "execute" ? m : "off" };
|
|
34
37
|
(globalThis as Record<string, unknown>).__chatMode = { mode: m === "chat" ? "chat" : "off" };
|
|
35
38
|
const requestRender = (globalThis as Record<string, unknown>).__footerRequestRender;
|
|
36
39
|
if (typeof requestRender === "function") requestRender();
|
|
@@ -47,7 +50,17 @@ export function setRefining(value: boolean): void {
|
|
|
47
50
|
|
|
48
51
|
export function getRefineCount(): number { return state.refineCount; }
|
|
49
52
|
export function incrementRefineCount(): void { state.refineCount++; }
|
|
50
|
-
|
|
53
|
+
|
|
54
|
+
/** Model reference to restore when the active mode ends (null = nothing to restore). */
|
|
55
|
+
export function getRestoringModel(): ModeModelConfig | null { return state.restoringModel; }
|
|
56
|
+
|
|
57
|
+
export function setRestoringModel(
|
|
58
|
+
model: ModeModelConfig | null,
|
|
59
|
+
pi: { appendEntry: (type: string, data?: unknown) => void },
|
|
60
|
+
): void {
|
|
61
|
+
state.restoringModel = model;
|
|
62
|
+
persist(pi);
|
|
63
|
+
}
|
|
51
64
|
|
|
52
65
|
export function setActivePlanFile(
|
|
53
66
|
file: string | null,
|
|
@@ -85,6 +98,7 @@ function persist(pi: { appendEntry: (type: string, data?: unknown) => void }): v
|
|
|
85
98
|
pi.appendEntry(ENTRY_TYPE, {
|
|
86
99
|
mode: state.mode,
|
|
87
100
|
activePlanFile: state.activePlanFile,
|
|
101
|
+
restoreModel: state.restoringModel,
|
|
88
102
|
} satisfies AgentModeBlob);
|
|
89
103
|
}
|
|
90
104
|
|
|
@@ -95,6 +109,13 @@ function normalizePlanFile(file: string | null | undefined): string | null {
|
|
|
95
109
|
return file;
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
/** Keep only a well-formed `{ provider, id }` pair; anything else means "nothing to restore". */
|
|
113
|
+
function normalizeModelRef(model: unknown): ModeModelConfig | null {
|
|
114
|
+
if (typeof model !== "object" || model === null) return null;
|
|
115
|
+
const { provider, id } = model as Partial<ModeModelConfig>;
|
|
116
|
+
return typeof provider === "string" && provider && typeof id === "string" && id ? { provider, id } : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
/**
|
|
99
120
|
* Restore state from the most recent relevant session entry on the current branch.
|
|
100
121
|
* Checks for native mode-switcher entries first; falls back to legacy plan-mode/chat-mode entries
|
|
@@ -105,20 +126,20 @@ export function restore(
|
|
|
105
126
|
): boolean {
|
|
106
127
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
107
128
|
const entry = entries[i];
|
|
108
|
-
if (entry.type !== "custom") continue;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
|
|
130
|
+
|
|
131
|
+
const data = entry.data as AgentModeBlob | undefined;
|
|
132
|
+
if (!data?.mode) continue; // Malformed/incomplete blob: keep looking back.
|
|
133
|
+
// The newest well-formed entry decides. A mode the registry does not know (a removed
|
|
134
|
+
// custom mode, a foreign branch) restores nothing at all: falling through to an older
|
|
135
|
+
// entry could resume a chat/execute mode the user had already left.
|
|
136
|
+
if (!getModeDefinition(data.mode)) return false;
|
|
137
|
+
state.mode = data.mode;
|
|
138
|
+
state.activePlanFile = normalizePlanFile(data.activePlanFile);
|
|
139
|
+
state.restoringModel = normalizeModelRef(data.restoreModel);
|
|
140
|
+
state.refining = false;
|
|
141
|
+
syncGlobalThis();
|
|
142
|
+
return true;
|
|
122
143
|
}
|
|
123
144
|
|
|
124
145
|
// Backward compat: restore from legacy entries written by the old extensions.
|
|
@@ -155,6 +176,7 @@ export function restore(
|
|
|
155
176
|
export function resetState(): void {
|
|
156
177
|
state.mode = "off";
|
|
157
178
|
state.activePlanFile = null;
|
|
179
|
+
state.restoringModel = null;
|
|
158
180
|
state.refining = false;
|
|
159
181
|
state.refineCount = 0;
|
|
160
182
|
syncGlobalThis();
|
|
@@ -7,6 +7,8 @@ export type AgentMode = string;
|
|
|
7
7
|
export interface AgentModeBlob {
|
|
8
8
|
mode: AgentMode;
|
|
9
9
|
activePlanFile: string | null;
|
|
10
|
+
/** Model to restore when the mode ends. Persisted so /reload and /resume keep the restore point. */
|
|
11
|
+
restoreModel?: ModeModelConfig | null;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface PlanFileSummary {
|