@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.
- package/CHANGELOG.md +33 -0
- package/dist/cli.js +3563 -3540
- package/dist/types/async/job-manager.d.ts +8 -0
- package/dist/types/config/settings-schema.d.ts +18 -8
- package/dist/types/config/settings.d.ts +3 -2
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +36 -0
- package/dist/types/irc/bus.d.ts +4 -0
- package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
- package/dist/types/modes/components/ask-dialog.d.ts +27 -0
- package/dist/types/modes/components/index.d.ts +2 -1
- package/dist/types/modes/components/model-browser.d.ts +97 -0
- package/dist/types/modes/components/model-hub.d.ts +51 -0
- package/dist/types/modes/components/pause-screen.d.ts +43 -0
- package/dist/types/modes/components/session-selector.d.ts +13 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/shared.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +9 -0
- package/dist/types/task/executor.d.ts +26 -13
- package/dist/types/task/index.d.ts +12 -11
- package/dist/types/task/label.d.ts +4 -0
- package/dist/types/task/repair-args.d.ts +8 -8
- package/dist/types/task/types.d.ts +31 -56
- package/dist/types/tools/ask.d.ts +12 -0
- package/dist/types/tools/conflict-detect.d.ts +17 -1
- package/dist/types/tools/job.d.ts +16 -0
- package/package.json +12 -12
- package/src/async/job-manager.ts +9 -0
- package/src/commit/agentic/tools/analyze-file.ts +2 -3
- package/src/config/settings-schema.ts +17 -6
- package/src/config/settings.ts +13 -4
- package/src/discovery/helpers.ts +3 -4
- package/src/extensibility/custom-tools/loader.ts +70 -37
- package/src/extensibility/extensions/types.ts +46 -0
- package/src/irc/bus.ts +61 -20
- package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
- package/src/modes/components/advisor-config.ts +30 -22
- package/src/modes/components/ask-dialog.ts +888 -0
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/model-browser.ts +688 -0
- package/src/modes/components/model-hub.ts +1655 -0
- package/src/modes/components/pause-screen.ts +208 -0
- package/src/modes/components/session-selector.ts +299 -42
- package/src/modes/components/tool-execution.ts +2 -0
- package/src/modes/controllers/event-controller.ts +8 -2
- package/src/modes/controllers/extension-ui-controller.ts +252 -5
- package/src/modes/controllers/selector-controller.ts +143 -87
- package/src/modes/controllers/tan-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +3 -0
- package/src/modes/shared.ts +1 -1
- package/src/modes/theme/theme.ts +3 -3
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +31 -4
- package/src/prompts/agents/scout.md +0 -1
- package/src/prompts/agents/task.md +1 -1
- package/src/prompts/system/subagent-system-prompt.md +1 -5
- package/src/prompts/system/subagent-yield-reminder.md +10 -0
- package/src/prompts/system/task-label.md +23 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/prompts/tools/task-summary.md +3 -0
- package/src/prompts/tools/task.md +17 -18
- package/src/session/agent-session.ts +2 -1
- package/src/session/session-context.test.ts +73 -0
- package/src/session/session-context.ts +43 -26
- package/src/slash-commands/builtin-registry.ts +9 -0
- package/src/task/agents.ts +2 -0
- package/src/task/executor.ts +159 -46
- package/src/task/index.ts +377 -239
- package/src/task/label.ts +38 -0
- package/src/task/render.ts +74 -22
- package/src/task/repair-args.ts +20 -31
- package/src/task/spawn-policy.test.ts +4 -4
- package/src/task/types.ts +46 -66
- package/src/tools/ask.ts +233 -40
- package/src/tools/conflict-detect.ts +102 -5
- package/src/tools/index.ts +1 -0
- package/src/tools/irc.ts +20 -11
- package/src/tools/job.ts +158 -18
- package/src/tools/write.ts +70 -6
- package/src/vibe/runtime.ts +1 -1
- package/src/web/search/providers/browser-headers.ts +30 -13
- package/dist/types/modes/components/model-selector.d.ts +0 -37
- package/dist/types/tools/bash-command-fixup.d.ts +0 -3
- package/src/modes/components/model-selector.ts +0 -1291
- 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:
|
|
900
|
-
): Promise<
|
|
901
|
-
const { promise, resolve, reject } = Promise.withResolvers<
|
|
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:
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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"
|
|
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
|
-
|
|
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 ===
|
|
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):
|
|
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
|
-
|
|
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
|
|
699
|
+
void this.#loginThenReopenModelHub(providerId);
|
|
674
700
|
},
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1133
|
-
//
|
|
1134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
}
|
package/src/modes/shared.ts
CHANGED
|
@@ -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
|
|
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)),
|