@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +3799 -3729
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/cli/bench-cli.d.ts +1 -7
  5. package/dist/types/cli/usage-cli.d.ts +1 -0
  6. package/dist/types/commands/usage.d.ts +7 -0
  7. package/dist/types/config/settings-schema.d.ts +19 -9
  8. package/dist/types/config/settings.d.ts +3 -2
  9. package/dist/types/discovery/helpers.d.ts +2 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  11. package/dist/types/irc/bus.d.ts +4 -0
  12. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  13. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  15. package/dist/types/modes/components/index.d.ts +2 -1
  16. package/dist/types/modes/components/model-browser.d.ts +100 -0
  17. package/dist/types/modes/components/model-hub.d.ts +52 -0
  18. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  19. package/dist/types/modes/components/session-selector.d.ts +13 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  22. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  23. package/dist/types/modes/interactive-mode.d.ts +3 -0
  24. package/dist/types/modes/queue-input.d.ts +8 -0
  25. package/dist/types/modes/shared.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +1 -1
  28. package/dist/types/session/agent-storage.d.ts +57 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/task/executor.d.ts +26 -13
  31. package/dist/types/task/index.d.ts +12 -11
  32. package/dist/types/task/label.d.ts +4 -0
  33. package/dist/types/task/repair-args.d.ts +8 -8
  34. package/dist/types/task/types.d.ts +31 -56
  35. package/dist/types/tools/ask.d.ts +12 -0
  36. package/dist/types/tools/conflict-detect.d.ts +17 -1
  37. package/dist/types/tools/job.d.ts +16 -0
  38. package/package.json +12 -12
  39. package/scripts/build-binary.ts +0 -1
  40. package/scripts/compile-binary.ts +4 -3
  41. package/src/async/job-manager.ts +9 -0
  42. package/src/cli/bench-cli.ts +7 -26
  43. package/src/cli/usage-cli.ts +11 -0
  44. package/src/commands/usage.ts +13 -2
  45. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  46. package/src/config/settings-schema.ts +18 -7
  47. package/src/config/settings.ts +13 -4
  48. package/src/discovery/helpers.ts +3 -4
  49. package/src/extensibility/custom-tools/loader.ts +70 -37
  50. package/src/extensibility/extensions/types.ts +46 -0
  51. package/src/irc/bus.ts +61 -20
  52. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  53. package/src/modes/components/advisor-config.ts +32 -22
  54. package/src/modes/components/ask-dialog.ts +888 -0
  55. package/src/modes/components/custom-editor.test.ts +58 -1
  56. package/src/modes/components/custom-editor.ts +42 -11
  57. package/src/modes/components/index.ts +2 -1
  58. package/src/modes/components/model-browser.ts +769 -0
  59. package/src/modes/components/model-hub.ts +2002 -0
  60. package/src/modes/components/pause-screen.ts +208 -0
  61. package/src/modes/components/session-selector.ts +299 -42
  62. package/src/modes/components/tool-execution.ts +2 -0
  63. package/src/modes/components/usage-row.ts +5 -6
  64. package/src/modes/controllers/event-controller.ts +8 -2
  65. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  66. package/src/modes/controllers/input-controller.ts +140 -6
  67. package/src/modes/controllers/selector-controller.ts +160 -97
  68. package/src/modes/controllers/tan-command-controller.ts +1 -1
  69. package/src/modes/controllers/todo-command-controller.ts +1 -2
  70. package/src/modes/interactive-mode.ts +8 -0
  71. package/src/modes/queue-input.ts +132 -0
  72. package/src/modes/shared.ts +1 -1
  73. package/src/modes/theme/theme.ts +3 -3
  74. package/src/modes/types.ts +4 -0
  75. package/src/modes/utils/ui-helpers.ts +50 -24
  76. package/src/prompts/agents/scout.md +0 -1
  77. package/src/prompts/agents/task.md +1 -1
  78. package/src/prompts/system/subagent-system-prompt.md +1 -5
  79. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  80. package/src/prompts/system/task-label.md +23 -0
  81. package/src/prompts/tools/job.md +1 -1
  82. package/src/prompts/tools/task-summary.md +3 -0
  83. package/src/prompts/tools/task.md +17 -18
  84. package/src/session/agent-session.ts +186 -49
  85. package/src/session/agent-storage.ts +330 -3
  86. package/src/session/history-storage.ts +1 -34
  87. package/src/session/session-context.test.ts +73 -0
  88. package/src/session/session-context.ts +43 -26
  89. package/src/slash-commands/builtin-registry.ts +18 -0
  90. package/src/task/agents.ts +2 -0
  91. package/src/task/executor.ts +159 -46
  92. package/src/task/index.ts +377 -239
  93. package/src/task/label.ts +38 -0
  94. package/src/task/render.ts +74 -22
  95. package/src/task/repair-args.ts +20 -31
  96. package/src/task/spawn-policy.test.ts +4 -4
  97. package/src/task/types.ts +46 -66
  98. package/src/tools/ask.ts +233 -40
  99. package/src/tools/conflict-detect.ts +102 -5
  100. package/src/tools/index.ts +1 -0
  101. package/src/tools/irc.ts +20 -11
  102. package/src/tools/job.ts +158 -18
  103. package/src/tools/write.ts +70 -6
  104. package/src/vibe/runtime.ts +1 -1
  105. package/src/web/search/providers/browser-headers.ts +30 -13
  106. package/dist/types/modes/components/model-selector.d.ts +0 -37
  107. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  108. package/src/modes/components/model-selector.ts +0 -1291
  109. package/src/tools/bash-command-fixup.ts +0 -4
