@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.
@@ -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
- const list = store.models;
480
- if (list.length === 0) {
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 - 1 + list.length) % list.length;
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 + 1) % list.length;
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 chosen = list[store.modelPicker.index];
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
- if (chosen)
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.store.modelPicker.open = true;
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 { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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
- const MAX_VISIBLE = 8;
6
- /** Interactive model selector shown above the composer (opened by /model). */
7
- export function ModelPicker({ width }) {
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
- if (list.length === 0) {
14
- return (_jsx(Box, { width: width, children: _jsx(Text, { color: dim, children: "no models available \u2014 configure a provider key first" }) }));
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
- const idx = Math.min(Math.max(store.modelPicker.index, 0), list.length - 1);
17
- const start = Math.max(0, Math.min(idx, list.length - MAX_VISIBLE));
18
- const window = list.slice(start, start + MAX_VISIBLE);
19
- return (_jsxs(Box, { width: width, flexDirection: "column", children: [start > 0 && _jsxs(Text, { color: muted, children: ["\u25B2 ", (start).toLocaleString(), " more\u2026"] }), window.map((m, i) => {
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
- return (_jsxs(Box, { children: [_jsx(Text, { color: at === idx ? accent : dim, children: at === idx ? '❯ ' : ' ' }), _jsx(Text, { color: at === idx ? accent : dim, children: m })] }, m));
22
- }), start + MAX_VISIBLE < list.length && _jsx(Text, { color: muted, children: "\u25BC more\u2026" }), _jsx(Text, { color: muted, children: "\u2191\u2193 select \u00B7 Enter choose \u00B7 Esc close" })] }));
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbit-intelligence/orbit-agent",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "orbit — a premium pure-TypeScript coding agent for Termux and desktop terminals, powered by Orbit X.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",