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

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 (89) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +3563 -3540
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/config/settings-schema.d.ts +18 -8
  5. package/dist/types/config/settings.d.ts +3 -2
  6. package/dist/types/discovery/helpers.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  8. package/dist/types/irc/bus.d.ts +4 -0
  9. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  10. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  11. package/dist/types/modes/components/index.d.ts +2 -1
  12. package/dist/types/modes/components/model-browser.d.ts +97 -0
  13. package/dist/types/modes/components/model-hub.d.ts +51 -0
  14. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  15. package/dist/types/modes/components/session-selector.d.ts +13 -0
  16. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  17. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +1 -0
  19. package/dist/types/modes/shared.d.ts +1 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +1 -1
  22. package/dist/types/session/session-context.d.ts +9 -0
  23. package/dist/types/task/executor.d.ts +26 -13
  24. package/dist/types/task/index.d.ts +12 -11
  25. package/dist/types/task/label.d.ts +4 -0
  26. package/dist/types/task/repair-args.d.ts +8 -8
  27. package/dist/types/task/types.d.ts +31 -56
  28. package/dist/types/tools/ask.d.ts +12 -0
  29. package/dist/types/tools/conflict-detect.d.ts +17 -1
  30. package/dist/types/tools/job.d.ts +16 -0
  31. package/package.json +12 -12
  32. package/src/async/job-manager.ts +9 -0
  33. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  34. package/src/config/settings-schema.ts +17 -6
  35. package/src/config/settings.ts +13 -4
  36. package/src/discovery/helpers.ts +3 -4
  37. package/src/extensibility/custom-tools/loader.ts +70 -37
  38. package/src/extensibility/extensions/types.ts +46 -0
  39. package/src/irc/bus.ts +61 -20
  40. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  41. package/src/modes/components/advisor-config.ts +30 -22
  42. package/src/modes/components/ask-dialog.ts +888 -0
  43. package/src/modes/components/index.ts +2 -1
  44. package/src/modes/components/model-browser.ts +688 -0
  45. package/src/modes/components/model-hub.ts +1655 -0
  46. package/src/modes/components/pause-screen.ts +208 -0
  47. package/src/modes/components/session-selector.ts +299 -42
  48. package/src/modes/components/tool-execution.ts +2 -0
  49. package/src/modes/controllers/event-controller.ts +8 -2
  50. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  51. package/src/modes/controllers/selector-controller.ts +143 -87
  52. package/src/modes/controllers/tan-command-controller.ts +1 -1
  53. package/src/modes/interactive-mode.ts +3 -0
  54. package/src/modes/shared.ts +1 -1
  55. package/src/modes/theme/theme.ts +3 -3
  56. package/src/modes/types.ts +2 -0
  57. package/src/modes/utils/ui-helpers.ts +31 -4
  58. package/src/prompts/agents/scout.md +0 -1
  59. package/src/prompts/agents/task.md +1 -1
  60. package/src/prompts/system/subagent-system-prompt.md +1 -5
  61. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  62. package/src/prompts/system/task-label.md +23 -0
  63. package/src/prompts/tools/job.md +1 -1
  64. package/src/prompts/tools/task-summary.md +3 -0
  65. package/src/prompts/tools/task.md +17 -18
  66. package/src/session/agent-session.ts +2 -1
  67. package/src/session/session-context.test.ts +73 -0
  68. package/src/session/session-context.ts +43 -26
  69. package/src/slash-commands/builtin-registry.ts +9 -0
  70. package/src/task/agents.ts +2 -0
  71. package/src/task/executor.ts +159 -46
  72. package/src/task/index.ts +377 -239
  73. package/src/task/label.ts +38 -0
  74. package/src/task/render.ts +74 -22
  75. package/src/task/repair-args.ts +20 -31
  76. package/src/task/spawn-policy.test.ts +4 -4
  77. package/src/task/types.ts +46 -66
  78. package/src/tools/ask.ts +233 -40
  79. package/src/tools/conflict-detect.ts +102 -5
  80. package/src/tools/index.ts +1 -0
  81. package/src/tools/irc.ts +20 -11
  82. package/src/tools/job.ts +158 -18
  83. package/src/tools/write.ts +70 -6
  84. package/src/vibe/runtime.ts +1 -1
  85. package/src/web/search/providers/browser-headers.ts +30 -13
  86. package/dist/types/modes/components/model-selector.d.ts +0 -37
  87. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  88. package/src/modes/components/model-selector.ts +0 -1291
  89. package/src/tools/bash-command-fixup.ts +0 -4