@@ -943,12 +943,18 @@ export class EventController {
943
943
  if (component) {
944
944
  const asyncState = (event.partialResult.details as { async?: { state?: string } } | undefined)?.async?.state;
945
945
  const isFinalAsyncState = asyncState === "completed" || asyncState === "failed";
946
+ // A final async snapshot is terminal only for a parked background
947
+ // block (the call already returned and was kept alive for its jobs).
948
+ // While the call is still executing — a mixed blocking+async task
949
+ // call whose jobs settle before its blocking subset — treat it as a
950
+ // partial frame: `tool_execution_end` still owns the terminal result.
951
+ const isTerminal = isFinalAsyncState && this.#backgroundToolCallIds.has(event.toolCallId);
946
952
  component.updateResult(
947
953
  { ...event.partialResult, isError: asyncState === "failed" },
948
- !isFinalAsyncState,
954
+ !isTerminal,
949
955
  event.toolCallId,
950
956
  );
951
- if (isFinalAsyncState) {
957
+ if (isTerminal) {
952
958
  this.ctx.pendingTools.delete(event.toolCallId);
953
959
  this.#backgroundToolCallIds.delete(event.toolCallId);
954
960
  }
@@ -5,6 +5,9 @@ import { KeybindingsManager } from "../../config/keybindings";
5
5
  import type {
6
6
  CompactOptions,
7
7
  ExtensionActions,
8
+ ExtensionAskDialogQuestion,
9
+ ExtensionAskDialogResult,
10
+ ExtensionAskDialogResultItem,
8
11
  ExtensionCommandContextActions,
9
12
  ExtensionContextActions,
10
13
  ExtensionError,
@@ -19,6 +22,7 @@ import type {
19
22
  } from "../../extensibility/extensions";
20
23
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
21
24
  import { createExtensionModelQuery } from "../../extensibility/extensions/model-api";
25
+ import { AskDialogComponent, boundPromptTitle } from "../../modes/components/ask-dialog";
22
26
  import { HookEditorComponent } from "../../modes/components/hook-editor";
23
27
  import { HookInputComponent } from "../../modes/components/hook-input";
24
28
  import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector";
@@ -28,12 +32,26 @@ import { normalizeCustomMessagePayload, USER_INTERRUPT_LABEL } from "../../sessi
28
32
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
29
33
 
30
34
  const MAX_WIDGET_LINES = 10;
35
+ const ASK_OTHER_OPTION = "Other (type your own)";
36
+ const ASK_CHAT_OPTION = "Chat about this";
37
+ const ASK_NEXT_OPTION = "Next →";
31
38
 
32
39
  interface CollabDialogWinner {
33
40
  source: "local" | "remote";
34
41
  value: string | undefined;
35
42
  }
36
43
 
44
+ interface CollabAskDialogWinner {
45
+ source: "local" | "remote";
46
+ value: ExtensionAskDialogResult | undefined;
47
+ }
48
+ /** Tagged result from a guest UI request, distinguishing a real answer (even
49
+ * one whose literal value is "unavailable"), an explicit guest cancel, and a
50
+ * transport-unavailable sentinel (collab teardown / abort). Replaces the old
51
+ * `string | "unavailable" | undefined` channel that let a guest answer of
52
+ * "unavailable" collide with the transport sentinel. */
53
+ type GuestUiResult = { kind: "answered"; value: string } | { kind: "cancelled" } | { kind: "unavailable" };
54
+
37
55
  function toWireSelectOptions(options: ExtensionUISelectItem[]): CollabUiSelectItem[] {
38
56
  return options.map(option =>
39
57
  typeof option === "string"
@@ -65,6 +83,7 @@ export class ExtensionUiController {
65
83
  select: (title, options, dialogOptions) => this.showCollabAwareSelector(title, options, dialogOptions),
66
84
  confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
67
85
  input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions),
86
+ askDialog: (questions, dialogOptions) => this.showAskDialog(questions, dialogOptions),
68
87
  notify: (message, type) => this.showHookNotify(message, type),
69
88
  onTerminalInput: handler => this.addExtensionTerminalInputListener(handler),
70
89
  setStatus: (key, text) => this.setHookStatus(key, text),
@@ -558,6 +577,106 @@ export class ExtensionUiController {
558
577
  );
559
578
  }
560
579
 
580
+ async showAskDialog(
581
+ questions: ExtensionAskDialogQuestion[],
582
+ dialogOptions?: ExtensionUIDialogOptions,
583
+ ): Promise<ExtensionAskDialogResult | undefined> {
584
+ const host = this.ctx.collabHost;
585
+ if (!host) return this.#showLocalAskDialog(questions, dialogOptions);
586
+ const localAbort = new AbortController();
587
+ const remoteAbort = new AbortController();
588
+ const parentSignal = dialogOptions?.signal;
589
+ const localSignal = parentSignal ? AbortSignal.any([parentSignal, localAbort.signal]) : localAbort.signal;
590
+ const remoteSignal = parentSignal ? AbortSignal.any([parentSignal, remoteAbort.signal]) : remoteAbort.signal;
591
+ const localWinner = this.#showLocalAskDialog(questions, { ...dialogOptions, signal: localSignal }).then(
592
+ (value): CollabAskDialogWinner => ({ source: "local", value }),
593
+ );
594
+ const remoteWinner: Promise<CollabAskDialogWinner> = this.#runGuestAskDialog(questions, remoteSignal).then(
595
+ result => (result === "unavailable" ? localWinner : { source: "remote", value: result }),
596
+ );
597
+ const winner = await Promise.race([localWinner, remoteWinner]);
598
+ if (winner.source === "remote") localAbort.abort();
599
+ else remoteAbort.abort();
600
+ return winner.value;
601
+ }
602
+
603
+ #showLocalAskDialog(
604
+ questions: ExtensionAskDialogQuestion[],
605
+ dialogOptions?: ExtensionUIDialogOptions,
606
+ ): Promise<ExtensionAskDialogResult | undefined> {
607
+ return this.#presentDialog<ExtensionAskDialogResult>(dialogOptions?.signal, settle => {
608
+ let askDialog: AskDialogComponent | undefined;
609
+ let promptEditor: HookEditorComponent | undefined;
610
+ let promptResolve: ((value: string | undefined) => void) | undefined;
611
+ let closed = false;
612
+
613
+ const restoreAskDialog = (): void => {
614
+ if (closed || !askDialog) return;
615
+ this.ctx.editorContainer.clear();
616
+ this.ctx.editorContainer.addChild(askDialog);
617
+ this.ctx.ui.setFocus(askDialog);
618
+ this.ctx.ui.requestRender();
619
+ };
620
+
621
+ const finishPrompt = (value: string | undefined): void => {
622
+ const resolvePrompt = promptResolve;
623
+ promptResolve = undefined;
624
+ promptEditor = undefined;
625
+ restoreAskDialog();
626
+ resolvePrompt?.(value);
627
+ };
628
+
629
+ const promptForText = (title: string, prefill?: string): Promise<string | undefined> => {
630
+ if (closed) return Promise.resolve(undefined);
631
+ const { promise, resolve } = Promise.withResolvers<string | undefined>();
632
+ promptResolve = resolve;
633
+ promptEditor = new HookEditorComponent(
634
+ this.ctx.ui,
635
+ title,
636
+ prefill,
637
+ value => finishPrompt(value),
638
+ () => finishPrompt(undefined),
639
+ { promptStyle: true },
640
+ );
641
+ this.ctx.editorContainer.clear();
642
+ this.ctx.editorContainer.addChild(promptEditor);
643
+ this.ctx.ui.setFocus(promptEditor);
644
+ this.ctx.ui.requestRender();
645
+ return promise;
646
+ };
647
+
648
+ askDialog = new AskDialogComponent(
649
+ questions,
650
+ {
651
+ onSubmit: result => settle(result),
652
+ onCancel: () => settle(undefined),
653
+ onPrompt: promptForText,
654
+ },
655
+ {
656
+ timeout: dialogOptions?.timeout,
657
+ onTimeout: dialogOptions?.onTimeout,
658
+ tui: this.ctx.ui,
659
+ },
660
+ );
661
+ this.ctx.editorContainer.clear();
662
+ this.ctx.editorContainer.addChild(askDialog);
663
+ this.ctx.ui.setFocus(askDialog);
664
+ this.ctx.ui.requestRender();
665
+
666
+ return () => {
667
+ closed = true;
668
+ askDialog?.dispose();
669
+ promptResolve?.(undefined);
670
+ promptResolve = undefined;
671
+ promptEditor = undefined;
672
+ this.ctx.editorContainer.clear();
673
+ this.ctx.editorContainer.addChild(this.ctx.editor);
674
+ this.ctx.ui.setFocus(this.ctx.editor);
675
+ this.ctx.ui.requestRender();
676
+ };
677
+ });
678
+ }
679
+
561
680
  /**
562
681
  * Race the local hook dialog against a mirrored guest ask. First *answer*
563
682
  * wins and cancels the other side. A remote `unavailable` settlement
@@ -591,6 +710,134 @@ export class ExtensionUiController {
591
710
  return winner.value;
592
711
  }
593
712
 
713
+ async #runGuestAskDialog(
714
+ questions: ExtensionAskDialogQuestion[],
715
+ signal: AbortSignal,
716
+ ): Promise<ExtensionAskDialogResult | "unavailable" | undefined> {
717
+ const results: ExtensionAskDialogResultItem[] = [];
718
+ for (const question of questions) {
719
+ const result = await this.#runGuestAskQuestion(question, signal);
720
+ if (result === "unavailable" || result === undefined) return result;
721
+ if (result === "chat") return { kind: "chat" };
722
+ results.push(result);
723
+ }
724
+ return { kind: "submit", results };
725
+ }
726
+
727
+ async #runGuestAskQuestion(
728
+ question: ExtensionAskDialogQuestion,
729
+ signal: AbortSignal,
730
+ ): Promise<ExtensionAskDialogResultItem | "chat" | "unavailable" | undefined> {
731
+ const selected = new Set<string>();
732
+ let customInput: string | undefined;
733
+ const baseOptions: CollabUiSelectItem[] = question.options.map(option =>
734
+ option.description?.trim() ? { label: option.label, description: option.description.trim() } : option.label,
735
+ );
736
+ if (question.multi) {
737
+ while (true) {
738
+ const checkedIndices = question.options
739
+ .map((option, index) => (selected.has(option.label) ? index : -1))
740
+ .filter(index => index >= 0);
741
+ // Mirror the local dialog's Next gating: omit the Next option until
742
+ // at least one option is checked or a custom answer exists, so a
743
+ // guest cannot submit an empty multi-select result
744
+ // (PRRT_kwDOQxs0bc6OFbDW). The remote select has no "disabled" row
745
+ // concept, so we omit rather than dim it.
746
+ const hasAnswer = selected.size > 0 || customInput !== undefined;
747
+ const options = [...baseOptions, ASK_OTHER_OPTION];
748
+ if (hasAnswer) options.push(ASK_NEXT_OPTION);
749
+ options.push(ASK_CHAT_OPTION);
750
+ const choice = await this.#requestGuestUiString(
751
+ {
752
+ kind: "select",
753
+ title: question.question,
754
+ options,
755
+ selectionMarker: "checkbox",
756
+ checkedIndices,
757
+ markableCount: question.options.length,
758
+ helpText: hasAnswer
759
+ ? "up/down navigate enter toggle Next → continue esc cancel"
760
+ : "up/down navigate enter toggle esc cancel",
761
+ },
762
+ signal,
763
+ );
764
+ if (choice.kind === "unavailable") return "unavailable";
765
+ if (choice.kind === "cancelled") return undefined;
766
+ if (choice.value === ASK_CHAT_OPTION) return "chat";
767
+ if (choice.value === ASK_NEXT_OPTION) break;
768
+ if (choice.value === ASK_OTHER_OPTION) {
769
+ const input = await this.#requestGuestUiString(
770
+ { kind: "editor", title: boundPromptTitle("Custom answer: ", question.question) },
771
+ signal,
772
+ );
773
+ if (input.kind === "unavailable") return "unavailable";
774
+ // Guest cancelled the Other editor: keep the ask open and
775
+ // return to the option list instead of cancelling the whole ask.
776
+ if (input.kind === "cancelled") continue;
777
+ customInput = input.value;
778
+ break;
779
+ }
780
+ if (selected.has(choice.value)) selected.delete(choice.value);
781
+ else selected.add(choice.value);
782
+ }
783
+ } else {
784
+ const recommended =
785
+ typeof question.recommended === "number" && Number.isInteger(question.recommended)
786
+ ? question.recommended
787
+ : 0;
788
+ const initialIndex = Math.max(0, Math.min(recommended, Math.max(0, question.options.length - 1)));
789
+ while (true) {
790
+ const choice = await this.#requestGuestUiString(
791
+ {
792
+ kind: "select",
793
+ title: question.question,
794
+ options: [...baseOptions, ASK_OTHER_OPTION, ASK_CHAT_OPTION],
795
+ initialIndex,
796
+ selectionMarker: "radio",
797
+ markableCount: question.options.length,
798
+ helpText: "up/down navigate enter select esc cancel",
799
+ },
800
+ signal,
801
+ );
802
+ if (choice.kind === "unavailable") return "unavailable";
803
+ if (choice.kind === "cancelled") return undefined;
804
+ if (choice.value === ASK_CHAT_OPTION) return "chat";
805
+ if (choice.value === ASK_OTHER_OPTION) {
806
+ const input = await this.#requestGuestUiString(
807
+ { kind: "editor", title: boundPromptTitle("Custom answer: ", question.question) },
808
+ signal,
809
+ );
810
+ if (input.kind === "unavailable") return "unavailable";
811
+ // Guest cancelled the Other editor: re-show the select list
812
+ // instead of cancelling the whole ask.
813
+ if (input.kind === "cancelled") continue;
814
+ customInput = input.value;
815
+ } else {
816
+ selected.add(choice.value);
817
+ }
818
+ break;
819
+ }
820
+ }
821
+ return {
822
+ id: question.id,
823
+ question: question.question,
824
+ options: question.options.map(option => option.label),
825
+ multi: question.multi ?? false,
826
+ selectedOptions: question.options.map(option => option.label).filter(label => selected.has(label)),
827
+ customInput,
828
+ };
829
+ }
830
+
831
+ async #requestGuestUiString(request: CollabUiRequestDraft, signal: AbortSignal): Promise<GuestUiResult> {
832
+ const host = this.ctx.collabHost;
833
+ if (!host) return { kind: "unavailable" };
834
+ const remote = host.requestGuestUi(request, signal);
835
+ if (!remote) return { kind: "unavailable" };
836
+ const result = await remote;
837
+ if (result.kind === "unavailable") return { kind: "unavailable" };
838
+ return typeof result.value === "string" ? { kind: "answered", value: result.value } : { kind: "cancelled" };
839
+ }
840
+
594
841
  /**
595
842
  * Show a selector for hooks.
596
843
  */
