@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
@@ -3,7 +3,7 @@ import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
3
3
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
4
4
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
5
5
  import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui";
6
- import { Input, Loader, Spacer, setTuiTight, Text } from "@oh-my-pi/pi-tui";
6
+ import { Loader, Spacer, setTuiTight, Text } from "@oh-my-pi/pi-tui";
7
7
  import { getAgentDbPath, getAgentDir, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils";
8
8
  import {
9
9
  type AdvisorConfigScope,
@@ -65,8 +65,9 @@ import { AssistantMessageComponent } from "../components/assistant-message";
65
65
  import { CopySelectorComponent } from "../components/copy-selector";
66
66
  import { ExtensionDashboard } from "../components/extensions";
67
67
  import { HistorySearchComponent } from "../components/history-search";
68
+ import { LoginDialogComponent } from "../components/login-dialog";
68
69
  import { LogoutAccountSelectorComponent } from "../components/logout-account-selector";
69
- import { ModelSelectorComponent } from "../components/model-selector";
70
+ import { ModelHubComponent, type ModelHubMode } from "../components/model-hub";
70
71
  import { OAuthSelectorComponent } from "../components/oauth-selector";
71
72
  import { PluginSelectorComponent } from "../components/plugin-selector";
72
73
  import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
@@ -585,50 +586,45 @@ export class SelectorController {
585
586
  }
586
587
 
587
588
  showModelSelector(options?: { temporaryOnly?: boolean }): void {
589
+ this.#showModelHub({ mode: options?.temporaryOnly ? "pick" : "roles" });
590
+ }
591
+
592
+ /**
593
+ * Fullscreen model hub on the alternate screen (the /settings idiom): the
594
+ * overlay enables mouse tracking for its lifetime and the transcript stays
595
+ * untouched underneath. `initialProviderId` preselects a provider's sidebar
596
+ * entry — used when reopening the hub after a /login round-trip.
597
+ */
598
+ #showModelHub(hubOptions: { mode: ModelHubMode; initialProviderId?: string }): void {
588
599
  const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
589
- this.showSelector(done => {
590
- const selector = new ModelSelectorComponent(
591
- this.ctx.ui,
592
- this.ctx.session.model,
593
- this.ctx.settings,
594
- this.ctx.session.modelRegistry,
595
- this.ctx.session.scopedModels,
596
- async (model, role, thinkingLevel, selector, action) => {
600
+ let overlayHandle: OverlayHandle | undefined;
601
+ let hub: ModelHubComponent | undefined;
602
+ let closed = false;
603
+ const done = () => {
604
+ // Re-entrant guard: cancel paths (Esc, pick, login forward) may race;
605
+ // the overlay must hide exactly once.
606
+ if (closed) return;
607
+ closed = true;
608
+ hub?.dispose();
609
+ overlayHandle?.hide();
610
+ this.focusActiveEditorArea();
611
+ this.ctx.ui.requestRender();
612
+ };
613
+ hub = new ModelHubComponent(
614
+ this.ctx.ui,
615
+ this.ctx.settings,
616
+ this.ctx.session.modelRegistry,
617
+ this.ctx.session.scopedModels,
618
+ {
619
+ onAssign: async (model, role, thinkingLevel, selector) => {
597
620
  // `auto` is session-global: never baked into a per-role model value
598
621
  // (it can't round-trip through `model:<level>`). Apply it to the session
599
622
  // separately and persist via `defaultThinkingLevel`.
600
623
  const isAuto = thinkingLevel === AUTO_THINKING;
601
- const concreteThinking = isAuto ? undefined : thinkingLevel;
624
+ const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel;
602
625
  const selectorValue = selector ?? `${model.provider}/${model.id}`;
603
626
  try {
604
- if (action === "retryFallback" && role !== null) {
605
- const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
606
- const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
607
- const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
608
- this.ctx.settings.set("retry.fallbackChains", {
609
- ...fallbackChains,
610
- [role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
611
- });
612
- const roleInfo = getRoleInfo(role, settings);
613
- const roleLabel = roleInfo?.name ?? role;
614
- this.ctx.showStatus(`${roleLabel} fallback model: ${fallbackSelector}`);
615
- return;
616
- }
617
- if (role === null) {
618
- // Temporary: update agent state but don't persist the model to settings
619
- await this.ctx.session.setModelTemporary(model);
620
- if (isAuto) {
621
- this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
622
- }
623
- this.ctx.statusLine.invalidate();
624
- this.ctx.updateEditorBorderColor();
625
- const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
626
- this.ctx.showStatus(
627
- `Session-only model: ${selector ?? model.id}. Use ${roleSelectorHint} or /model for roles.`,
628
- );
629
- done();
630
- this.ctx.ui.requestRender();
631
- } else if (role === "default") {
627
+ if (role === "default") {
632
628
  const { switched } = await this.ctx.session.setModel(model, role, {
633
629
  selector,
634
630
  thinkingLevel: concreteThinking,
@@ -649,33 +645,101 @@ export class SelectorController {
649
645
  this.ctx.updateEditorBorderColor();
650
646
  }
651
647
  this.ctx.showStatus(`Default model: ${selector ?? model.id}`);
652
- // Don't call done() - selector stays open for role assignment
653
648
  } else {
654
- // Other roles (smol, slow): just update settings, not current model
655
- this.ctx.settings.setModelRole(
656
- role,
657
- formatModelSelectorValue(selector ?? `${model.provider}/${model.id}`, concreteThinking),
658
- );
649
+ // Other roles (smol, slow, custom): update settings, not the current model.
650
+ this.ctx.settings.setModelRole(role, formatModelSelectorValue(selectorValue, concreteThinking));
659
651
  if (isAuto) {
660
652
  this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
661
653
  }
662
654
  const roleInfo = getRoleInfo(role, settings);
663
- const roleLabel = roleInfo?.name ?? role;
664
- this.ctx.showStatus(`${roleLabel} model: ${selector ?? model.id}`);
665
- // Don't call done() - selector stays open
655
+ this.ctx.showStatus(`${roleInfo?.name ?? role} model: ${selector ?? model.id}`);
666
656
  }
667
657
  } catch (error) {
668
658
  this.ctx.showError(error instanceof Error ? error.message : String(error));
669
659
  }
670
660
  },
671
- () => {
661
+ onUnassign: role => {
662
+ try {
663
+ this.ctx.settings.setModelRole(role, undefined);
664
+ const roleInfo = getRoleInfo(role, settings);
665
+ this.ctx.showStatus(`${roleInfo?.name ?? role} role cleared — auto-selection applies`);
666
+ } catch (error) {
667
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
668
+ }
669
+ },
670
+ onFallbackChainChange: (role, chain) => {
671
+ try {
672
+ const chains = { ...this.ctx.settings.get("retry.fallbackChains") };
673
+ if (chain.length === 0) {
674
+ delete chains[role];
675
+ } else {
676
+ chains[role] = chain;
677
+ }
678
+ this.ctx.settings.set("retry.fallbackChains", chains);
679
+ const roleInfo = getRoleInfo(role, settings);
680
+ this.ctx.showStatus(
681
+ chain.length > 0
682
+ ? `${roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}`
683
+ : `${roleInfo?.name ?? role} fallbacks cleared`,
684
+ );
685
+ } catch (error) {
686
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
687
+ }
688
+ },
689
+ onPick: async (model, selector) => {
690
+ try {
691
+ // Session-only: update agent state but don't persist the model to settings.
692
+ await this.ctx.session.setModelTemporary(model);
693
+ this.ctx.statusLine.invalidate();
694
+ this.ctx.updateEditorBorderColor();
695
+ const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
696
+ this.ctx.showStatus(
697
+ `Session-only model: ${selector ?? model.id}. Use ${roleSelectorHint} or /model for roles.`,
698
+ );
699
+ done();
700
+ } catch (error) {
701
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
702
+ }
703
+ },
704
+ onLoginRequest: providerId => {
672
705
  done();
673
- this.ctx.ui.requestRender();
706
+ void this.#loginThenReopenModelHub(providerId);
674
707
  },
675
- { ...options, currentContextTokens },
676
- );
677
- return { component: selector, focus: selector };
708
+ onCycleOrderChange: order => {
709
+ try {
710
+ this.ctx.settings.set("cycleOrder", order);
711
+ this.ctx.showStatus(
712
+ order.length > 0 ? `Quick-switch cycle: ${order.join(" → ")}` : "Quick-switch cycle cleared",
713
+ );
714
+ } catch (error) {
715
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
716
+ }
717
+ },
718
+ onCancel: () => done(),
719
+ },
720
+ {
721
+ mode: hubOptions.mode,
722
+ currentContextTokens,
723
+ initialProviderId: hubOptions.initialProviderId,
724
+ },
725
+ );
726
+ overlayHandle = this.ctx.ui.showOverlay(hub, {
727
+ anchor: "bottom-center",
728
+ width: "100%",
729
+ maxHeight: "100%",
730
+ margin: 0,
731
+ fullscreen: true,
678
732
  });
733
+ this.ctx.ui.setFocus(hub);
734
+ this.ctx.ui.requestRender();
735
+ }
736
+
737
+ /** /login round-trip for a locked provider; reopen the hub on that provider only after a successful login. */
738
+ async #loginThenReopenModelHub(providerId: string): Promise<void> {
739
+ const succeeded = await this.#handleOAuthLogin(providerId);
740
+ if (succeeded) {
741
+ this.#showModelHub({ mode: "roles", initialProviderId: providerId });
742
+ }
679
743
  }
680
744
 
681
745
  async showPluginSelector(mode: "install" | "uninstall" = "install"): Promise<void> {
@@ -1122,60 +1186,51 @@ export class SelectorController {
1122
1186
  await this.showSessionSelector();
1123
1187
  }
1124
1188
 
1125
- async #handleOAuthLogin(providerId: string): Promise<void> {
1189
+ /**
1190
+ * Run the OAuth login flow for `providerId` inside a cancellable
1191
+ * {@link LoginDialogComponent} that replaces the editor slot. Esc aborts:
1192
+ * the dialog's abort signal reaches the provider flow, any pending prompt
1193
+ * rejects, and the editor is restored immediately. Returns true when
1194
+ * credentials were stored.
1195
+ */
1196
+ async #handleOAuthLogin(providerId: string): Promise<boolean> {
1126
1197
  this.ctx.showStatus(`Logging in to ${providerId}…`);
1127
1198
  const manualInput = this.ctx.oauthManualInput;
1128
1199
  const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
1200
+ let restored = false;
1201
+ const restoreEditor = () => {
1202
+ if (restored) return;
1203
+ restored = true;
1204
+ this.ctx.editorContainer.clear();
1205
+ this.ctx.editorContainer.addChild(this.ctx.editor);
1206
+ this.ctx.ui.setFocus(this.ctx.editor);
1207
+ this.ctx.ui.requestRender();
1208
+ };
1209
+ const dialog = new LoginDialogComponent(this.ctx.ui, providerId, (_success, message) => {
1210
+ // Fires on Esc: unblock the editor immediately; the aborted flow's
1211
+ // rejection settles the awaited login below.
1212
+ restoreEditor();
1213
+ if (message) this.ctx.showStatus(message);
1214
+ });
1215
+ this.ctx.editorContainer.clear();
1216
+ this.ctx.editorContainer.addChild(dialog);
1217
+ this.ctx.ui.setFocus(dialog);
1218
+ this.ctx.ui.requestRender();
1129
1219
  try {
1130
1220
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
1221
+ signal: dialog.signal,
1131
1222
  onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
1132
- const block = new TranscriptBlock();
1133
- // Full URL first: works from any machine, including SSH boxes
1134
- // where the OMP-hosted `launchUrl` would resolve against the
1135
- // user's local browser and fail.
1136
- block.addChild(new Text(theme.fg("dim", info.url), 1, 0));
1137
- const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
1138
- block.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
1139
- if (info.launchUrl && info.launchUrl !== info.url) {
1140
- block.addChild(
1141
- new Text(theme.fg("dim", `Local shortcut (this machine only): ${info.launchUrl}`), 1, 0),
1142
- );
1143
- }
1144
- if (info.instructions) {
1145
- block.addChild(new Spacer(1));
1146
- block.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
1147
- }
1223
+ // The dialog renders the full URL (SSH-safe copy target) and
1224
+ // opens the browser best-effort.
1225
+ dialog.showAuth(info.url, info.instructions, info.launchUrl);
1148
1226
  if (useManualInput) {
1149
- block.addChild(new Spacer(1));
1150
- block.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
1151
- }
1152
- this.ctx.present(block);
1153
- this.ctx.openInBrowser(info.url);
1154
- },
1155
- onPrompt: async (prompt: { message: string; placeholder?: string }) => {
1156
- const promptBlock = new TranscriptBlock();
1157
- promptBlock.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
1158
- if (prompt.placeholder) {
1159
- promptBlock.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
1227
+ dialog.showProgress(MANUAL_LOGIN_TIP);
1160
1228
  }
1161
- this.ctx.present(promptBlock);
1162
- const { promise, resolve } = Promise.withResolvers<string>();
1163
- const codeInput = new Input();
1164
- codeInput.onSubmit = () => {
1165
- const code = codeInput.getValue();
1166
- this.ctx.editorContainer.clear();
1167
- this.ctx.editorContainer.addChild(this.ctx.editor);
1168
- this.ctx.ui.setFocus(this.ctx.editor);
1169
- resolve(code);
1170
- };
1171
- this.ctx.editorContainer.clear();
1172
- this.ctx.editorContainer.addChild(codeInput);
1173
- this.ctx.ui.setFocus(codeInput);
1174
- this.ctx.ui.requestRender();
1175
- return promise;
1176
1229
  },
1230
+ onPrompt: (prompt: { message: string; placeholder?: string }) =>
1231
+ dialog.showPrompt(prompt.message, prompt.placeholder),
1177
1232
  onProgress: (message: string) => {
1178
- this.ctx.present(new Text(theme.fg("dim", message), 1, 0));
1233
+ dialog.showProgress(message);
1179
1234
  },
1180
1235
  onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
1181
1236
  });
@@ -1186,12 +1241,20 @@ export class SelectorController {
1186
1241
  );
1187
1242
  block.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
1188
1243
  this.ctx.present(block);
1244
+ return true;
1189
1245
  } catch (error: unknown) {
1246
+ if (dialog.signal.aborted) {
1247
+ // User-cancelled: the dialog already restored the editor and
1248
+ // surfaced "Login cancelled".
1249
+ return false;
1250
+ }
1190
1251
  this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
1252
+ return false;
1191
1253
  } finally {
1192
1254
  if (useManualInput) {
1193
1255
  manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
1194
1256
  }
1257
+ restoreEditor();
1195
1258
  }
1196
1259
  }
1197
1260
 
@@ -160,7 +160,7 @@ export class TanCommandController {
160
160
  }
161
161
  }
162
162
  },
163
- { ownerId },
163
+ { ownerId, agentId: cloneId },
164
164
  );
165
165
  } catch (error) {
166
166
  if (cloneFile) await removeCloneSession(cloneFile);
@@ -133,8 +133,7 @@ export class TodoCommandController {
133
133
 
134
134
  /**
135
135
  * True latest todo state for the user-facing /todo verbs. Reads from session
136
- * entries so that completed/abandoned tasks remain visible after resume
137
- * (where `session.getTodoPhases()` would have stripped them).
136
+ * entries or falls back to the active session state.
138
137
  */
139
138
  #currentPhases(): TodoPhase[] {
140
139
  const fromEntries = getLatestTodoPhasesFromEntries(this.ctx.sessionManager.getBranch());
@@ -566,6 +566,9 @@ export class InteractiveMode implements InteractiveModeContext {
566
566
  get focusedAgentId(): string | undefined {
567
567
  return this.#focusController.focusedAgentId;
568
568
  }
569
+ get sessionName(): string | undefined {
570
+ return this.session.sessionName;
571
+ }
569
572
  focusAgentSession(id: string): Promise<void> {
570
573
  return this.#focusController.focusAgent(id);
571
574
  }
@@ -4284,6 +4287,11 @@ export class InteractiveMode implements InteractiveModeContext {
4284
4287
  return this.#inputController.handleImagePaste();
4285
4288
  }
4286
4289
 
4290
+ /** Queue slash-command input behind the active turn. */
4291
+ handleQueueCommand(message: string): Promise<void> {
4292
+ return this.#inputController.handleQueueCommand(message);
4293
+ }
4294
+
4287
4295
  handleBtwCommand(question: string): Promise<void> {
4288
4296
  return this.#btwController.start(question);
4289
4297
  }
@@ -0,0 +1,132 @@
1
+ const QUEUE_PREFIXES: readonly string[] = ["->", "=>"];
2
+ /** Prefix matcher shared by queue-list parsing and editor highlighting. */
3
+ export const QUEUE_LIST_MARKER_RE = /^([\t ]*)(\d+|[A-Za-z]+)([.)])(?=[\t ]|$)/;
4
+ const CANONICAL_ROMAN_RE = /^(?=[MDCLXVI])M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})$/i;
5
+
6
+ interface EnumeratedItem {
7
+ line: number;
8
+ indent: string;
9
+ marker: string;
10
+ punctuation: string;
11
+ content: string;
12
+ }
13
+
14
+ interface EnumeratedList {
15
+ source: string;
16
+ lines: string[];
17
+ items: EnumeratedItem[];
18
+ }
19
+
20
+ /** Extract the message body from the `->` / `=>` yield-queue shorthand. */
21
+ export function parseQueueShorthand(text: string): string | undefined {
22
+ const prefix = QUEUE_PREFIXES.find(candidate => text.startsWith(candidate));
23
+ return prefix ? text.slice(prefix.length).trim() : undefined;
24
+ }
25
+
26
+ function parseEnumeratedItem(line: string, lineIndex: number): EnumeratedItem | undefined {
27
+ const match = QUEUE_LIST_MARKER_RE.exec(line);
28
+ if (!match) return undefined;
29
+ const [matched, indent, marker, punctuation] = match;
30
+ if (indent === undefined || marker === undefined || punctuation === undefined) return undefined;
31
+ return { line: lineIndex, indent, marker, punctuation, content: line.slice(matched.length).trimStart() };
32
+ }
33
+
34
+ function decodeDecimal(marker: string): number | undefined {
35
+ if (!/^\d+$/.test(marker)) return undefined;
36
+ const value = Number(marker);
37
+ return Number.isSafeInteger(value) ? value : undefined;
38
+ }
39
+
40
+ function decodeRoman(marker: string): number | undefined {
41
+ if (!CANONICAL_ROMAN_RE.test(marker)) return undefined;
42
+ const values: Readonly<Record<string, number>> = {
43
+ I: 1,
44
+ V: 5,
45
+ X: 10,
46
+ L: 50,
47
+ C: 100,
48
+ D: 500,
49
+ M: 1000,
50
+ };
51
+ const upper = marker.toUpperCase();
52
+ let value = 0;
53
+ for (let index = 0; index < upper.length; index++) {
54
+ const current = values[upper[index] ?? ""];
55
+ if (current === undefined) return undefined;
56
+ const next = values[upper[index + 1] ?? ""] ?? 0;
57
+ value += current < next ? -current : current;
58
+ }
59
+ return value;
60
+ }
61
+
62
+ function decodeAlpha(marker: string): number | undefined {
63
+ if (!/^[A-Za-z]+$/.test(marker)) return undefined;
64
+ let value = 0;
65
+ for (const char of marker.toUpperCase()) {
66
+ value = value * 26 + char.charCodeAt(0) - 64;
67
+ if (!Number.isSafeInteger(value)) return undefined;
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function isSequential(markers: readonly string[], decode: (marker: string) => number | undefined): boolean {
73
+ let previous = decode(markers[0] ?? "");
74
+ if (previous === undefined) return false;
75
+ for (let index = 1; index < markers.length; index++) {
76
+ const current = decode(markers[index] ?? "");
77
+ if (current === undefined || current !== previous + 1) return false;
78
+ previous = current;
79
+ }
80
+ return true;
81
+ }
82
+
83
+ function isEnumeratedSequence(items: readonly EnumeratedItem[]): boolean {
84
+ const markers = items.map(item => item.marker);
85
+ if (markers.every(marker => /^\d+$/.test(marker))) return isSequential(markers, decodeDecimal);
86
+ if (
87
+ !markers.every(marker => marker === marker.toUpperCase()) &&
88
+ !markers.every(marker => marker === marker.toLowerCase())
89
+ ) {
90
+ return false;
91
+ }
92
+ return isSequential(markers, decodeRoman) || isSequential(markers, decodeAlpha);
93
+ }
94
+
95
+ function parseEnumeratedList(text: string): EnumeratedList | undefined {
96
+ const source = text.trim();
97
+ if (!source) return undefined;
98
+ const lines = source.split(/\r?\n/);
99
+ const first = parseEnumeratedItem(lines[0] ?? "", 0);
100
+ if (!first) return undefined;
101
+
102
+ const items = [first];
103
+ for (let lineIndex = 1; lineIndex < lines.length; lineIndex++) {
104
+ const item = parseEnumeratedItem(lines[lineIndex] ?? "", lineIndex);
105
+ if (item?.indent === first.indent) items.push(item);
106
+ }
107
+ if (items.length < 2 || items.some(item => item.punctuation !== first.punctuation) || !isEnumeratedSequence(items)) {
108
+ return undefined;
109
+ }
110
+ return { source, lines, items };
111
+ }
112
+
113
+ /** Whether text currently forms a sequential queue list, including an unfinished trailing item. */
114
+ export function isQueuedMessageList(text: string): boolean {
115
+ return parseEnumeratedList(text) !== undefined;
116
+ }
117
+
118
+ /** Split a sequential numeric, Roman-numeral, or alphabetic list into queue entries. */
119
+ export function splitQueuedMessages(text: string): string[] {
120
+ const list = parseEnumeratedList(text);
121
+ if (!list) {
122
+ const source = text.trim();
123
+ return source ? [source] : [];
124
+ }
125
+
126
+ const messages = list.items.map((item, index) => {
127
+ const nextLine = list.items[index + 1]?.line ?? list.lines.length;
128
+ return [item.content, ...list.lines.slice(item.line + 1, nextLine)].join("\n").trim();
129
+ });
130
+ while (messages.at(-1) === "") messages.pop();
131
+ return messages.length > 0 && messages.every(Boolean) ? messages : [list.source];
132
+ }
@@ -18,7 +18,7 @@ export function sanitizeStatusText(text: string): string {
18
18
  // Tab Bar Theme
19
19
  // ═══════════════════════════════════════════════════════════════════════════
20
20
 
21
- /** Shared tab bar theme used by model-selector and settings-selector. */
21
+ /** Shared tab bar theme used by fullscreen overlays (settings, agent hub). */
22
22
  export function getTabBarTheme(): TabBarTheme {
23
23
  return {
24
24
  label: (text: string) => theme.bold(theme.fg("accent", text)),
@@ -249,7 +249,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
249
249
  "status.disabled": "⦸",
250
250
  "status.enabled": "●",
251
251
  "status.running": "⟳",
252
- "status.shadowed": "",
252
+ "status.shadowed": "",
253
253
  "status.aborted": "⏹",
254
254
  "status.done": "•",
255
255
  // Navigation
@@ -464,8 +464,8 @@ const NERD_SYMBOLS: SymbolMap = {
464
464
  "status.enabled": "\uf111",
465
465
  // pick:  | alt:   
466
466
  "status.running": "\uf110",
467
- // pick: | alt: ◑ ◒ ◓ ◔
468
- "status.shadowed": "",
467
+ // pick: (nf-fa-circle_o, pairs with status.enabled's nf-fa-circle) | alt:
468
+ "status.shadowed": "\uf10c",
469
469
  // pick:  | alt:  
470
470
  "status.aborted": "\uf04d",
471
471
  // pick: • | alt: ● ·
@@ -113,6 +113,8 @@ export interface InteractiveModeContext {
113
113
  // Session access
114
114
  session: AgentSession;
115
115
  sessionManager: SessionManager;
116
+ /** The current session display name / title. */
117
+ readonly sessionName: string | undefined;
116
118
  /** Session the transcript/editor/status are attached to: the focused agent's, else `session`. */
117
119
  readonly viewSession: AgentSession;
118
120
  /** Id of the focused agent, undefined when the main session is attached. */
@@ -375,6 +377,8 @@ export interface InteractiveModeContext {
375
377
  handleCtrlZ(): void;
376
378
  handleDequeue(): void;
377
379
  handleImagePaste(): Promise<boolean>;
380
+ /** Queue a message for delivery only after the active agent turn would stop. */
381
+ handleQueueCommand(message: string): Promise<void>;
378
382
  handleBtwCommand(question: string): Promise<void>;
379
383
  handleTanCommand(work: string): Promise<void>;
380
384
  hasActiveBtw(): boolean;