@signalridge/pi-goal 0.49.7

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/src/menu.ts ADDED
@@ -0,0 +1,854 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ActionMenuItem } from "@narumitw/pi-tui-kit";
3
+ import { formatTokenCount as formatCompactTokenCount, formatDuration } from "./accounting.js";
4
+ import { parseTokenBudget } from "./command.js";
5
+ import type { GoalCommandController } from "./commands.js";
6
+ import { notifyTerminal, safeGoalMenuText } from "./errors.js";
7
+
8
+ export { safeGoalMenuText } from "./errors.js";
9
+
10
+ import type { ActiveGoal, PendingQueueAction } from "./persistence.js";
11
+ import { goalQueueIdentity } from "./queue.js";
12
+ import { type GoalRuntime, goalSummary } from "./runtime.js";
13
+
14
+ export const GOAL_MENU_ACTIONS = {
15
+ start: "Start a goal…",
16
+ startBudget: "Start with token budget…",
17
+ pause: "Pause goal",
18
+ resume: "Resume goal",
19
+ reviewSafety: "Review and continue…",
20
+ increaseBudget: "Increase budget and resume…",
21
+ edit: "Edit goal…",
22
+ replace: "Replace goal…",
23
+ status: "View full status",
24
+ queue: "Queue…",
25
+ settings: "Settings…",
26
+ help: "Help",
27
+ clear: "Clear goal…",
28
+ close: "Close",
29
+ } as const;
30
+
31
+ const QUEUE_ACTIONS = {
32
+ add: "Add goal…",
33
+ prioritize: "Prioritize goal…",
34
+ skip: "Skip current goal…",
35
+ dropLast: "Drop last goal…",
36
+ back: "Back",
37
+ } as const;
38
+
39
+ interface GoalMenuRuntimeView {
40
+ activeGoal?: ActiveGoal;
41
+ queuedGoals: ActiveGoal[];
42
+ pendingQueueAction?: PendingQueueAction;
43
+ queueFrozen: boolean;
44
+ settings: GoalRuntime["settings"];
45
+ recordGoalUsage?: GoalRuntime["recordGoalUsage"];
46
+ persistGoal?: GoalRuntime["persistGoal"];
47
+ updateStatus?: GoalRuntime["updateStatus"];
48
+ }
49
+
50
+ export interface GoalMenuState {
51
+ title: string;
52
+ actions: string[];
53
+ }
54
+
55
+ type ShowSettings = (ctx: ExtensionCommandContext, target?: "automatic") => Promise<void>;
56
+ type GoalMenuScreen =
57
+ | "main"
58
+ | "start-budget"
59
+ | "start-custom-budget"
60
+ | "increase-budget"
61
+ | "safety"
62
+ | "queue"
63
+ | "status"
64
+ | "help";
65
+ type GoalMenuAction =
66
+ | "start"
67
+ | "start-with-budget"
68
+ | "start-with-custom-budget"
69
+ | "submit-increase-budget"
70
+ | "pause"
71
+ | "resume"
72
+ | "safety-resume"
73
+ | "safety-settings"
74
+ | "edit"
75
+ | "replace"
76
+ | "settings"
77
+ | "clear"
78
+ | "queue-add"
79
+ | "queue-prioritize"
80
+ | "queue-skip"
81
+ | "queue-drop"
82
+ | "back";
83
+
84
+ export function buildGoalMenuState(runtime: GoalMenuRuntimeView): GoalMenuState {
85
+ const goal = runtime.activeGoal;
86
+ const queueCount = runtime.queuedGoals.length;
87
+ const pausedByAutomaticLimit = goal?.status === "paused" && goal.safetyPauseCause === "continuation_limit";
88
+ const state = runtime.queueFrozen
89
+ ? "Queue frozen"
90
+ : runtime.pendingQueueAction
91
+ ? "Waiting for Pi to settle"
92
+ : pausedByAutomaticLimit
93
+ ? "Paused — automatic-work limit reached"
94
+ : displayStatus(goal?.status);
95
+ const automaticTurnLimit = runtime.settings.continuationLimits.automaticTurns;
96
+ const used = goal?.automaticModelTurns ?? 0;
97
+ const automaticResponses =
98
+ automaticTurnLimit === null
99
+ ? `Automatic work: ${used} responses · Unlimited`
100
+ : `Automatic work: ${used} of ${automaticTurnLimit} responses${
101
+ used < automaticTurnLimit ? ` · ${automaticTurnLimit - used} remaining` : ""
102
+ }`;
103
+ const title = goal
104
+ ? [
105
+ `Goal · ${state}`,
106
+ safeGoalMenuText(goal.text),
107
+ `Usage: ${
108
+ goal.tokenBudget === undefined
109
+ ? formatDuration(goal.timeUsedSeconds)
110
+ : `${formatTokenCount(goal.tokensUsed)}/${formatTokenCount(goal.tokenBudget)}`
111
+ }`,
112
+ automaticResponses,
113
+ ...(pausedByAutomaticLimit ? ["Progress is saved. Review the safety limit before continuing."] : []),
114
+ ...(queueCount > 0 ? [`Queue: ${queueCount} queued`] : []),
115
+ ].join("\n")
116
+ : [
117
+ `Goal · ${state}`,
118
+ "No goal is currently set",
119
+ automaticTurnLimit === null
120
+ ? "Automatic work is configured as Unlimited."
121
+ : `Automatic work is configured to pause after ${automaticTurnLimit} responses.`,
122
+ ].join("\n");
123
+
124
+ if (runtime.queueFrozen || runtime.pendingQueueAction) {
125
+ return {
126
+ title,
127
+ actions: [
128
+ GOAL_MENU_ACTIONS.status,
129
+ GOAL_MENU_ACTIONS.settings,
130
+ GOAL_MENU_ACTIONS.help,
131
+ GOAL_MENU_ACTIONS.clear,
132
+ GOAL_MENU_ACTIONS.close,
133
+ ],
134
+ };
135
+ }
136
+
137
+ const actions: string[] = [];
138
+ if (!goal || goal.status === "complete") {
139
+ actions.push(GOAL_MENU_ACTIONS.start, GOAL_MENU_ACTIONS.startBudget);
140
+ } else if (goal.status === "active") {
141
+ actions.push(GOAL_MENU_ACTIONS.pause);
142
+ } else if (goal.status === "budget_limited") {
143
+ actions.push(GOAL_MENU_ACTIONS.increaseBudget);
144
+ } else if (pausedByAutomaticLimit) {
145
+ actions.push(GOAL_MENU_ACTIONS.reviewSafety);
146
+ } else {
147
+ actions.push(GOAL_MENU_ACTIONS.resume);
148
+ }
149
+ if (goal && goal.status !== "complete") {
150
+ actions.push(GOAL_MENU_ACTIONS.edit, GOAL_MENU_ACTIONS.replace);
151
+ }
152
+ if (goal) actions.push(GOAL_MENU_ACTIONS.status);
153
+ if (goal && (runtime.settings.experimental.goals || queueCount > 0)) {
154
+ actions.push(GOAL_MENU_ACTIONS.queue);
155
+ }
156
+ actions.push(GOAL_MENU_ACTIONS.settings, GOAL_MENU_ACTIONS.help);
157
+ if (goal) actions.push(GOAL_MENU_ACTIONS.clear);
158
+ actions.push(GOAL_MENU_ACTIONS.close);
159
+ return { title, actions };
160
+ }
161
+
162
+ export async function showGoalManager(
163
+ runtime: GoalMenuRuntimeView,
164
+ commands: GoalCommandController,
165
+ ctx: ExtensionCommandContext,
166
+ showSettings: ShowSettings,
167
+ ): Promise<void> {
168
+ if (ctx.mode !== "tui") {
169
+ commands.showGoal(ctx);
170
+ return;
171
+ }
172
+ const owner = runtime as GoalRuntime;
173
+ const generation = owner.menuGeneration;
174
+ const ownerSignal = owner.menuController?.signal;
175
+ const isMenuCurrent = () =>
176
+ owner.menuController === undefined || (generation === owner.menuGeneration && !owner.menuController.signal.aborted);
177
+ const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
178
+ if (!isMenuCurrent()) return;
179
+ let displayedGoal: ActiveGoal | undefined;
180
+ let startBudgetQueueIdentity = currentGoalQueueIdentity(runtime);
181
+ let displayedBudgetGoal: ActiveGoal | undefined;
182
+ let displayedBudgetValue: number | undefined;
183
+ let displayedBudgetUsage: number | undefined;
184
+ let displayedBudgetStatus: ActiveGoal["status"] | undefined;
185
+ let displayedQueueHead: ActiveGoal | undefined;
186
+ let displayedQueueFirst: ActiveGoal | undefined;
187
+ let displayedQueueLast: ActiveGoal | undefined;
188
+ const menu = defineMenu<undefined, GoalMenuScreen, GoalMenuAction, ExtensionCommandContext>({
189
+ start: "main",
190
+ screens: {
191
+ main: () => {
192
+ refreshGoalMenuState(runtime, ctx);
193
+ const state = buildGoalMenuState(runtime);
194
+ displayedGoal = runtime.activeGoal;
195
+ startBudgetQueueIdentity = currentGoalQueueIdentity(runtime);
196
+ return {
197
+ kind: "actions",
198
+ title: "Goal",
199
+ lines: state.title.split("\n").slice(1),
200
+ items: state.actions.map(goalMainMenuItem),
201
+ hint: "close",
202
+ };
203
+ },
204
+ "start-budget": () => {
205
+ return {
206
+ kind: "actions",
207
+ title: "Choose token budget",
208
+ lines: tokenBudgetGuidance(runtime.settings.continuationLimits.automaticTurns),
209
+ items: [
210
+ {
211
+ id: "25k",
212
+ label: "25k — Lower token ceiling",
213
+ description: "Set the cumulative token limit to 25k.",
214
+ action: "start-with-budget",
215
+ },
216
+ {
217
+ id: "100k",
218
+ label: "100k — Suggested",
219
+ description: "Set the cumulative token limit to 100k.",
220
+ action: "start-with-budget",
221
+ },
222
+ {
223
+ id: "300k",
224
+ label: "300k — Higher token ceiling",
225
+ description: "Set the cumulative token limit to 300k.",
226
+ action: "start-with-budget",
227
+ },
228
+ {
229
+ id: "custom",
230
+ label: "Set a custom budget…",
231
+ description: "Enter an exact cumulative token limit.",
232
+ to: "start-custom-budget",
233
+ },
234
+ { id: "back", label: "Back", action: "back" },
235
+ ],
236
+ hint: "back",
237
+ };
238
+ },
239
+ "start-custom-budget": () => ({
240
+ kind: "input",
241
+ title: "Custom token budget",
242
+ lines: customTokenBudgetGuidance(runtime.settings.continuationLimits.automaticTurns),
243
+ placeholder: "100k",
244
+ action: "start-with-custom-budget",
245
+ hint: "back",
246
+ }),
247
+ "increase-budget": () => {
248
+ const goal = runtime.activeGoal;
249
+ displayedBudgetGoal = goal;
250
+ displayedBudgetValue = goal?.tokenBudget;
251
+ displayedBudgetUsage = goal?.tokensUsed;
252
+ displayedBudgetStatus = goal?.status;
253
+ if (goal && goal.tokensUsed >= Number.MAX_SAFE_INTEGER) {
254
+ return {
255
+ kind: "detail",
256
+ title: "Increase token budget unavailable",
257
+ lines: [
258
+ `Current usage: ${formatBudgetDecisionValue(goal.tokensUsed)}`,
259
+ "No larger safe whole-number token budget is available. Progress remains saved; choose Back and clear or replace the goal when ready.",
260
+ ],
261
+ hint: "back",
262
+ };
263
+ }
264
+ return {
265
+ kind: "input",
266
+ title: "Increase token budget",
267
+ lines: goal
268
+ ? increaseTokenBudgetGuidance(goal, runtime.settings.continuationLimits.automaticTurns)
269
+ : ["The budget-limited goal is no longer available. Return to the Goal menu."],
270
+ placeholder: goal ? suggestedIncreasedBudget(goal) : "300k",
271
+ action: "submit-increase-budget",
272
+ hint: "back",
273
+ };
274
+ },
275
+ safety: () => {
276
+ const goal = runtime.activeGoal;
277
+ displayedGoal = goal;
278
+ const limit = runtime.settings.continuationLimits.automaticTurns;
279
+ const used = goal?.automaticModelTurns ?? 0;
280
+ const queueCount = runtime.queuedGoals.length;
281
+ return {
282
+ kind: "actions",
283
+ title: "Automatic work paused",
284
+ lines: goal
285
+ ? [
286
+ automaticPauseSummary(used, limit),
287
+ `${safeGoalMenuText(goal.text)} is preserved.`,
288
+ `${formatInteger(goal.tokensUsed)} cumulative tokens and ${formatDuration(goal.timeUsedSeconds)} active time are preserved.`,
289
+ `The objective, usage, and ${queueCount} queued ${queueCount === 1 ? "goal is" : "goals are"} preserved.`,
290
+ limit === null
291
+ ? "Continuing resets the counter to 0 and resumes with Unlimited automatic work."
292
+ : `Continuing resets the counter to 0 and allows up to ${limit} more automatic model responses.`,
293
+ ]
294
+ : ["The paused goal is no longer available. Return to the Goal menu."],
295
+ items: goal
296
+ ? [
297
+ {
298
+ id: "continue",
299
+ label: limit === null ? "Continue — Unlimited" : `Continue — up to ${limit} more responses`,
300
+ action: "safety-resume" as const,
301
+ },
302
+ {
303
+ id: "settings",
304
+ label: "Change automatic-work limit…",
305
+ action: "safety-settings" as const,
306
+ },
307
+ { id: "back", label: "Back", action: "back" as const },
308
+ ]
309
+ : [{ id: "back", label: "Back", action: "back" as const }],
310
+ hint: "back",
311
+ };
312
+ },
313
+ queue: () => {
314
+ displayedQueueHead = runtime.activeGoal;
315
+ displayedQueueFirst = runtime.queuedGoals[0];
316
+ displayedQueueLast = runtime.queuedGoals.at(-1) ?? runtime.activeGoal;
317
+ return {
318
+ kind: "actions",
319
+ title: "Goal queue",
320
+ lines: [
321
+ `${runtime.queuedGoals.length + (runtime.activeGoal ? 1 : 0)} total`,
322
+ ...(runtime.activeGoal ? [`Current: ${safeGoalMenuText(runtime.activeGoal.text)}`] : []),
323
+ ],
324
+ items: [
325
+ { id: "add", label: QUEUE_ACTIONS.add, action: "queue-add" },
326
+ { id: "prioritize", label: QUEUE_ACTIONS.prioritize, action: "queue-prioritize" },
327
+ ...(runtime.queuedGoals.length > 0
328
+ ? [
329
+ { id: "skip", label: QUEUE_ACTIONS.skip, action: "queue-skip" as const },
330
+ {
331
+ id: "drop-last",
332
+ label: QUEUE_ACTIONS.dropLast,
333
+ action: "queue-drop" as const,
334
+ },
335
+ ]
336
+ : []),
337
+ { id: "back", label: QUEUE_ACTIONS.back, action: "back" },
338
+ ],
339
+ hint: "back",
340
+ };
341
+ },
342
+ status: () => ({
343
+ kind: "detail",
344
+ title: "Goal status",
345
+ lines: runtime.activeGoal
346
+ ? goalSummary(
347
+ runtime.activeGoal,
348
+ runtime.queuedGoals,
349
+ runtime.settings.experimental.goals,
350
+ runtime.queueFrozen,
351
+ runtime.pendingQueueAction,
352
+ runtime.settings.continuationLimits.automaticTurns,
353
+ ).split("\n")
354
+ : ["No goal is currently set."],
355
+ hint: "back",
356
+ }),
357
+ help: () => ({
358
+ kind: "detail",
359
+ title: "Goal help",
360
+ lines: goalHelp().split("\n").slice(1),
361
+ hint: "back",
362
+ }),
363
+ },
364
+ actions: {
365
+ start: async () => {
366
+ await startFromMenu(commands, ctx);
367
+ return { kind: "close" };
368
+ },
369
+ "start-with-budget": async ({ itemId, signal }) => {
370
+ const budget = parseTokenBudget(itemId);
371
+ if (budget === undefined) return { kind: "rejected" };
372
+ return startBudgetedGoal(
373
+ runtime,
374
+ commands,
375
+ ctx,
376
+ budget,
377
+ runtime.settings.continuationLimits.automaticTurns,
378
+ startBudgetQueueIdentity,
379
+ signal,
380
+ isMenuCurrent,
381
+ "stay",
382
+ );
383
+ },
384
+ "start-with-custom-budget": async ({ value, signal }) => {
385
+ const budget = parseTokenBudget(value ?? "");
386
+ if (budget === undefined) {
387
+ notifyTerminal(ctx.ui, "Enter a positive token amount, for example 25k, 300k, or 1.5m.", "warning");
388
+ return { kind: "rejected" };
389
+ }
390
+ return startBudgetedGoal(
391
+ runtime,
392
+ commands,
393
+ ctx,
394
+ budget,
395
+ runtime.settings.continuationLimits.automaticTurns,
396
+ startBudgetQueueIdentity,
397
+ signal,
398
+ isMenuCurrent,
399
+ "back",
400
+ );
401
+ },
402
+ "submit-increase-budget": async ({ value, signal }) => {
403
+ const goal = displayedBudgetGoal;
404
+ const budget = parseTokenBudget(value ?? "");
405
+ if (budget === undefined) {
406
+ notifyTerminal(ctx.ui, "Enter a positive token amount, for example 300k, 1.5m, or 300000.", "warning");
407
+ return { kind: "rejected" };
408
+ }
409
+ if (
410
+ !goal ||
411
+ !requireCurrentBudgetPreview(
412
+ runtime,
413
+ goal,
414
+ displayedBudgetValue,
415
+ displayedBudgetUsage,
416
+ displayedBudgetStatus,
417
+ ctx,
418
+ )
419
+ ) {
420
+ return { kind: "close" };
421
+ }
422
+ if (budget <= goal.tokensUsed) {
423
+ notifyTerminal(
424
+ ctx.ui,
425
+ `Enter a new cumulative total greater than current usage (${formatCompactTokenCount(goal.tokensUsed)}).`,
426
+ "warning",
427
+ );
428
+ return { kind: "rejected" };
429
+ }
430
+ const confirmed = await ctx.ui.confirm(
431
+ "Increase goal budget?",
432
+ increaseBudgetPreview(goal, budget, runtime.settings.continuationLimits.automaticTurns),
433
+ );
434
+ if (signal.aborted || !isMenuCurrent()) return { kind: "close" };
435
+ if (!confirmed) return { kind: "rejected" };
436
+ if (
437
+ !requireCurrentBudgetPreview(
438
+ runtime,
439
+ goal,
440
+ displayedBudgetValue,
441
+ displayedBudgetUsage,
442
+ displayedBudgetStatus,
443
+ ctx,
444
+ )
445
+ ) {
446
+ return { kind: "close" };
447
+ }
448
+ await commands.editGoal(goal.text, budget, ctx);
449
+ return { kind: "close" };
450
+ },
451
+ pause: async () => {
452
+ if (!displayedGoal || !requireCurrentMenuGoal(runtime, displayedGoal, ctx)) {
453
+ return { kind: "stay" };
454
+ }
455
+ commands.pauseGoal(ctx);
456
+ return { kind: "close" };
457
+ },
458
+ resume: async () => {
459
+ if (!displayedGoal || !requireCurrentMenuGoal(runtime, displayedGoal, ctx)) {
460
+ return { kind: "stay" };
461
+ }
462
+ await commands.resumeGoal(ctx);
463
+ return { kind: "close" };
464
+ },
465
+ "safety-resume": async () => {
466
+ if (!displayedGoal || !requireCurrentMenuGoal(runtime, displayedGoal, ctx)) {
467
+ return { kind: "stay" };
468
+ }
469
+ await commands.resumeGoal(ctx);
470
+ return { kind: "close" };
471
+ },
472
+ "safety-settings": async () => {
473
+ if (!displayedGoal || !requireCurrentMenuGoal(runtime, displayedGoal, ctx)) {
474
+ return { kind: "stay" };
475
+ }
476
+ const expectedGoal = displayedGoal;
477
+ await showSettings(ctx, "automatic");
478
+ if (!isMenuCurrent()) return { kind: "close" };
479
+ if (!requireCurrentMenuGoal(runtime, expectedGoal, ctx)) return { kind: "stay" };
480
+ return { kind: "stay" };
481
+ },
482
+ edit: async () => {
483
+ if (!displayedGoal || !requireCurrentMenuGoal(runtime, displayedGoal, ctx)) {
484
+ return { kind: "stay" };
485
+ }
486
+ await editFromMenu(runtime, commands, ctx);
487
+ return { kind: "close" };
488
+ },
489
+ replace: async () => {
490
+ await startFromMenu(commands, ctx);
491
+ return { kind: "close" };
492
+ },
493
+ settings: async () => {
494
+ await showSettings(ctx);
495
+ return { kind: "stay" };
496
+ },
497
+ clear: async () => {
498
+ const previewedQueue = goalQueueIdentity(runtime.activeGoal, runtime.queuedGoals, runtime.pendingQueueAction);
499
+ if (!(await confirmClear(runtime, ctx))) return { kind: "stay" };
500
+ if (goalQueueIdentity(runtime.activeGoal, runtime.queuedGoals, runtime.pendingQueueAction) !== previewedQueue) {
501
+ notifyTerminal(
502
+ ctx.ui,
503
+ "The goal queue changed while the dialog was open. Reopen /goal and try again.",
504
+ "warning",
505
+ );
506
+ return { kind: "stay" };
507
+ }
508
+ commands.clearGoal(ctx);
509
+ return { kind: "close" };
510
+ },
511
+ "queue-add": async () => {
512
+ const objective = (await ctx.ui.editor("Add goal to queue", ""))?.trim();
513
+ if (objective) await commands.addGoal(objective, undefined, ctx);
514
+ return { kind: "close" };
515
+ },
516
+ "queue-prioritize": async () => {
517
+ const goal = displayedQueueHead;
518
+ if (!goal) return { kind: "stay" };
519
+ const objective = (await ctx.ui.editor("Prioritize goal", ""))?.trim();
520
+ if (!objective || !requireCurrentQueueHead(runtime, goal, ctx)) return { kind: "stay" };
521
+ const confirmed = await ctx.ui.confirm(
522
+ "Prioritize goal?",
523
+ `New priority goal:\n${safeGoalMenuText(objective, 4_000)}\n\nCurrent goal moved to the queue:\n${safeGoalMenuText(goal.text, 4_000)}`,
524
+ );
525
+ if (confirmed && requireCurrentQueueHead(runtime, goal, ctx)) {
526
+ await commands.prioritizeGoal(objective, undefined, ctx);
527
+ }
528
+ return { kind: "close" };
529
+ },
530
+ "queue-skip": async () => {
531
+ const goal = displayedQueueHead;
532
+ if (!goal) return { kind: "stay" };
533
+ const next = displayedQueueFirst;
534
+ const nextEffect = !next
535
+ ? "No goal remains"
536
+ : next.status === "queued"
537
+ ? `Start next goal:\n${safeGoalMenuText(next.text, 4_000)}`
538
+ : `Next goal remains ${displayStatus(next.status).toLowerCase()}:\n${safeGoalMenuText(next.text, 4_000)}`;
539
+ const confirmed = await ctx.ui.confirm(
540
+ "Skip current goal?",
541
+ `Remove current goal:\n${safeGoalMenuText(goal.text, 4_000)}\n\n${nextEffect}`,
542
+ );
543
+ if (confirmed && requireCurrentQueueSelection(runtime, goal, next, "first", ctx)) {
544
+ await commands.skipGoal(ctx);
545
+ }
546
+ return { kind: "close" };
547
+ },
548
+ "queue-drop": async () => {
549
+ const goal = displayedQueueHead;
550
+ const last = displayedQueueLast;
551
+ if (!goal || !last) return { kind: "stay" };
552
+ const confirmed = await ctx.ui.confirm(
553
+ "Drop last goal?",
554
+ `Remove from queue:\n${safeGoalMenuText(last.text, 4_000)}`,
555
+ );
556
+ if (confirmed && requireCurrentQueueSelection(runtime, goal, last, "last", ctx)) {
557
+ commands.dropLastGoal(ctx);
558
+ }
559
+ return { kind: "close" };
560
+ },
561
+ back: async () => ({ kind: "back" }),
562
+ },
563
+ });
564
+ await runMenu(ctx, menu, {
565
+ getState: () => undefined,
566
+ signal: ownerSignal,
567
+ isCurrent: isMenuCurrent,
568
+ });
569
+ }
570
+
571
+ function goalMainMenuItem(label: string): ActionMenuItem<GoalMenuScreen, GoalMenuAction> {
572
+ if (label === GOAL_MENU_ACTIONS.status) return { id: "status", label, to: "status" as const };
573
+ if (label === GOAL_MENU_ACTIONS.startBudget) {
574
+ return { id: "start-budget", label, to: "start-budget" as const };
575
+ }
576
+ if (label === GOAL_MENU_ACTIONS.increaseBudget) {
577
+ return { id: "increase-budget", label, to: "increase-budget" as const };
578
+ }
579
+ if (label === GOAL_MENU_ACTIONS.reviewSafety) {
580
+ return { id: "review-safety", label, to: "safety" as const };
581
+ }
582
+ if (label === GOAL_MENU_ACTIONS.queue) return { id: "queue", label, to: "queue" as const };
583
+ if (label === GOAL_MENU_ACTIONS.help) return { id: "help", label, to: "help" as const };
584
+ if (label === GOAL_MENU_ACTIONS.close) return { id: "close", label, close: true as const };
585
+ const actions = new Map<string, GoalMenuAction>([
586
+ [GOAL_MENU_ACTIONS.start, "start"],
587
+ [GOAL_MENU_ACTIONS.pause, "pause"],
588
+ [GOAL_MENU_ACTIONS.resume, "resume"],
589
+ [GOAL_MENU_ACTIONS.edit, "edit"],
590
+ [GOAL_MENU_ACTIONS.replace, "replace"],
591
+ [GOAL_MENU_ACTIONS.settings, "settings"],
592
+ [GOAL_MENU_ACTIONS.clear, "clear"],
593
+ ]);
594
+ return { id: actions.get(label) ?? label, label, action: actions.get(label) ?? "settings" };
595
+ }
596
+
597
+ function refreshGoalMenuState(runtime: GoalMenuRuntimeView, ctx: ExtensionCommandContext) {
598
+ const goal = runtime.activeGoal;
599
+ if (!goal || runtime.queueFrozen) return;
600
+ runtime.recordGoalUsage?.(goal, ctx);
601
+ runtime.persistGoal?.(goal);
602
+ runtime.updateStatus?.(ctx, goal);
603
+ }
604
+
605
+ async function startFromMenu(commands: GoalCommandController, ctx: ExtensionCommandContext) {
606
+ const objective = (await ctx.ui.editor("Goal objective", ""))?.trim();
607
+ if (!objective) return;
608
+ await commands.startGoal(objective, undefined, ctx);
609
+ }
610
+
611
+ async function startBudgetedGoal(
612
+ runtime: GoalMenuRuntimeView,
613
+ commands: GoalCommandController,
614
+ ctx: ExtensionCommandContext,
615
+ budget: number,
616
+ automaticLimit: number | null,
617
+ expectedQueueIdentity: string,
618
+ signal: AbortSignal,
619
+ isMenuCurrent: () => boolean,
620
+ cancelTransition: "stay" | "back",
621
+ ) {
622
+ if (!requireCurrentStartBudgetQueue(runtime, expectedQueueIdentity, ctx)) {
623
+ return { kind: "rejected" as const };
624
+ }
625
+ const objective = (
626
+ await ctx.ui.editor(
627
+ `Goal objective · Token budget ${formatCompactTokenCount(budget)} · ${automaticLimit === null ? "Automatic Unlimited" : `Automatic limit ${automaticLimit}`}`,
628
+ "",
629
+ )
630
+ )?.trim();
631
+ if (signal.aborted || !isMenuCurrent()) return { kind: "close" as const };
632
+ if (!objective) return { kind: cancelTransition } as const;
633
+ if (!requireCurrentStartBudgetQueue(runtime, expectedQueueIdentity, ctx)) {
634
+ return { kind: "rejected" as const };
635
+ }
636
+ await commands.startGoal(
637
+ objective,
638
+ budget,
639
+ ctx,
640
+ undefined,
641
+ () => !signal.aborted && isMenuCurrent(),
642
+ () => !signal.aborted && isMenuCurrent(),
643
+ );
644
+ return { kind: "close" as const };
645
+ }
646
+
647
+ function tokenBudgetGuidance(automaticLimit: number | null) {
648
+ return [
649
+ "Set the maximum cumulative token usage for this goal.",
650
+ "The final model call may exceed the limit; this is not a dollar-cost cap.",
651
+ automaticBudgetGuidance(automaticLimit),
652
+ ];
653
+ }
654
+
655
+ function customTokenBudgetGuidance(automaticLimit: number | null) {
656
+ return [
657
+ "Enter the maximum cumulative token usage for this goal.",
658
+ "Examples: 25k, 300k, 1.5m, or 300000.",
659
+ "The final model call may exceed this value; this is not a dollar-cost cap.",
660
+ automaticBudgetGuidance(automaticLimit),
661
+ ];
662
+ }
663
+
664
+ function automaticBudgetGuidance(automaticLimit: number | null) {
665
+ return automaticLimit === null
666
+ ? "Automatic work has no response-count cap."
667
+ : `Automatic work will also pause after ${automaticLimit} responses.`;
668
+ }
669
+
670
+ function increaseTokenBudgetGuidance(goal: ActiveGoal, automaticLimit: number | null) {
671
+ return [
672
+ `Current budget: ${formatBudgetDecisionValue(goal.tokenBudget ?? 0)}`,
673
+ `Current usage: ${formatBudgetDecisionValue(goal.tokensUsed)}`,
674
+ `Enter a new cumulative total greater than ${formatBudgetDecisionValue(goal.tokensUsed)}.`,
675
+ "Examples: 300k, 1.5m, or 300000.",
676
+ "The final model call may exceed the limit; this is not a dollar-cost cap.",
677
+ automaticBudgetGuidance(automaticLimit),
678
+ ];
679
+ }
680
+
681
+ function suggestedIncreasedBudget(goal: ActiveGoal) {
682
+ const floor = Math.max(goal.tokensUsed, goal.tokenBudget ?? 0);
683
+ for (const suggestion of [25_000, 100_000, 300_000, 500_000, 1_000_000]) {
684
+ if (suggestion > floor) return formatCompactTokenCount(suggestion);
685
+ }
686
+ return formatCompactTokenCount(
687
+ Math.min(Number.MAX_SAFE_INTEGER, Math.max(Math.floor(floor) + 1, Math.ceil(floor * 2))),
688
+ );
689
+ }
690
+
691
+ function formatBudgetDecisionValue(value: number) {
692
+ const compact = formatCompactTokenCount(value);
693
+ if (value < 1_000 || value % 1_000 === 0) return compact;
694
+ return `${compact} (${formatInteger(value)} tokens)`;
695
+ }
696
+
697
+ function increaseBudgetPreview(goal: ActiveGoal, budget: number, automaticLimit: number | null) {
698
+ return [
699
+ `Goal: ${safeGoalMenuText(goal.text, 4_000)}`,
700
+ `Budget: ${formatCompactTokenCount(goal.tokenBudget ?? 0)} → ${formatCompactTokenCount(budget)}`,
701
+ `Current usage: ${formatCompactTokenCount(goal.tokensUsed)}`,
702
+ automaticLimit === null
703
+ ? "Automatic work: Unlimited after resume"
704
+ : `Automatic work: up to ${automaticLimit} more responses after resume`,
705
+ "The goal will resume immediately.",
706
+ ].join("\n");
707
+ }
708
+
709
+ async function editFromMenu(
710
+ runtime: GoalMenuRuntimeView,
711
+ commands: GoalCommandController,
712
+ ctx: ExtensionCommandContext,
713
+ ) {
714
+ const goal = runtime.activeGoal;
715
+ if (!goal) return;
716
+ const objective = (await ctx.ui.editor("Edit goal objective", goal.text))?.trim();
717
+ if (!objective || objective === goal.text) return;
718
+ if (!requireCurrentMenuGoal(runtime, goal, ctx)) return;
719
+ if (goal.status === "active") {
720
+ const confirmed = await ctx.ui.confirm(
721
+ "Apply goal edit?",
722
+ `Current goal:\n${safeGoalMenuText(goal.text, 4_000)}\n\nUpdated goal:\n${safeGoalMenuText(objective, 4_000)}\n\nApplying this edit starts a new guarded goal instance.`,
723
+ );
724
+ if (!confirmed || !requireCurrentMenuGoal(runtime, goal, ctx)) return;
725
+ }
726
+ await commands.editGoal(objective, undefined, ctx);
727
+ }
728
+
729
+ async function confirmClear(runtime: GoalMenuRuntimeView, ctx: ExtensionCommandContext) {
730
+ const goals = [runtime.activeGoal, ...runtime.queuedGoals].filter((goal): goal is ActiveGoal => goal !== undefined);
731
+ const pendingPriority =
732
+ runtime.pendingQueueAction?.kind === "prioritize" ? runtime.pendingQueueAction.objective : undefined;
733
+ const summaries = [
734
+ ...goals.map((goal) => safeGoalMenuText(goal.text, 4_000)),
735
+ ...(pendingPriority ? [`Pending priority: ${safeGoalMenuText(pendingPriority, 4_000)}`] : []),
736
+ ];
737
+ if (summaries.length === 0) return false;
738
+ return ctx.ui.confirm(
739
+ summaries.length > 1 ? "Clear goal queue?" : "Clear goal?",
740
+ `Remove ${summaries.length === 1 ? "this goal" : `all ${summaries.length} goals`}:\n\n${summaries
741
+ .map((summary, index) => `${index + 1}. ${summary}`)
742
+ .join("\n")}\n\nThis cannot be undone.`,
743
+ );
744
+ }
745
+
746
+ function currentGoalQueueIdentity(runtime: GoalMenuRuntimeView) {
747
+ return goalQueueIdentity(runtime.activeGoal, runtime.queuedGoals, runtime.pendingQueueAction);
748
+ }
749
+
750
+ function requireCurrentStartBudgetQueue(
751
+ runtime: GoalMenuRuntimeView,
752
+ expectedIdentity: string,
753
+ ctx: ExtensionCommandContext,
754
+ ) {
755
+ if (currentGoalQueueIdentity(runtime) === expectedIdentity) {
756
+ return true;
757
+ }
758
+ notifyTerminal(
759
+ ctx.ui,
760
+ "The goal queue changed while the token budget flow was open. Reopen /goal and try again.",
761
+ "warning",
762
+ );
763
+ return false;
764
+ }
765
+
766
+ function requireCurrentBudgetPreview(
767
+ runtime: GoalMenuRuntimeView,
768
+ expectedGoal: ActiveGoal,
769
+ expectedBudget: number | undefined,
770
+ expectedUsage: number | undefined,
771
+ expectedStatus: ActiveGoal["status"] | undefined,
772
+ ctx: ExtensionCommandContext,
773
+ ) {
774
+ const current = runtime.activeGoal;
775
+ if (
776
+ current?.id === expectedGoal.id &&
777
+ current.tokenBudget === expectedBudget &&
778
+ current.tokensUsed === expectedUsage &&
779
+ current.status === expectedStatus
780
+ ) {
781
+ return true;
782
+ }
783
+ notifyTerminal(
784
+ ctx.ui,
785
+ "The goal changed or its usage changed while the budget dialog was open. Reopen /goal and try again.",
786
+ "warning",
787
+ );
788
+ return false;
789
+ }
790
+
791
+ function requireCurrentQueueHead(runtime: GoalMenuRuntimeView, expectedGoal: ActiveGoal, ctx: ExtensionCommandContext) {
792
+ if (runtime.activeGoal?.id === expectedGoal.id) return true;
793
+ notifyTerminal(ctx.ui, "The goal queue changed while the dialog was open. Reopen /goal and try again.", "warning");
794
+ return false;
795
+ }
796
+
797
+ function requireCurrentQueueSelection(
798
+ runtime: GoalMenuRuntimeView,
799
+ expectedGoal: ActiveGoal,
800
+ expectedQueuedGoal: ActiveGoal | undefined,
801
+ position: "first" | "last",
802
+ ctx: ExtensionCommandContext,
803
+ ) {
804
+ const currentQueuedGoal =
805
+ position === "first" ? runtime.queuedGoals[0] : (runtime.queuedGoals.at(-1) ?? runtime.activeGoal);
806
+ if (runtime.activeGoal?.id === expectedGoal.id && currentQueuedGoal?.id === expectedQueuedGoal?.id) {
807
+ return true;
808
+ }
809
+ notifyTerminal(ctx.ui, "The goal queue changed while the dialog was open. Reopen /goal and try again.", "warning");
810
+ return false;
811
+ }
812
+
813
+ function requireCurrentMenuGoal(runtime: GoalMenuRuntimeView, expected: ActiveGoal, ctx: ExtensionCommandContext) {
814
+ if (runtime.activeGoal?.id === expected.id) return true;
815
+ notifyTerminal(ctx.ui, "The active goal changed while the dialog was open. Reopen /goal and try again.", "warning");
816
+ return false;
817
+ }
818
+
819
+ function displayStatus(status?: ActiveGoal["status"]) {
820
+ if (!status) return "No goal";
821
+ if (status === "usage_limited") return "Usage limited";
822
+ if (status === "budget_limited") return "Budget limited";
823
+ return status[0]?.toUpperCase() + status.slice(1);
824
+ }
825
+
826
+ function formatTokenCount(tokens: number) {
827
+ return String(tokens);
828
+ }
829
+
830
+ function formatInteger(value: number) {
831
+ return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
832
+ }
833
+
834
+ function automaticPauseSummary(used: number, limit: number | null) {
835
+ if (limit === null) {
836
+ return `Goal paused after ${used} responses at its previous safety limit. Current limit: Unlimited.`;
837
+ }
838
+ if (used < limit) {
839
+ return `Goal paused after ${used} responses at its previous safety limit. Current automatic-work limit: ${limit}.`;
840
+ }
841
+ return `Goal reached its ${used}-of-${limit} safety limit.`;
842
+ }
843
+
844
+ function goalHelp() {
845
+ return [
846
+ "Goal menu",
847
+ "Use the menu for guided status, edits, queue management, settings, and confirmations.",
848
+ "Direct routes remain available for deterministic workflows:",
849
+ "/goal <objective>",
850
+ "/goal status | pause | resume | edit | clear",
851
+ "/goal --tokens 100k <objective>",
852
+ "Escape cancels the current menu or input without changing goal state.",
853
+ ].join("\n");
854
+ }