@@ -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);
@@ -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,23 +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, action) => {
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) {
627
+ if (action === "retryFallback") {
605
628
  const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
606
629
  const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
607
630
  const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
@@ -610,25 +633,10 @@ export class SelectorController {
610
633
  [role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
611
634
  });
612
635
  const roleInfo = getRoleInfo(role, settings);
613
- const roleLabel = roleInfo?.name ?? role;
614
- this.ctx.showStatus(`${roleLabel} fallback model: ${fallbackSelector}`);
636
+ this.ctx.showStatus(`${roleInfo?.name ?? role} fallback model: ${fallbackSelector}`);
615
637
  return;
616
638
  }
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") {
639
+ if (role === "default") {
632
640
  const { switched } = await this.ctx.session.setModel(model, role, {
633
641
  selector,
634
642
  thinkingLevel: concreteThinking,
@@ -649,33 +657,82 @@ export class SelectorController {
649
657
  this.ctx.updateEditorBorderColor();
650
658
  }
651
659
  this.ctx.showStatus(`Default model: ${selector ?? model.id}`);
652
- // Don't call done() - selector stays open for role assignment
653
660
  } 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
- );
661
+ // Other roles (smol, slow, custom): update settings, not the current model.
662
+ this.ctx.settings.setModelRole(role, formatModelSelectorValue(selectorValue, concreteThinking));
659
663
  if (isAuto) {
660
664
  this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
661
665
  }
662
666
  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
667
+ this.ctx.showStatus(`${roleInfo?.name ?? role} model: ${selector ?? model.id}`);
666
668
  }
667
669
  } catch (error) {
668
670
  this.ctx.showError(error instanceof Error ? error.message : String(error));
669
671
  }
670
672
  },
671
- () => {
673
+ onUnassign: role => {
674
+ try {
675
+ this.ctx.settings.setModelRole(role, undefined);
676
+ const roleInfo = getRoleInfo(role, settings);
677
+ this.ctx.showStatus(`${roleInfo?.name ?? role} role cleared — auto-selection applies`);
678
+ } catch (error) {
679
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
680
+ }
681
+ },
682
+ onPick: async (model, selector) => {
683
+ try {
684
+ // Session-only: update agent state but don't persist the model to settings.
685
+ await this.ctx.session.setModelTemporary(model);
686
+ this.ctx.statusLine.invalidate();
687
+ this.ctx.updateEditorBorderColor();
688
+ const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
689
+ this.ctx.showStatus(
690
+ `Session-only model: ${selector ?? model.id}. Use ${roleSelectorHint} or /model for roles.`,
691
+ );
692
+ done();
693
+ } catch (error) {
694
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
695
+ }
696
+ },
697
+ onLoginRequest: providerId => {
672
698
  done();
673
- this.ctx.ui.requestRender();
699
+ void this.#loginThenReopenModelHub(providerId);
674
700
  },
675
- { ...options, currentContextTokens },
676
- );
677
- return { component: selector, focus: selector };
701
+ onCycleOrderChange: order => {
702
+ try {
703
+ this.ctx.settings.set("cycleOrder", order);
704
+ this.ctx.showStatus(
705
+ order.length > 0 ? `Quick-switch cycle: ${order.join(" → ")}` : "Quick-switch cycle cleared",
706
+ );
707
+ } catch (error) {
708
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
709
+ }
710
+ },
711
+ onCancel: () => done(),
712
+ },
713
+ {
714
+ mode: hubOptions.mode,
715
+ currentContextTokens,
716
+ initialProviderId: hubOptions.initialProviderId,
717
+ },
718
+ );
719
+ overlayHandle = this.ctx.ui.showOverlay(hub, {
720
+ anchor: "bottom-center",
721
+ width: "100%",
722
+ maxHeight: "100%",
723
+ margin: 0,
724
+ fullscreen: true,
678
725
  });