@@ -894,11 +1141,11 @@ export class ExtensionUiController {
894
1141
  * the current dialog and hands the surface to the next queued request. A request
895
1142
  * whose signal aborts before its turn resolves `undefined` and is never shown.
896
1143
  */
897
- #presentDialog(
1144
+ #presentDialog<T = string>(
898
1145
  signal: AbortSignal | undefined,
899
- present: (settle: (value: string | undefined) => void) => () => void,
900
- ): Promise<string | undefined> {
901
- const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
1146
+ present: (settle: (value: T | undefined) => void) => () => void,
1147
+ ): Promise<T | undefined> {
1148
+ const { promise, resolve, reject } = Promise.withResolvers<T | undefined>();
902
1149
  let settled = false;
903
1150
  let started = false;
904
1151
  let hide: (() => void) | undefined;
@@ -907,7 +1154,7 @@ export class ExtensionUiController {
907
1154
  settle(undefined);
908
1155
  }
909
1156
 
910
- const settle = (value: string | undefined): void => {
1157
+ const settle = (value: T | undefined): void => {
911
1158
  if (settled) return;
912
1159
  settled = true;
913
1160
  signal?.removeEventListener("abort", onAbort);
@@ -13,6 +13,7 @@ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-
13
13
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
14
14
  import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
15
15
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
16
+ import { parseQueueShorthand, splitQueuedMessages } from "../../modes/queue-input";
16
17
  import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command";
17
18
  import type { InteractiveModeContext } from "../../modes/types";
18
19
  import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
@@ -266,6 +267,8 @@ export class InputController {
266
267
  });
267
268
  }
268
269
  this.ctx.editor.onEscape = () => {
270
+ // Side-channel panels are the topmost view. Esc dismisses them before
271
+ // touching loop mode, maintenance, or the underlying main turn.
269
272
  // Active context maintenance owns Esc: auto/manual compaction,
270
273
  // handoff generation, and auto-retry backoff all advertise
271
274
  // "(esc to cancel)". Dispatch on live session state instead of
@@ -281,6 +284,13 @@ export class InputController {
281
284
  // (see EventController). Main-session maintenance still owns Esc and
282
285
  // stays cancellable from the main view (focused submit gates /compact
283
286
  // and handoff, so manual maintenance is main-only anyway).
287
+ if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
288
+ return;
289
+ }
290
+ if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
291
+ return;
292
+ }
293
+
284
294
  if (!this.ctx.focusedAgentId) {
285
295
  const viewSession = this.ctx.viewSession;
286
296
  let aborted = false;
@@ -308,12 +318,6 @@ export class InputController {
308
318
  }
309
319
  return;
310
320
  }
311
- if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
312
- return;
313
- }
314
- if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
315
- return;
316
- }
317
321
  if (this.ctx.focusedAgentId) {
318
322
  // Esc never interrupts the focused agent's turn: clear typed text,
319
323
  // else return the view to the main session. Interrupt via empty
@@ -647,6 +651,16 @@ export class InputController {
647
651
 
648
652
  if (!text && !hasInputImages) return;
649
653
 
654
+ const queueBody = parseQueueShorthand(text);
655
+ if (queueBody !== undefined) {
656
+ await this.#queueForYield(queueBody, {
657
+ historyText: text,
658
+ images: inputImages,
659
+ imageLinks: inputImageLinks,
660
+ });
661
+ return;
662
+ }
663
+
650
664
  // Handle built-in slash commands
