@hank-warren/pi-plan-mode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { PlanExportDestination } from "./plan-export.js";
3
+ import type { PlanModeState } from "./state.js";
4
+
5
+ type InteractiveUi = typeof import("./interactive-ui.js");
6
+
7
+ interface MenuLifecycle {
8
+ signal: AbortSignal;
9
+ isCurrent(): boolean;
10
+ }
11
+
12
+ interface PlanActionControllerOptions {
13
+ loadInteractiveUi(): Promise<InteractiveUi>;
14
+ getState(): PlanModeState;
15
+ captureLifecycle(): MenuLifecycle;
16
+ statusText(): string;
17
+ implementationOutcome(): string;
18
+ getExportDestination(ctx: ExtensionContext): PlanExportDestination;
19
+ show(ctx: ExtensionContext): void;
20
+ finalize(ctx: ExtensionContext): void;
21
+ implementHere(ctx: ExtensionContext): void | Promise<void>;
22
+ implementFresh(ctx: ExtensionContext, isCurrent: () => boolean): void | Promise<void>;
23
+ exportPlan(
24
+ ctx: ExtensionContext,
25
+ path: string,
26
+ signal: AbortSignal,
27
+ isCurrent: () => boolean,
28
+ ): Promise<boolean>;
29
+ settings(ctx: ExtensionContext, signal: AbortSignal, isCurrent: () => boolean): Promise<boolean>;
30
+ save(ctx: ExtensionContext): void;
31
+ stay(ctx: ExtensionContext): void;
32
+ exitReady(ctx: ExtensionContext): void;
33
+ clearSaved(ctx: ExtensionContext): void;
34
+ }
35
+
36
+ export function createPlanActionController(options: PlanActionControllerOptions) {
37
+ const freshAction = (ctx: ExtensionContext, lifecycle: MenuLifecycle, signal: AbortSignal) =>
38
+ options.implementFresh(ctx, () => lifecycle.isCurrent() && !signal.aborted);
39
+
40
+ return {
41
+ async showSaved(ctx: ExtensionContext) {
42
+ const lifecycle = options.captureLifecycle();
43
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
44
+ const ui = await options.loadInteractiveUi();
45
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
46
+ await ui.showSavedPlanMenu(ctx, {
47
+ statusText: options.statusText(),
48
+ implementationOutcome: options.implementationOutcome,
49
+ getExportDestination: () => options.getExportDestination(ctx),
50
+ signal: lifecycle.signal,
51
+ isCurrent: lifecycle.isCurrent,
52
+ show: () => options.show(ctx),
53
+ implementHere: () => options.implementHere(ctx),
54
+ implementFresh: (signal) => freshAction(ctx, lifecycle, signal),
55
+ exportPlan: (path, signal) => options.exportPlan(ctx, path, signal, lifecycle.isCurrent),
56
+ settings: (signal) => options.settings(ctx, signal, lifecycle.isCurrent),
57
+ clear: () => options.clearSaved(ctx),
58
+ });
59
+ },
60
+ async showCurrent(ctx: ExtensionContext) {
61
+ if (!ctx.hasUI) {
62
+ ctx.ui.notify(options.statusText(), "info");
63
+ return;
64
+ }
65
+ const lifecycle = options.captureLifecycle();
66
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
67
+ const ui = await options.loadInteractiveUi();
68
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
69
+ await ui.showPlanModeMenu(ctx, {
70
+ statusText: options.statusText(),
71
+ hasReadyPlan: options.getState().latestPlan !== undefined,
72
+ implementationOutcome: options.implementationOutcome,
73
+ getExportDestination: () => options.getExportDestination(ctx),
74
+ ...lifecycle,
75
+ show: () => options.show(ctx),
76
+ finalize: () => options.finalize(ctx),
77
+ implementHere: () => options.implementHere(ctx),
78
+ implementFresh: (signal) => freshAction(ctx, lifecycle, signal),
79
+ exportPlan: (path, signal) => options.exportPlan(ctx, path, signal, lifecycle.isCurrent),
80
+ save: () => options.save(ctx),
81
+ stay: () => options.stay(ctx),
82
+ exit: () => options.exitReady(ctx),
83
+ });
84
+ },
85
+ async showReady(ctx: ExtensionContext) {
86
+ const lifecycle = options.captureLifecycle();
87
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
88
+ const ui = await options.loadInteractiveUi();
89
+ if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
90
+ await ui.showReadyPlanMenu(ctx, {
91
+ ...lifecycle,
92
+ implementationOutcome: options.implementationOutcome,
93
+ getExportDestination: () => options.getExportDestination(ctx),
94
+ implementHere: () => options.implementHere(ctx),
95
+ implementFresh: (signal) => freshAction(ctx, lifecycle, signal),
96
+ exportPlan: (path, signal) => options.exportPlan(ctx, path, signal, lifecycle.isCurrent),
97
+ save: () => options.save(ctx),
98
+ stay: () => undefined,
99
+ exit: () => options.exitReady(ctx),
100
+ });
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,197 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+ import { type PlanExportDestinationProvider, planExportInputScreen } from "./plan-export-screen.js";
4
+
5
+ interface MenuLifecycle {
6
+ signal: AbortSignal;
7
+ isCurrent(): boolean;
8
+ }
9
+
10
+ const IMPLEMENTATION_CONTEXT_LINES = [
11
+ "Implement here keeps this planning conversation.",
12
+ "Start fresh transfers only the approved plan to a new session.",
13
+ ] as const;
14
+
15
+ interface PlanMenuOptions extends MenuLifecycle {
16
+ statusText: string;
17
+ hasReadyPlan: boolean;
18
+ implementationOutcome(): string;
19
+ getExportDestination: PlanExportDestinationProvider;
20
+ show(): void;
21
+ finalize(): void;
22
+ implementHere(): void | Promise<void>;
23
+ implementFresh(signal: AbortSignal): void | Promise<void>;
24
+ exportPlan(path: string, signal: AbortSignal): Promise<boolean>;
25
+ save(): void;
26
+ stay(): void;
27
+ exit(): void;
28
+ }
29
+
30
+ export async function showPlanModeMenu(ctx: ExtensionContext, options: PlanMenuOptions) {
31
+ type Screen = "main" | "export";
32
+ type Action =
33
+ | "show"
34
+ | "finalize"
35
+ | "implement-here"
36
+ | "implement-fresh"
37
+ | "export"
38
+ | "save"
39
+ | "stay"
40
+ | "exit";
41
+ const menu = defineMenu<undefined, Screen, Action, ExtensionContext>({
42
+ start: "main",
43
+ screens: {
44
+ main: () => ({
45
+ kind: "actions",
46
+ title: "Plan mode",
47
+ lines: [
48
+ options.statusText,
49
+ ...(options.hasReadyPlan
50
+ ? [...IMPLEMENTATION_CONTEXT_LINES, options.implementationOutcome()]
51
+ : []),
52
+ ],
53
+ items: options.hasReadyPlan
54
+ ? [
55
+ { id: "show", label: "Show latest proposed plan", action: "show" },
56
+ {
57
+ id: "implement-here",
58
+ label: "Implement here",
59
+ description: "Continue in this session with the planning conversation.",
60
+ action: "implement-here",
61
+ },
62
+ {
63
+ id: "implement-fresh",
64
+ label: "Start fresh and implement",
65
+ description: "Open a new linked session; transfer only the approved plan.",
66
+ action: "implement-fresh",
67
+ busyLabel: "Starting fresh implementation session…",
68
+ },
69
+ { id: "export", label: "Export plan…", to: "export" },
70
+ { id: "save", label: "Save for later", action: "save" },
71
+ { id: "stay", label: "Stay in Plan mode", action: "stay" },
72
+ { id: "exit", label: "Discard plan and exit", action: "exit" },
73
+ ]
74
+ : [
75
+ { id: "finalize", label: "Request final plan", action: "finalize" },
76
+ { id: "stay", label: "Stay in Plan mode", action: "stay" },
77
+ { id: "exit", label: "Exit Plan mode", action: "exit" },
78
+ ],
79
+ hint: "close",
80
+ }),
81
+ export: () => planExportInputScreen(options.getExportDestination),
82
+ },
83
+ actions: {
84
+ show: async () => {
85
+ options.show();
86
+ return { kind: "close" };
87
+ },
88
+ finalize: async () => {
89
+ options.finalize();
90
+ return { kind: "close" };
91
+ },
92
+ "implement-here": async () => {
93
+ await options.implementHere();
94
+ return { kind: "close" };
95
+ },
96
+ "implement-fresh": async ({ signal }) => {
97
+ await options.implementFresh(signal);
98
+ return { kind: "close" };
99
+ },
100
+ export: async ({ value, signal }) =>
101
+ (await options.exportPlan(value ?? "", signal)) ? { kind: "close" } : { kind: "rejected" },
102
+ save: async () => {
103
+ options.save();
104
+ return { kind: "close" };
105
+ },
106
+ stay: async () => {
107
+ options.stay();
108
+ return { kind: "close" };
109
+ },
110
+ exit: async () => {
111
+ options.exit();
112
+ return { kind: "close" };
113
+ },
114
+ },
115
+ });
116
+ await runMenu(ctx, menu, {
117
+ getState: () => undefined,
118
+ signal: options.signal,
119
+ isCurrent: options.isCurrent,
120
+ });
121
+ }
122
+
123
+ interface ReadyPlanMenuOptions extends MenuLifecycle {
124
+ implementationOutcome(): string;
125
+ getExportDestination: PlanExportDestinationProvider;
126
+ implementHere(): void | Promise<void>;
127
+ implementFresh(signal: AbortSignal): void | Promise<void>;
128
+ exportPlan(path: string, signal: AbortSignal): Promise<boolean>;
129
+ save(): void;
130
+ stay(): void;
131
+ exit(): void;
132
+ }
133
+
134
+ export async function showReadyPlanMenu(ctx: ExtensionContext, options: ReadyPlanMenuOptions) {
135
+ type Screen = "ready" | "export";
136
+ type Action = "implement-here" | "implement-fresh" | "export" | "save" | "stay" | "exit";
137
+ const menu = defineMenu<undefined, Screen, Action, ExtensionContext>({
138
+ start: "ready",
139
+ screens: {
140
+ ready: () => ({
141
+ kind: "actions",
142
+ title: "Proposed plan ready. What next?",
143
+ lines: [...IMPLEMENTATION_CONTEXT_LINES, options.implementationOutcome()],
144
+ items: [
145
+ {
146
+ id: "implement-here",
147
+ label: "Implement here",
148
+ description: "Continue in this session with the planning conversation.",
149
+ action: "implement-here",
150
+ },
151
+ {
152
+ id: "implement-fresh",
153
+ label: "Start fresh and implement",
154
+ description: "Open a new linked session; transfer only the approved plan.",
155
+ action: "implement-fresh",
156
+ busyLabel: "Starting fresh implementation session…",
157
+ },
158
+ { id: "export", label: "Export plan…", to: "export" },
159
+ { id: "save", label: "Save for later", action: "save" },
160
+ { id: "stay", label: "Stay in Plan mode", action: "stay" },
161
+ { id: "exit", label: "Discard plan and exit", action: "exit" },
162
+ ],
163
+ hint: "close",
164
+ }),
165
+ export: () => planExportInputScreen(options.getExportDestination),
166
+ },
167
+ actions: {
168
+ "implement-here": async () => {
169
+ await options.implementHere();
170
+ return { kind: "close" };
171
+ },
172
+ "implement-fresh": async ({ signal }) => {
173
+ await options.implementFresh(signal);
174
+ return { kind: "close" };
175
+ },
176
+ export: async ({ value, signal }) =>
177
+ (await options.exportPlan(value ?? "", signal)) ? { kind: "close" } : { kind: "rejected" },
178
+ save: async () => {
179
+ options.save();
180
+ return { kind: "close" };
181
+ },
182
+ stay: async () => {
183
+ options.stay();
184
+ return { kind: "close" };
185
+ },
186
+ exit: async () => {
187
+ options.exit();
188
+ return { kind: "close" };
189
+ },
190
+ },
191
+ });
192
+ await runMenu(ctx, menu, {
193
+ getState: () => undefined,
194
+ signal: options.signal,
195
+ isCurrent: options.isCurrent,
196
+ });
197
+ }
@@ -0,0 +1,38 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { exportStoredPlan, planExportDestination } from "./plan-export.js";
3
+ import { configuredPlanExportPath, type PlanModeSettings } from "./settings.js";
4
+ import type { PlanModeState } from "./state.js";
5
+
6
+ interface PlanExportControllerOptions {
7
+ getState(): PlanModeState;
8
+ getSettings(): PlanModeSettings;
9
+ finishReady(ctx: ExtensionContext): void;
10
+ }
11
+
12
+ export function createPlanExportController(options: PlanExportControllerOptions) {
13
+ return {
14
+ export(
15
+ path: string | undefined,
16
+ ctx: ExtensionContext,
17
+ signal: AbortSignal,
18
+ isCurrent: () => boolean,
19
+ ) {
20
+ const state = options.getState();
21
+ return exportStoredPlan(
22
+ state,
23
+ path,
24
+ ctx,
25
+ {
26
+ signal,
27
+ isCurrent,
28
+ getState: options.getState,
29
+ finishReady: () => options.finishReady(ctx),
30
+ },
31
+ configuredPlanExportPath(options.getSettings()),
32
+ );
33
+ },
34
+ getDestination(ctx: ExtensionContext) {
35
+ return planExportDestination(configuredPlanExportPath(options.getSettings()), ctx.cwd);
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,19 @@
1
+ import type { PlanExportDestination } from "./plan-export.js";
2
+
3
+ export type PlanExportDestinationProvider = () => PlanExportDestination;
4
+
5
+ export function planExportInputScreen(getDestination: PlanExportDestinationProvider) {
6
+ const destination = getDestination();
7
+ return {
8
+ kind: "input" as const,
9
+ title: "Export plan",
10
+ lines: [
11
+ "Existing paths are never overwritten.",
12
+ `Default: ${destination.configuredPath}`,
13
+ `Resolves to: ${destination.resolvedPath}`,
14
+ ],
15
+ placeholder: destination.configuredPath,
16
+ action: "export" as const,
17
+ hint: "back" as const,
18
+ };
19
+ }
@@ -0,0 +1,145 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { stripVTControlCharacters } from "node:util";
4
+ import { type ExtensionContext, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
+ import { DEFAULT_PLAN_EXPORT_PATH } from "./settings.js";
6
+ import type { PlanModeState } from "./state.js";
7
+
8
+ export { DEFAULT_PLAN_EXPORT_PATH };
9
+
10
+ export interface PlanExportResult {
11
+ path: string;
12
+ }
13
+
14
+ export interface PlanExportDestination {
15
+ configuredPath: string;
16
+ resolvedPath: string;
17
+ }
18
+
19
+ export interface PlanExportLifecycle {
20
+ signal: AbortSignal;
21
+ isCurrent(): boolean;
22
+ getState?(): PlanModeState;
23
+ finishReady?(): void;
24
+ }
25
+
26
+ export async function exportStoredPlan(
27
+ state: PlanModeState,
28
+ requestedPath: string | undefined,
29
+ ctx: ExtensionContext,
30
+ lifecycle?: PlanExportLifecycle,
31
+ defaultPath = DEFAULT_PLAN_EXPORT_PATH,
32
+ ) {
33
+ const plan =
34
+ (state.enabled ? state.latestPlan : undefined)?.trim() ??
35
+ state.savedPlan?.plan.trim() ??
36
+ state.activeImplementation?.plan.trim();
37
+ if (!plan) {
38
+ const error = new Error(
39
+ "No completed plan is available to export. Use /plan finalize when planning is complete.",
40
+ );
41
+ if (!ctx.hasUI) throw error;
42
+ ctx.ui.notify(error.message, "warning");
43
+ return false;
44
+ }
45
+
46
+ const isCurrent = () =>
47
+ !lifecycle ||
48
+ (lifecycle.isCurrent() && (!lifecycle.getState || lifecycle.getState() === state));
49
+ let result: PlanExportResult;
50
+ try {
51
+ result = await exportPlanToFile(
52
+ plan,
53
+ requestedPath,
54
+ ctx.cwd,
55
+ lifecycle?.signal,
56
+ isCurrent,
57
+ defaultPath,
58
+ );
59
+ } catch (error: unknown) {
60
+ if (lifecycle?.signal.aborted || !isCurrent()) return false;
61
+ if (!ctx.hasUI) throw error;
62
+ const detail = error instanceof Error ? error.message : String(error);
63
+ ctx.ui.notify(safeNotification(`Unable to export plan: ${detail}`), "error");
64
+ return false;
65
+ }
66
+
67
+ if (!isCurrent()) return false;
68
+ const finishedReady =
69
+ state.enabled && Boolean(state.latestPlan?.trim()) && lifecycle?.finishReady !== undefined;
70
+ if (finishedReady) lifecycle.finishReady?.();
71
+ const detail = finishedReady ? " Plan mode disabled." : "";
72
+ ctx.ui.notify(safeNotification(`Plan exported to ${result.path}.${detail}`), "info");
73
+ return true;
74
+ }
75
+
76
+ export async function exportPlanToFile(
77
+ plan: string,
78
+ requestedPath: string | undefined,
79
+ cwd: string,
80
+ signal?: AbortSignal,
81
+ isCurrent: () => boolean = () => true,
82
+ defaultPath = DEFAULT_PLAN_EXPORT_PATH,
83
+ ): Promise<PlanExportResult> {
84
+ const path = resolvePlanExportPath(requestedPath, cwd, defaultPath);
85
+ await withFileMutationQueue(path, async () => {
86
+ throwIfCancelled(signal, isCurrent);
87
+ await mkdir(dirname(path), { recursive: true });
88
+ throwIfCancelled(signal, isCurrent);
89
+ try {
90
+ await writeFile(path, `${plan}\n`, { encoding: "utf8", flag: "wx" });
91
+ } catch (error: unknown) {
92
+ if (isNodeError(error) && error.code === "EEXIST") {
93
+ throw new Error(
94
+ `Plan export target already exists: ${path}. Choose another path or remove it first.`,
95
+ );
96
+ }
97
+ throw error;
98
+ }
99
+ });
100
+ return { path };
101
+ }
102
+
103
+ export function planExportDestination(defaultPath: string, cwd: string): PlanExportDestination {
104
+ return {
105
+ configuredPath: safeNotification(defaultPath),
106
+ resolvedPath: safeNotification(resolvePlanExportPath(undefined, cwd, defaultPath)),
107
+ };
108
+ }
109
+
110
+ export function resolvePlanExportPath(
111
+ requestedPath: string | undefined,
112
+ cwd: string,
113
+ defaultPath = DEFAULT_PLAN_EXPORT_PATH,
114
+ ) {
115
+ const rawPath = requestedPath?.trim() || defaultPath;
116
+ const normalizedPath = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
117
+ if (!normalizedPath.trim()) throw new Error("Plan export path must not be empty.");
118
+ if (normalizedPath.includes("\0")) {
119
+ throw new Error("Plan export path must not contain NUL bytes.");
120
+ }
121
+ return resolve(cwd, normalizedPath);
122
+ }
123
+
124
+ function safeNotification(value: string) {
125
+ let sanitized = "";
126
+ for (const character of stripVTControlCharacters(value)) {
127
+ const codePoint = character.codePointAt(0);
128
+ sanitized +=
129
+ codePoint !== undefined && codePoint > 0x1f && !(codePoint >= 0x7f && codePoint <= 0x9f)
130
+ ? character
131
+ : " ";
132
+ }
133
+ return sanitized;
134
+ }
135
+
136
+ function throwIfCancelled(signal: AbortSignal | undefined, isCurrent: () => boolean) {
137
+ if (!signal?.aborted && isCurrent()) return;
138
+ throw signal?.reason instanceof Error
139
+ ? signal.reason
140
+ : new DOMException("Plan export cancelled", "AbortError");
141
+ }
142
+
143
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
144
+ return error instanceof Error && "code" in error;
145
+ }
@@ -0,0 +1,122 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
+
4
+ export interface PlanLaunchTool {
5
+ name: string;
6
+ description: string;
7
+ searchText: string;
8
+ disabled: boolean;
9
+ disabledReason?: string;
10
+ }
11
+
12
+ interface PlanLaunchMenuOptions {
13
+ statusText: string;
14
+ toolSummary(selectedNames: ReadonlySet<string>): string;
15
+ getSelectedNames(): ReadonlySet<string>;
16
+ tools: readonly PlanLaunchTool[];
17
+ signal: AbortSignal;
18
+ isCurrent(): boolean;
19
+ initialScreen?: "main" | "tools";
20
+ start(signal: AbortSignal): void;
21
+ startWithTools(toolNames: string[], signal: AbortSignal): void;
22
+ settings(signal: AbortSignal): Promise<boolean>;
23
+ }
24
+
25
+ export async function showPlanLaunchMenu(ctx: ExtensionContext, options: PlanLaunchMenuOptions) {
26
+ type Screen = "main" | "tools" | "help";
27
+ type Action = "start" | "toggle-tool" | "start-with-tools" | "settings";
28
+ const selectedNames = new Set(options.getSelectedNames());
29
+ let draftChanged = false;
30
+ const menu = defineMenu<undefined, Screen, Action, ExtensionContext>({
31
+ start: options.initialScreen ?? "main",
32
+ screens: {
33
+ main: () => ({
34
+ kind: "actions",
35
+ title: "Plan mode",
36
+ lines: [options.statusText, options.toolSummary(selectedNames)],
37
+ items: [
38
+ { id: "start", label: "Start Plan mode", action: "start" },
39
+ { id: "tools", label: "Choose tools, then start…", to: "tools" },
40
+ { id: "settings", label: "Settings", action: "settings" },
41
+ { id: "help", label: "How Plan mode works", to: "help" },
42
+ ],
43
+ hint: "close",
44
+ }),
45
+ tools: () => ({
46
+ kind: "multiSelect",
47
+ title: "Choose Plan-mode tools",
48
+ lines: [
49
+ "Changes apply only when you start Plan mode.",
50
+ "Non-built-in tools run at user risk.",
51
+ ],
52
+ enableSearch: true,
53
+ viewportSize: 10,
54
+ items: options.tools.map((tool) => ({
55
+ id: tool.name,
56
+ label: tool.name,
57
+ description: tool.description,
58
+ searchText: tool.searchText,
59
+ selected: selectedNames.has(tool.name),
60
+ disabled: tool.disabled,
61
+ disabledReason: tool.disabledReason,
62
+ })),
63
+ action: "toggle-tool",
64
+ actions: [
65
+ {
66
+ id: "start-with-tools",
67
+ label: "Done — start Plan mode",
68
+ action: "start-with-tools",
69
+ },
70
+ ],
71
+ hint: "back",
72
+ }),
73
+ help: () => ({
74
+ kind: "detail",
75
+ title: "How Plan mode works",
76
+ lines: [
77
+ "Plan mode uses read-only exploration to understand the project before implementation.",
78
+ "The agent can ask important decision questions, then returns a complete implementation-ready plan.",
79
+ "File mutation stays blocked until you explicitly choose to implement the completed plan.",
80
+ ],
81
+ hint: "back",
82
+ }),
83
+ },
84
+ actions: {
85
+ start: async ({ signal }) => {
86
+ if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
87
+ options.start(signal);
88
+ return { kind: "close" };
89
+ },
90
+ "toggle-tool": async ({ itemId, selected, signal }) => {
91
+ if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
92
+ const tool = options.tools.find((candidate) => candidate.name === itemId);
93
+ if (!tool || tool.disabled) return { kind: "rejected" };
94
+ if (selected) selectedNames.add(tool.name);
95
+ else selectedNames.delete(tool.name);
96
+ draftChanged = true;
97
+ return { kind: "stay" };
98
+ },
99
+ "start-with-tools": async ({ signal }) => {
100
+ if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
101
+ options.startWithTools(Array.from(selectedNames), signal);
102
+ return { kind: "close" };
103
+ },
104
+ settings: async ({ signal }) => {
105
+ if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
106
+ const close = await options.settings(signal);
107
+ if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
108
+ if (close) return { kind: "close" };
109
+ if (!draftChanged) {
110
+ selectedNames.clear();
111
+ for (const name of options.getSelectedNames()) selectedNames.add(name);
112
+ }
113
+ return { kind: "stay" };
114
+ },
115
+ },
116
+ });
117
+ await runMenu(ctx, menu, {
118
+ getState: () => undefined,
119
+ signal: options.signal,
120
+ isCurrent: options.isCurrent,
121
+ });
122
+ }