@orbit-intelligence/orbit-agent 0.3.13 → 0.3.15
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/dist/src/cli/run.js +10 -2
- package/dist/src/config/config-schema.js +11 -1
- package/dist/src/core/agent/agent-loop.js +9 -4
- package/dist/src/core/llm/endpoints.js +110 -0
- package/dist/src/core/llm/http.js +13 -0
- package/dist/src/core/llm/index.js +39 -1
- package/dist/src/core/llm/models.js +175 -58
- package/dist/src/core/llm/providers/anthropic.js +237 -0
- package/dist/src/core/llm/providers/ollama.js +86 -0
- package/dist/src/core/llm/sanitize.js +47 -0
- package/dist/src/core/llm/secrets.js +74 -6
- package/dist/src/setup/wizard.js +7 -1
- package/dist/src/tui/InkApp.js +6 -2
- package/dist/src/tui/app.js +333 -11
- package/dist/src/tui/components/ModelPicker.js +26 -13
- package/dist/src/tui/picker.js +162 -0
- package/dist/src/tui/store.js +8 -0
- package/dist/src/utils/platform.js +3 -0
- package/package.json +1 -1
package/dist/src/tui/app.js
CHANGED
|
@@ -4,6 +4,10 @@ import { providerNames } from '../config/config-schema.js';
|
|
|
4
4
|
import { makeTheme, THEME_NAMES } from './themes/index.js';
|
|
5
5
|
import { AppStore, ASK_OPTIONS } from './store.js';
|
|
6
6
|
import { InkApp } from './InkApp.js';
|
|
7
|
+
import { findPickerIndex, isPickerHeader, resolveModelRows, resolveProviderRows, } from './picker.js';
|
|
8
|
+
import { saveNamedKey } from '../core/llm/secrets.js';
|
|
9
|
+
import { saveEndpoint, rememberEndpointModel } from '../core/llm/endpoints.js';
|
|
10
|
+
import { isImportableDirectly } from '../core/llm/secrets.js';
|
|
7
11
|
export class TuiApp {
|
|
8
12
|
store = new AppStore();
|
|
9
13
|
theme;
|
|
@@ -13,6 +17,7 @@ export class TuiApp {
|
|
|
13
17
|
config;
|
|
14
18
|
bus;
|
|
15
19
|
models;
|
|
20
|
+
pickBuilder = null;
|
|
16
21
|
instance = null;
|
|
17
22
|
done = false;
|
|
18
23
|
/** Last dispatched key (sig + timestamp) for de-duping keyboard repeats. */
|
|
@@ -26,6 +31,7 @@ export class TuiApp {
|
|
|
26
31
|
this.onCommand = opts.onCommand ?? (async () => null);
|
|
27
32
|
this.version = opts.version;
|
|
28
33
|
this.models = opts.models ?? [];
|
|
34
|
+
this.pickBuilder = opts.pickRows ?? null;
|
|
29
35
|
this.theme = makeTheme(opts.config.theme);
|
|
30
36
|
this.store.version = opts.version;
|
|
31
37
|
this.store.cwd = process.cwd();
|
|
@@ -79,6 +85,65 @@ export class TuiApp {
|
|
|
79
85
|
getMessages() {
|
|
80
86
|
return this.store.messages;
|
|
81
87
|
}
|
|
88
|
+
/** Push the current routed model list (after rebuild) into the app/UI. */
|
|
89
|
+
setModels(list) {
|
|
90
|
+
this.models = [...list];
|
|
91
|
+
this.store.models = [...list];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Rebuild the current picker stage's rows (providers, or models for the
|
|
95
|
+
* selected provider). The orbitx model list uses the LIVE routed models so
|
|
96
|
+
* offered == routable — this is the fix for "No model matches".
|
|
97
|
+
*/
|
|
98
|
+
async refreshModelPicker() {
|
|
99
|
+
const store = this.store;
|
|
100
|
+
const stage = store.pickStage;
|
|
101
|
+
const provider = store.pickProviderProviderId;
|
|
102
|
+
try {
|
|
103
|
+
const rows = await (this.pickBuilder
|
|
104
|
+
? this.pickBuilder(stage, provider, this.models)
|
|
105
|
+
: this.defaultRows(stage, provider));
|
|
106
|
+
store.pickRows = rows;
|
|
107
|
+
if (rows.length === 0)
|
|
108
|
+
store.modelPicker.index = 0;
|
|
109
|
+
else if (store.modelPicker.index >= rows.length)
|
|
110
|
+
store.modelPicker.index = rows.length - 1;
|
|
111
|
+
if (stage === 'providers') {
|
|
112
|
+
const cur = store.route?.provider;
|
|
113
|
+
const firstItem = rows.findIndex((r) => r.kind === 'item' && r.providerId === cur);
|
|
114
|
+
store.modelPicker.index = firstItem >= 0 ? firstItem : rows.findIndex((r) => r.kind === 'item');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
store.pickRows = [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
defaultRows(stage, provider) {
|
|
122
|
+
if (stage === 'providers')
|
|
123
|
+
return Promise.resolve(resolveProviderRows());
|
|
124
|
+
return resolveModelRows(provider ?? '', this.models);
|
|
125
|
+
}
|
|
126
|
+
/** Open the /model overlay, starting at the provider (stage 1) list. */
|
|
127
|
+
async openModelPicker() {
|
|
128
|
+
const store = this.store;
|
|
129
|
+
store.pickStage = 'providers';
|
|
130
|
+
store.pickProviderProviderId = null;
|
|
131
|
+
store.keyEntry = null;
|
|
132
|
+
await this.refreshModelPicker();
|
|
133
|
+
const rows = store.pickRows;
|
|
134
|
+
if (rows.length === 0) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
for (const m of store.models) {
|
|
137
|
+
const provider = m.split('/')[0] ?? 'orbitx';
|
|
138
|
+
if (seen.has(m))
|
|
139
|
+
continue;
|
|
140
|
+
seen.add(m);
|
|
141
|
+
store.pickRows.push({ kind: 'item', label: m, id: m, provider });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
store.modelPicker.open = true;
|
|
145
|
+
store.refresh();
|
|
146
|
+
}
|
|
82
147
|
ask(prompt) {
|
|
83
148
|
return new Promise((resolve) => {
|
|
84
149
|
this.store.pendingAsk = { prompt, index: 0, resolve };
|
|
@@ -476,38 +541,297 @@ export class TuiApp {
|
|
|
476
541
|
}
|
|
477
542
|
handleModelPickerKey(input, key) {
|
|
478
543
|
const store = this.store;
|
|
479
|
-
|
|
480
|
-
if (
|
|
544
|
+
// Inline key/custom-endpoint entry box: capture text, Enter advances.
|
|
545
|
+
if (store.keyEntry) {
|
|
546
|
+
this.handleKeyEntryKey(input, key);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const rows = store.pickRows;
|
|
550
|
+
if (rows.length === 0) {
|
|
481
551
|
store.modelPicker.open = false;
|
|
482
552
|
store.refresh();
|
|
483
553
|
return;
|
|
484
554
|
}
|
|
485
555
|
if (key.escape) {
|
|
556
|
+
if (store.pickStage === 'models') {
|
|
557
|
+
// Back to provider list (stage 1), re-enter the source-of-truth rows.
|
|
558
|
+
store.pickStage = 'providers';
|
|
559
|
+
store.pickProviderProviderId = null;
|
|
560
|
+
void this.refreshModelPicker().then(() => store.refresh());
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
486
563
|
store.modelPicker.open = false;
|
|
487
564
|
store.refresh();
|
|
488
565
|
return;
|
|
489
566
|
}
|
|
490
567
|
if (key.upArrow || (key.tab && key.shift)) {
|
|
491
|
-
store.modelPicker.index = (store.modelPicker.index -
|
|
568
|
+
store.modelPicker.index = this.movePickerIndex(store.modelPicker.index, -1);
|
|
492
569
|
store.refresh();
|
|
493
570
|
return;
|
|
494
571
|
}
|
|
495
572
|
if (key.downArrow || key.tab) {
|
|
496
|
-
store.modelPicker.index = (store.modelPicker.index
|
|
573
|
+
store.modelPicker.index = this.movePickerIndex(store.modelPicker.index, 1);
|
|
497
574
|
store.refresh();
|
|
498
575
|
return;
|
|
499
576
|
}
|
|
500
577
|
if (key.return) {
|
|
501
|
-
const
|
|
578
|
+
const row = rows[store.modelPicker.index];
|
|
579
|
+
if (!row || isPickerHeader(row)) {
|
|
580
|
+
store.modelPicker.open = false;
|
|
581
|
+
store.refresh();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (store.pickStage === 'providers') {
|
|
585
|
+
void this.enterProviderScope(row);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
502
588
|
store.modelPicker.open = false;
|
|
503
589
|
store.refresh();
|
|
504
|
-
|
|
505
|
-
void this.switchModel(chosen);
|
|
590
|
+
void this.selectPickerModel(row);
|
|
506
591
|
return;
|
|
507
592
|
}
|
|
508
593
|
if (input && !key.ctrl && !key.meta)
|
|
509
594
|
store.refresh();
|
|
510
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* Stage 1 Enter: open the chosen provider's key-entry (if missing a required
|
|
598
|
+
* key) or advance to its model list (stage 2).
|
|
599
|
+
*/
|
|
600
|
+
async enterProviderScope(row) {
|
|
601
|
+
const store = this.store;
|
|
602
|
+
const providerRow = row;
|
|
603
|
+
const providerId = providerRow.providerId;
|
|
604
|
+
// Add-a-custom-endpoint flow.
|
|
605
|
+
if (providerId === '__add_custom__') {
|
|
606
|
+
store.keyEntry = { kind: 'endpoint-name', provider: '', value: '', cursorPos: 0, hint: 'name for this endpoint (e.g. lmstudio)' };
|
|
607
|
+
store.refresh();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// Named key flows: provider needs a key and none is configured.
|
|
611
|
+
if (providerRow.needsKey) {
|
|
612
|
+
const p = providerId;
|
|
613
|
+
if (!isImportableDirectly(p)) {
|
|
614
|
+
this.note(`Provider "${providerId}" needs a key. Run \`orbit setup\` or set the env var.`);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
store.keyEntry = { kind: 'key-label', provider: p, value: '', cursorPos: 0, hint: `label for this ${p} key (e.g. "work")` };
|
|
618
|
+
store.refresh();
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
await this.enterModelStage(providerId);
|
|
622
|
+
}
|
|
623
|
+
/** Advance to stage 2 (model list) for a provider, rebuilding rows live. */
|
|
624
|
+
async enterModelStage(providerId) {
|
|
625
|
+
const store = this.store;
|
|
626
|
+
store.pickStage = 'models';
|
|
627
|
+
store.pickProviderProviderId = providerId;
|
|
628
|
+
const rows = await (this.pickBuilder
|
|
629
|
+
? this.pickBuilder('models', providerId, this.models)
|
|
630
|
+
: resolveModelRows(providerId, this.models));
|
|
631
|
+
// Custom endpoint with no discoverable models: let the user type the id.
|
|
632
|
+
if (providerId.startsWith('custom:') &&
|
|
633
|
+
rows.length === 1 &&
|
|
634
|
+
rows[0]?.kind === 'header' &&
|
|
635
|
+
/no models found/.test(rows[0].label)) {
|
|
636
|
+
const name = providerId.slice('custom:'.length);
|
|
637
|
+
store.keyEntry = {
|
|
638
|
+
kind: 'endpoint-fallback-model',
|
|
639
|
+
provider: name,
|
|
640
|
+
value: '',
|
|
641
|
+
cursorPos: 0,
|
|
642
|
+
hint: `model id served by ${name} (no live /v1/models response)`,
|
|
643
|
+
};
|
|
644
|
+
store.pickRows = rows;
|
|
645
|
+
store.refresh();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
store.pickRows = rows;
|
|
649
|
+
store.modelPicker.index = findPickerIndex(rows, store.route?.model, this.config.model.primary);
|
|
650
|
+
store.refresh();
|
|
651
|
+
}
|
|
652
|
+
/** Inline text boxes (key label/value, endpoint name/url/key, fallback model). */
|
|
653
|
+
handleKeyEntryKey(input, key) {
|
|
654
|
+
const store = this.store;
|
|
655
|
+
const e = store.keyEntry;
|
|
656
|
+
if (!e)
|
|
657
|
+
return;
|
|
658
|
+
const max = e.kind === 'key-value' ? 256 : 128;
|
|
659
|
+
if (key.escape) {
|
|
660
|
+
store.keyEntry = null;
|
|
661
|
+
store.refresh();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (key.return) {
|
|
665
|
+
void this.commitKeyEntry(e);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (key.backspace && e.value.length > 0) {
|
|
669
|
+
const pos = Math.max(0, e.cursorPos - 1);
|
|
670
|
+
e.value = e.value.slice(0, pos) + e.value.slice(e.cursorPos);
|
|
671
|
+
e.cursorPos = pos;
|
|
672
|
+
store.refresh();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (key.delete && e.cursorPos < e.value.length) {
|
|
676
|
+
e.value = e.value.slice(0, e.cursorPos) + e.value.slice(e.cursorPos + 1);
|
|
677
|
+
store.refresh();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (key.leftArrow) {
|
|
681
|
+
e.cursorPos = Math.max(0, e.cursorPos - 1);
|
|
682
|
+
store.refresh();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (key.rightArrow) {
|
|
686
|
+
e.cursorPos = Math.min(e.value.length, e.cursorPos + 1);
|
|
687
|
+
store.refresh();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (key.home) {
|
|
691
|
+
e.cursorPos = 0;
|
|
692
|
+
store.refresh();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (key.end) {
|
|
696
|
+
e.cursorPos = e.value.length;
|
|
697
|
+
store.refresh();
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (key.ctrl && input === 'u') {
|
|
701
|
+
e.value = '';
|
|
702
|
+
e.cursorPos = 0;
|
|
703
|
+
store.refresh();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (input && !key.ctrl && !key.meta) {
|
|
707
|
+
if (e.value.length >= max) {
|
|
708
|
+
store.refresh();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
e.value = e.value.slice(0, e.cursorPos) + input + e.value.slice(e.cursorPos);
|
|
712
|
+
e.cursorPos += input.length;
|
|
713
|
+
store.refresh();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async commitKeyEntry(e) {
|
|
717
|
+
const store = this.store;
|
|
718
|
+
const val = e.value.trim();
|
|
719
|
+
switch (e.kind) {
|
|
720
|
+
case 'key-label':
|
|
721
|
+
if (!val) {
|
|
722
|
+
store.keyEntry = null;
|
|
723
|
+
store.refresh();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
store.keyEntry = {
|
|
727
|
+
kind: 'key-value',
|
|
728
|
+
provider: e.provider,
|
|
729
|
+
value: '',
|
|
730
|
+
cursorPos: 0,
|
|
731
|
+
hint: `paste the ${e.provider} API key (${val})`,
|
|
732
|
+
keyLabel: val,
|
|
733
|
+
};
|
|
734
|
+
store.refresh();
|
|
735
|
+
return;
|
|
736
|
+
case 'key-value': {
|
|
737
|
+
const p = e.provider;
|
|
738
|
+
const err = await saveNamedKey(p, e.keyLabel ?? '', val);
|
|
739
|
+
if (err) {
|
|
740
|
+
this.note(`Could not save key: ${err}`);
|
|
741
|
+
store.keyEntry = null;
|
|
742
|
+
store.refresh();
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
store.keyEntry = null;
|
|
746
|
+
await this.enterModelStage(p);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
case 'endpoint-name':
|
|
750
|
+
if (!val) {
|
|
751
|
+
store.keyEntry = null;
|
|
752
|
+
store.refresh();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
store.keyEntry = { kind: 'endpoint-url', provider: val, value: '', cursorPos: 0, hint: 'OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1' };
|
|
756
|
+
store.refresh();
|
|
757
|
+
return;
|
|
758
|
+
case 'endpoint-url':
|
|
759
|
+
if (!val.startsWith('http')) {
|
|
760
|
+
this.note('Endpoint base URL must start with http:// or https://');
|
|
761
|
+
store.refresh();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
store.keyEntry = { kind: 'endpoint-key', provider: e.provider, value: '', cursorPos: 0, hint: 'optional API key (Enter to skip)', baseUrl: val };
|
|
765
|
+
store.refresh();
|
|
766
|
+
return;
|
|
767
|
+
case 'endpoint-key': {
|
|
768
|
+
const name = e.provider;
|
|
769
|
+
const err = await saveEndpoint({ name, baseUrl: e.baseUrl ?? '', key: val || undefined });
|
|
770
|
+
if (err) {
|
|
771
|
+
this.note(`Could not save endpoint: ${err}`);
|
|
772
|
+
store.keyEntry = null;
|
|
773
|
+
store.refresh();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
store.keyEntry = null;
|
|
777
|
+
// Refresh provider rows so the new endpoint appears, then jump to its models.
|
|
778
|
+
store.pickStage = 'providers';
|
|
779
|
+
await this.refreshModelPicker();
|
|
780
|
+
await this.enterModelStage(`custom:${name}`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
case 'endpoint-fallback-model': {
|
|
784
|
+
store.keyEntry = null;
|
|
785
|
+
const name = e.provider;
|
|
786
|
+
if (val) {
|
|
787
|
+
rememberEndpointModel(name, val);
|
|
788
|
+
}
|
|
789
|
+
store.modelPicker.open = false;
|
|
790
|
+
store.refresh();
|
|
791
|
+
void this.selectEndpointModel(name, val);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async selectEndpointModel(name, model) {
|
|
797
|
+
if (!model)
|
|
798
|
+
return;
|
|
799
|
+
const full = `${name}/${model}`;
|
|
800
|
+
rememberEndpointModel(name, model);
|
|
801
|
+
const result = await this.onCommand({ type: 'model', value: full });
|
|
802
|
+
if (result === null)
|
|
803
|
+
this.note(`Model → ${full}`);
|
|
804
|
+
}
|
|
805
|
+
/** Step over the row list, skipping non-selectable header rows. */
|
|
806
|
+
movePickerIndex(index, delta) {
|
|
807
|
+
const rows = this.store.pickRows;
|
|
808
|
+
if (rows.length === 0)
|
|
809
|
+
return 0;
|
|
810
|
+
let i = index;
|
|
811
|
+
for (let steps = 0; steps < rows.length; steps++) {
|
|
812
|
+
i = (i + delta + rows.length) % rows.length;
|
|
813
|
+
const row = rows[i];
|
|
814
|
+
if (row && !isPickerHeader(row))
|
|
815
|
+
return i;
|
|
816
|
+
}
|
|
817
|
+
return index;
|
|
818
|
+
}
|
|
819
|
+
async selectPickerModel(item) {
|
|
820
|
+
// Custom endpoints: id is `<name>/<model>`; just switch the model (the
|
|
821
|
+
// endpoint's provider is registered by name). No provider switch needed.
|
|
822
|
+
if (item.provider.startsWith('custom:')) {
|
|
823
|
+
const name = item.provider.slice('custom:'.length);
|
|
824
|
+
await this.selectEndpointModel(name, item.id.slice(name.length + 1));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
// Switching provider first lets the same Enter pick a model from a
|
|
828
|
+
// provider that isn't active (e.g. gemini model while on openrouter).
|
|
829
|
+
const currentProvider = this.store.route?.provider;
|
|
830
|
+
if (item.provider !== currentProvider) {
|
|
831
|
+
await this.switchProvider(item.provider);
|
|
832
|
+
}
|
|
833
|
+
await this.switchModel(item.id);
|
|
834
|
+
}
|
|
511
835
|
handleTabAutocomplete() {
|
|
512
836
|
const buf = this.store.input.currentBuffer();
|
|
513
837
|
const token = buf.split(/\s+/)[0] ?? '';
|
|
@@ -578,7 +902,7 @@ export class TuiApp {
|
|
|
578
902
|
`/clear clear the conversation`,
|
|
579
903
|
`/help show this help`,
|
|
580
904
|
`/model [id] pick a model (bare: interactive selector)`,
|
|
581
|
-
`/provider <id> switch provider (orbitx · groq · gemini · openrouter)`,
|
|
905
|
+
`/provider <id> switch provider (orbitx · groq · gemini · openrouter · openai · anthropic · grok · deepseek · ollama)`,
|
|
582
906
|
`/plan <task> plan first: propose a plan, approve with /run`,
|
|
583
907
|
`/run execute the proposed plan`,
|
|
584
908
|
`/reject discard the proposed plan`,
|
|
@@ -670,9 +994,7 @@ export class TuiApp {
|
|
|
670
994
|
}
|
|
671
995
|
case 'model':
|
|
672
996
|
if (!arg) {
|
|
673
|
-
this.
|
|
674
|
-
this.store.modelPicker.index = Math.max(0, this.models.findIndex((m) => m === this.store.route?.model || m.endsWith(`/${this.config.model.primary}`)));
|
|
675
|
-
this.store.refresh();
|
|
997
|
+
void this.openModelPicker();
|
|
676
998
|
return;
|
|
677
999
|
}
|
|
678
1000
|
await this.switchModel(arg);
|
|
@@ -1,23 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { useStore } from '../context.js';
|
|
4
4
|
import { themeColor } from '../colors.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
export function ModelPicker({
|
|
5
|
+
import { isPickerHeader } from '../picker.js';
|
|
6
|
+
/** Model/provider selector overlay (opened by /model). Height includes border. */
|
|
7
|
+
export function ModelPicker({ height }) {
|
|
8
8
|
const store = useStore();
|
|
9
|
-
const list = store.models;
|
|
10
9
|
const accent = store.theme ? themeColor(store.theme, 'accent') : '#7aa2f7';
|
|
11
10
|
const dim = store.theme ? themeColor(store.theme, 'dim') : '#565f89';
|
|
12
11
|
const muted = store.theme ? themeColor(store.theme, 'muted') : '#969cbc';
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const attention = store.theme ? themeColor(store.theme, 'attention') : '#c3a332';
|
|
13
|
+
const rows = store.pickRows;
|
|
14
|
+
const idx = rows.length === 0 ? 0 : Math.min(Math.max(store.modelPicker.index, 0), rows.length - 1);
|
|
15
|
+
const innerH = Math.max(1, height - 3); // border(2) + footer(1)
|
|
16
|
+
const stageLabel = store.pickStage === 'models' ? 'model' : 'provider';
|
|
17
|
+
// Inline key / custom-endpoint entry: show one prompt + the typed value.
|
|
18
|
+
if (store.keyEntry) {
|
|
19
|
+
const ke = store.keyEntry;
|
|
20
|
+
const display = ke.kind === 'key-value' ? (ke.value ? '••••••••'.slice(0, 8) : '') : ke.value;
|
|
21
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: accent, width: "100%", height: height, flexDirection: "column", paddingX: 1, children: [_jsx(Box, { children: _jsxs(Text, { color: attention, bold: true, wrap: "truncate-end", children: [ke.kind === 'key-label' && `${ke.provider} key — label`, ke.kind === 'key-value' && `${ke.provider} key — value`, ke.kind === 'endpoint-name' && 'custom endpoint — name', ke.kind === 'endpoint-url' && `custom endpoint — base URL`, ke.kind === 'endpoint-key' && `custom endpoint — optional key`, ke.kind === 'endpoint-fallback-model' && `custom endpoint — model id`] }) }), _jsx(Text, { color: muted, wrap: "truncate-end", children: ke.hint }), _jsxs(Box, { children: [_jsx(Text, { color: dim, children: "\u276F " }), _jsx(Text, { color: accent, children: display || ' ' }), _jsx(Text, { color: muted, children: "\u258F" })] }), _jsx(Text, { color: muted, children: "Enter accept \u00B7 Esc cancel" })] }));
|
|
15
22
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
if (rows.length === 0) {
|
|
24
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: dim, width: "100%", height: height, flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: dim, children: ["no ", stageLabel, "s available \u2014 configure a provider key first"] }), _jsx(Text, { color: muted, children: "Esc back \u00B7 Esc close" })] }));
|
|
25
|
+
}
|
|
26
|
+
const start = Math.max(0, Math.min(idx, rows.length - innerH));
|
|
27
|
+
const windowRows = rows.slice(start, start + innerH);
|
|
28
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: accent, width: "100%", height: height, flexDirection: "column", paddingX: 1, children: [start > 0 && (_jsxs(Text, { color: muted, children: ["\u25B2 ", start, " more\u2026"] })), windowRows.map((r, i) => {
|
|
20
29
|
const at = start + i;
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
if (isPickerHeader(r)) {
|
|
31
|
+
return (_jsx(Box, { children: _jsx(Text, { color: attention, bold: true, wrap: "truncate-end", children: r.label }) }, `h-${at}`));
|
|
32
|
+
}
|
|
33
|
+
const hint = r.hint;
|
|
34
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: at === idx ? accent : dim, children: at === idx ? '❯ ' : ' ' }), _jsx(Text, { color: at === idx ? accent : dim, wrap: "truncate-end", children: r.label }), hint && _jsxs(Text, { color: muted, children: [" ", hint] })] }, r.id ?? `p-${at}`));
|
|
35
|
+
}), start + innerH < rows.length && (_jsxs(Text, { color: muted, children: ["\u25BC ", rows.length - start - innerH, " more\u2026"] })), _jsx(Text, { color: muted, children: store.pickStage === 'models' ? '↑↓ select · Enter switch · Esc back' : '↑↓ select · Enter next · Esc close' })] }));
|
|
23
36
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { PROVIDER_SPECS, ORBITX_SERVE } from '../core/llm/index.js';
|
|
2
|
+
import { describeProviderKey } from '../core/llm/secrets.js';
|
|
3
|
+
import { createOllamaProvider } from '../core/llm/providers/ollama.js';
|
|
4
|
+
import { loadEndpoints, listEndpointModels } from '../core/llm/endpoints.js';
|
|
5
|
+
export function isPickerHeader(row) {
|
|
6
|
+
return row.kind === 'header';
|
|
7
|
+
}
|
|
8
|
+
function specLabel(id) {
|
|
9
|
+
return PROVIDER_SPECS.find((p) => p.id === id)?.label ?? id;
|
|
10
|
+
}
|
|
11
|
+
/** Stage 1 — provider rows (sync; no probing here). */
|
|
12
|
+
export function resolveProviderRows() {
|
|
13
|
+
const rows = [];
|
|
14
|
+
for (const spec of PROVIDER_SPECS) {
|
|
15
|
+
if (spec.id === 'ollama') {
|
|
16
|
+
const keyInfo = describeProviderKey('ollama');
|
|
17
|
+
rows.push({
|
|
18
|
+
kind: 'item',
|
|
19
|
+
providerId: 'ollama',
|
|
20
|
+
label: 'Ollama (local)',
|
|
21
|
+
hint: 'auto-detects a local server · no key required',
|
|
22
|
+
keyInfo: keyInfo === null ? { provider: 'ollama', available: false } : keyInfo,
|
|
23
|
+
});
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (spec.id === 'orbitx') {
|
|
27
|
+
const keyInfo = describeProviderKey('orbitx');
|
|
28
|
+
rows.push({
|
|
29
|
+
kind: 'item',
|
|
30
|
+
providerId: 'orbitx',
|
|
31
|
+
label: 'Orbit X (auto-routes Groq · Gemini · OpenRouter)',
|
|
32
|
+
hint: keyInfo?.available ? `token ${keyInfo.masked} · ${keyInfo.source}` : 'token optional — gateway auto-routes',
|
|
33
|
+
keyInfo: keyInfo === null ? { provider: 'orbitx', available: false } : keyInfo,
|
|
34
|
+
});
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const keyInfo = describeProviderKey(spec.id);
|
|
38
|
+
const needsKey = spec.requiresSecret && !keyInfo?.available;
|
|
39
|
+
rows.push({
|
|
40
|
+
kind: 'item',
|
|
41
|
+
providerId: spec.id,
|
|
42
|
+
label: spec.label,
|
|
43
|
+
hint: needsKey
|
|
44
|
+
? 'no key — press Enter to add'
|
|
45
|
+
: keyInfo
|
|
46
|
+
? `key ${keyInfo.masked} · ${keyInfo.source}${keyInfo.label ? ` · ${keyInfo.label}` : ''}`
|
|
47
|
+
: 'no key required',
|
|
48
|
+
needsKey,
|
|
49
|
+
keyInfo: keyInfo ?? null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Named custom OpenAI-compatible endpoints.
|
|
53
|
+
const endpoints = loadEndpoints();
|
|
54
|
+
rows.push({ kind: 'header', label: 'Custom endpoints (OpenAI-compatible)' });
|
|
55
|
+
for (const ep of endpoints) {
|
|
56
|
+
rows.push({
|
|
57
|
+
kind: 'item',
|
|
58
|
+
providerId: `custom:${ep.name}`,
|
|
59
|
+
label: ep.name,
|
|
60
|
+
hint: `${ep.baseUrl}${ep.key ? ' · key set' : ' · no key'}`,
|
|
61
|
+
keyInfo: { provider: ep.name, available: true, source: 'file' },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
rows.push({
|
|
65
|
+
kind: 'item',
|
|
66
|
+
providerId: '__add_custom__',
|
|
67
|
+
label: '+ add a custom endpoint',
|
|
68
|
+
hint: 'LM Studio · vLLM · any OpenAI-compatible server',
|
|
69
|
+
keyInfo: null,
|
|
70
|
+
});
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
/** Stage 2 — model rows for a provider. */
|
|
74
|
+
export async function resolveModelRows(providerId, liveModels) {
|
|
75
|
+
const rows = [];
|
|
76
|
+
if (providerId.startsWith('custom:')) {
|
|
77
|
+
const name = providerId.slice('custom:'.length);
|
|
78
|
+
const ep = loadEndpoints().find((e) => e.name === name);
|
|
79
|
+
if (!ep) {
|
|
80
|
+
rows.push({ kind: 'header', label: 'unknown custom endpoint' });
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
83
|
+
rows.push({ kind: 'header', label: `${name} — ${ep.baseUrl}` });
|
|
84
|
+
const live = await listEndpointModels(name);
|
|
85
|
+
const saved = (ep.models ?? []).filter(Boolean);
|
|
86
|
+
const merged = [...new Set([...live, ...saved])];
|
|
87
|
+
if (merged.length === 0) {
|
|
88
|
+
rows.push({
|
|
89
|
+
kind: 'header',
|
|
90
|
+
label: 'no models found — type a model id below (or start the server)',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
for (const m of merged) {
|
|
95
|
+
rows.push({ kind: 'item', label: m, id: `${name}/${m}`, provider: `custom:${name}` });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
if (providerId === 'ollama') {
|
|
101
|
+
const local = await createOllamaProvider().listModels();
|
|
102
|
+
if (local.length === 0) {
|
|
103
|
+
rows.push({ kind: 'header', label: 'Ollama server offline — run `ollama serve`' });
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
rows.push({ kind: 'header', label: `Ollama (local) — ${local.length} installed` });
|
|
107
|
+
for (const full of local) {
|
|
108
|
+
rows.push({ kind: 'item', label: full.slice('ollama/'.length), id: full, provider: 'ollama' });
|
|
109
|
+
}
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
112
|
+
if (providerId === 'orbitx') {
|
|
113
|
+
rows.push({ kind: 'header', label: 'Orbit X (auto-routes Groq · Gemini · OpenRouter)' });
|
|
114
|
+
// Prefer the LIVE routable list so offered == routable (kills "No model
|
|
115
|
+
// matches"); fall back to the serve table only when no orbitx candidates.
|
|
116
|
+
const live = liveModels.filter((m) => m.startsWith('orbitx/'));
|
|
117
|
+
const serve = live.length > 0 ? live : ORBITX_SERVE.map((b) => `orbitx/${b}`);
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
for (const full of serve) {
|
|
120
|
+
if (seen.has(full))
|
|
121
|
+
continue;
|
|
122
|
+
seen.add(full);
|
|
123
|
+
const bare = full.slice('orbitx/'.length);
|
|
124
|
+
rows.push({
|
|
125
|
+
kind: 'item',
|
|
126
|
+
label: bare === 'auto' ? 'auto (backend routes across providers)' : bare,
|
|
127
|
+
id: full,
|
|
128
|
+
provider: 'orbitx',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return rows;
|
|
132
|
+
}
|
|
133
|
+
// Catalog provider.
|
|
134
|
+
const spec = PROVIDER_SPECS.find((p) => p.id === providerId);
|
|
135
|
+
if (!spec) {
|
|
136
|
+
rows.push({ kind: 'header', label: `unknown provider ${providerId}` });
|
|
137
|
+
return rows;
|
|
138
|
+
}
|
|
139
|
+
rows.push({ kind: 'header', label: spec.label });
|
|
140
|
+
for (const m of spec.models) {
|
|
141
|
+
rows.push({ kind: 'item', label: m.label ?? m.id, id: `${spec.id}/${m.id}`, provider: spec.id });
|
|
142
|
+
}
|
|
143
|
+
if (spec.supportsCustom) {
|
|
144
|
+
rows.push({ kind: 'header', label: '— or type a custom model id below' });
|
|
145
|
+
}
|
|
146
|
+
return rows;
|
|
147
|
+
}
|
|
148
|
+
/** Row index whose item matches the current route/model, else the first item. */
|
|
149
|
+
export function findPickerIndex(rows, routeModel, configPrimary) {
|
|
150
|
+
if (rows.length === 0)
|
|
151
|
+
return 0;
|
|
152
|
+
const cur = routeModel || configPrimary || '';
|
|
153
|
+
for (let i = 0; i < rows.length; i++) {
|
|
154
|
+
const r = rows[i];
|
|
155
|
+
if (r && r.kind === 'item' && (r.id === cur || (configPrimary && r.id?.endsWith(`/${configPrimary.split('/').pop()}`)))) {
|
|
156
|
+
return i;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Fall back to the first item row.
|
|
160
|
+
const firstItem = rows.findIndex((r) => r.kind === 'item');
|
|
161
|
+
return firstItem >= 0 ? firstItem : 0;
|
|
162
|
+
}
|
package/dist/src/tui/store.js
CHANGED
|
@@ -39,6 +39,14 @@ export class AppStore {
|
|
|
39
39
|
cwd = '';
|
|
40
40
|
models = [];
|
|
41
41
|
modelPicker = { open: false, index: 0 };
|
|
42
|
+
/** Which stage of the /model overlay is showing rows: providers or models. */
|
|
43
|
+
pickStage = 'providers';
|
|
44
|
+
/** Provider whose model list is showing in the model stage. */
|
|
45
|
+
pickProviderProviderId = null;
|
|
46
|
+
/** Inline key/custom-endpoint entry overlay (two-stage picker). */
|
|
47
|
+
keyEntry = null;
|
|
48
|
+
/** Grouped rows for the /model overlay (see picker.ts). */
|
|
49
|
+
pickRows = [];
|
|
42
50
|
agents = new Map();
|
|
43
51
|
dockOpen = false;
|
|
44
52
|
yolo = false;
|
|
@@ -42,6 +42,9 @@ export function configPath() {
|
|
|
42
42
|
export function keysPath() {
|
|
43
43
|
return join(configDir(), 'keys.json');
|
|
44
44
|
}
|
|
45
|
+
export function endpointsPath() {
|
|
46
|
+
return join(configDir(), 'endpoints.json');
|
|
47
|
+
}
|
|
45
48
|
export function historyPath() {
|
|
46
49
|
return join(dataDir(), 'history.json');
|
|
47
50
|
}
|
package/package.json
CHANGED