651
665
  if (text) {
652
666
  const slashResult = await executeBuiltinSlashCommand(text, {
@@ -1114,6 +1128,126 @@ export class InputController {
1114
1128
  }
1115
1129
  }
1116
1130
 
1131
+ /** Queue `/queue` input behind an active turn, or start it immediately when idle. */
1132
+ async handleQueueCommand(text: string): Promise<void> {
1133
+ const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
1134
+ const imageLinks =
1135
+ images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
1136
+ await this.#queueForYield(text, { images, imageLinks });
1137
+ }
1138
+
1139
+ async #queueForYield(
1140
+ text: string,
1141
+ options: {
1142
+ historyText?: string;
1143
+ images?: ImageContent[];
1144
+ imageLinks?: (string | undefined)[];
1145
+ },
1146
+ ): Promise<void> {
1147
+ const splitMessages = splitQueuedMessages(text);
1148
+ if (splitMessages.length === 0 && !options.images?.length) {
1149
+ this.ctx.editor.clearDraft();
1150
+ this.ctx.showWarning("Usage: /queue <message> (or start a prompt with -> / =>)");
1151
+ return;
1152
+ }
1153
+
1154
+ const messages = splitMessages.length > 0 ? splitMessages : [""];
1155
+ const originalDraft = this.ctx.editor.getText();
1156
+ const images = options.images?.length ? [...options.images] : undefined;
1157
+ const imageLinks = options.imageLinks
1158
+ ? [...options.imageLinks]
1159
+ : images
1160
+ ? images.map(() => undefined)
1161
+ : undefined;
1162
+ this.ctx.editor.clearDraft(options.historyText);
1163
+
1164
+ if (this.ctx.session.isCompacting) {
1165
+ for (let index = 0; index < messages.length; index++) {
1166
+ this.ctx.compactionQueuedMessages.push({
1167
+ text: messages[index] ?? "",
1168
+ mode: "followUp",
1169
+ images: index === 0 ? images : undefined,
1170
+ });
1171
+ }
1172
+ this.ctx.updatePendingMessagesDisplay();
1173
+ this.ctx.showStatus(
1174
+ messages.length === 1
1175
+ ? "Queued message for after compaction"
1176
+ : `Queued ${messages.length} messages for after compaction`,
1177
+ );
1178
+ this.ctx.ui.requestRender();
1179
+ return;
1180
+ }
1181
+
1182
+ const startImmediately = !this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0;
1183
+ let queuedCount = 0;
1184
+ try {
1185
+ if (startImmediately && this.ctx.onInputCallback) {
1186
+ const first = messages[0] ?? "";
1187
+ const submission = this.ctx.startPendingSubmission({
1188
+ text: first,
1189
+ images,
1190
+ imageLinks,
1191
+ streamingBehavior: "followUp",
1192
+ });
1193
+ this.ctx.onInputCallback(submission);
1194
+ queuedCount = 1;
1195
+ }
1196
+ while (queuedCount < messages.length) {
1197
+ const message = messages[queuedCount] ?? "";
1198
+ const queuedImages = queuedCount === 0 ? images : undefined;
1199
+ await this.ctx.withLocalSubmission(
1200
+ message,
1201
+ async () => {
1202
+ if (startImmediately && queuedCount === 0) {
1203
+ await this.ctx.session.prompt(message, {
1204
+ images: queuedImages,
1205
+ streamingBehavior: "followUp",
1206
+ });
1207
+ } else {
1208
+ await this.ctx.session.followUp(message, queuedImages);
1209
+ }
1210
+ },
1211
+ { imageCount: queuedImages?.length ?? 0 },
1212
+ );
1213
+ queuedCount++;
1214
+ }
1215
+ } catch (error) {
1216
+ if (queuedCount === 0) {
1217
+ this.ctx.editor.setText(originalDraft);
1218
+ if (images) {
1219
+ this.ctx.editor.pendingImages = images;
1220
+ this.ctx.editor.pendingImageLinks = imageLinks ?? images.map(() => undefined);
1221
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
1222
+ }
1223
+ } else {
1224
+ const remaining = messages.slice(queuedCount);
1225
+ const restored =
1226
+ remaining.length === 1
1227
+ ? `=> ${remaining[0]}`
1228
+ : `=>\n${remaining
1229
+ .map((message, index) => `${index + 1}. ${message.replaceAll("\n", "\n ")}`)
1230
+ .join("\n")}`;
1231
+ this.ctx.editor.setText(restored);
1232
+ }
1233
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1234
+ }
1235
+
1236
+ this.ctx.updatePendingMessagesDisplay();
1237
+ if (queuedCount === messages.length) {
1238
+ this.ctx.showStatus(
1239
+ startImmediately
1240
+ ? queuedCount === 1
1241
+ ? "Sent queued message"
1242
+ : `Sent first message; queued ${queuedCount - 1} for later yields`
1243
+ : queuedCount === 1
1244
+ ? "Queued message for when the agent yields"
1245
+ : `Queued ${queuedCount} messages for when the agent yields`,
1246
+ );
1247
+ }
1248
+ this.ctx.ui.requestRender();
1249
+ }
1250
+
1117
1251
  /** Send editor text as a follow-up message (queued behind current stream). */
1118
1252
  async handleFollowUp(): Promise<void> {
1119
1253
  let text = this.ctx.editor.getExpandedText().trim();