726
+ this.ctx.ui.setFocus(hub);
727
+ this.ctx.ui.requestRender();
728
+ }
729
+
730
+ /** /login round-trip for a locked provider; reopen the hub on that provider only after a successful login. */
731
+ async #loginThenReopenModelHub(providerId: string): Promise<void> {
732
+ const succeeded = await this.#handleOAuthLogin(providerId);
733
+ if (succeeded) {
734
+ this.#showModelHub({ mode: "roles", initialProviderId: providerId });
735
+ }
679
736
  }
680
737
 
681
738
  async showPluginSelector(mode: "install" | "uninstall" = "install"): Promise<void> {
@@ -1122,60 +1179,51 @@ export class SelectorController {
1122
1179
  await this.showSessionSelector();
1123
1180
  }
1124
1181
 
1125
- async #handleOAuthLogin(providerId: string): Promise<void> {
1182
+ /**
1183
+ * Run the OAuth login flow for `providerId` inside a cancellable
1184
+ * {@link LoginDialogComponent} that replaces the editor slot. Esc aborts:
1185
+ * the dialog's abort signal reaches the provider flow, any pending prompt
1186
+ * rejects, and the editor is restored immediately. Returns true when
1187
+ * credentials were stored.
1188
+ */
1189
+ async #handleOAuthLogin(providerId: string): Promise<boolean> {
1126
1190
  this.ctx.showStatus(`Logging in to ${providerId}…`);
1127
1191
  const manualInput = this.ctx.oauthManualInput;
1128
1192
  const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
1193
+ let restored = false;
1194
+ const restoreEditor = () => {
1195
+ if (restored) return;
1196
+ restored = true;
1197
+ this.ctx.editorContainer.clear();
1198
+ this.ctx.editorContainer.addChild(this.ctx.editor);
1199
+ this.ctx.ui.setFocus(this.ctx.editor);
1200
+ this.ctx.ui.requestRender();
1201
+ };
1202
+ const dialog = new LoginDialogComponent(this.ctx.ui, providerId, (_success, message) => {
1203
+ // Fires on Esc: unblock the editor immediately; the aborted flow's
1204
+ // rejection settles the awaited login below.
1205
+ restoreEditor();
1206
+ if (message) this.ctx.showStatus(message);
1207
+ });
1208
+ this.ctx.editorContainer.clear();
1209
+ this.ctx.editorContainer.addChild(dialog);
1210
+ this.ctx.ui.setFocus(dialog);
1211
+ this.ctx.ui.requestRender();
1129
1212
  try {
1130
1213
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
1214
+ signal: dialog.signal,
1131
1215
  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
- }
1216
+ // The dialog renders the full URL (SSH-safe copy target) and
1217
+ // opens the browser best-effort.
1218
+ dialog.showAuth(info.url, info.instructions, info.launchUrl);
1148
1219
  if (useManualInput) {
1149
- block.addChild(new Spacer(1));
1150
- block.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
1220
+ dialog.showProgress(MANUAL_LOGIN_TIP);
1151
1221
  }
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));
1160
- }
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
1222
  },
1223
+ onPrompt: (prompt: { message: string; placeholder?: string }) =>
1224
+ dialog.showPrompt(prompt.message, prompt.placeholder),
1177
1225
  onProgress: (message: string) => {
1178
- this.ctx.present(new Text(theme.fg("dim", message), 1, 0));
1226
+ dialog.showProgress(message);
1179
1227
  },
1180
1228
  onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
1181
1229
  });
@@ -1186,12 +1234,20 @@ export class SelectorController {
1186
1234
  );
1187
1235
  block.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
1188
1236
  this.ctx.present(block);
1237
+ return true;
1189
1238
  } catch (error: unknown) {
1239
+ if (dialog.signal.aborted) {
1240
+ // User-cancelled: the dialog already restored the editor and
1241
+ // surfaced "Login cancelled".
1242
+ return false;
1243
+ }
1190
1244
  this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
1245
+ return false;
1191
1246
  } finally {
1192
1247
  if (useManualInput) {
1193
1248
  manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
1194
1249
  }
1250
+ restoreEditor();
1195
1251
  }
1196
1252
  }
1197
1253
 
@@ -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);
@@ -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
  }
@@ -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)),