@orbit-intelligence/orbit-agent 0.3.14 → 0.3.16

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.
@@ -1,4 +1,4 @@
1
- import { readFileSync, readdirSync, statSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { resolve, dirname, join, basename } from 'node:path';
3
3
  /**
4
4
  * Project-scoped instruction loading, modeled on Agent Build / Claude Code:
@@ -14,6 +14,10 @@ import { resolve, dirname, join, basename } from 'node:path';
14
14
  */
15
15
  const CONVENTION_FILES = ['AGENTS.md', '.orbit/AGENTS.md', '.agents/AGENTS.md', 'CLAUDE.md'];
16
16
  const MAX_TOTAL_BYTES = 120_000;
17
+ /** Files holding long-term memory (read once at startup, editable via /remember). */
18
+ const MEMORY_FILES = ['MEMORY.md', '.orbit/MEMORY.md'];
19
+ /** Cap for injected memory, ~25KB; /remember bounds appends to this. */
20
+ const MAX_MEMORY_BYTES = 25_000;
17
21
  export function loadProjectContext(cwd) {
18
22
  const files = [];
19
23
  const bodies = [];
@@ -34,6 +38,7 @@ export function loadProjectContext(cwd) {
34
38
  }
35
39
  return total > MAX_TOTAL_BYTES;
36
40
  });
41
+ const { memory, memoryFile } = loadMemory(cwd);
37
42
  const skillsDir = findSkillsDir(cwd);
38
43
  const skillBodies = {};
39
44
  const skills = [];
@@ -52,7 +57,62 @@ export function loadProjectContext(cwd) {
52
57
  }
53
58
  skills.sort((a, b) => a.name.localeCompare(b.name));
54
59
  }
55
- return { files, conventions: bodies.join('\n'), skills, skillBodies };
60
+ return { files, conventions: bodies.join('\n'), memory, memoryFile, skills, skillBodies };
61
+ }
62
+ /**
63
+ * Locate memory files walking up from cwd (lowest dir wins), concatenating in
64
+ * order. Capped at MAX_MEMORY_BYTES like conventions but separately, so a blob
65
+ * of memory never eats the AGENTS.md budget.
66
+ */
67
+ function loadMemory(cwd) {
68
+ const found = [];
69
+ collectUpward(cwd, (dir) => {
70
+ for (const name of MEMORY_FILES) {
71
+ const p = join(dir, name);
72
+ if (found.some((f) => f.p === p))
73
+ continue;
74
+ const raw = readSafe(p);
75
+ if (raw === null)
76
+ continue;
77
+ found.push({ p, raw });
78
+ }
79
+ return false;
80
+ });
81
+ if (found.length === 0)
82
+ return { memory: '', memoryFile: null };
83
+ let memory = '';
84
+ for (const { p, raw } of found) {
85
+ memory += `## ${relLabel(p, cwd)}\n${raw.trim()}\n\n`;
86
+ if (memory.length > MAX_MEMORY_BYTES)
87
+ break;
88
+ }
89
+ return { memory: memory.slice(0, MAX_MEMORY_BYTES), memoryFile: found[0].p };
90
+ }
91
+ /**
92
+ * Append a note to the project's memory file (/remember). Creates MEMORY.md in
93
+ * cwd when none exists. Returns the file path written, or null on failure.
94
+ */
95
+ export function appendProjectMemory(cwd, note) {
96
+ const target = resolve(cwd, 'MEMORY.md');
97
+ const existing = readSafe(target) ?? '';
98
+ const header = existing.trim().length > 0 ? '' : '# orbit memory\n';
99
+ let body = existing + (existing.endsWith('\n') ? '' : '\n') + `- ${new Date().toISOString().slice(0, 10)}: ${note.trim()}\n`;
100
+ if (body.length > MAX_MEMORY_BYTES) {
101
+ // Keep the newest notes: drop the oldest lines until under cap.
102
+ const lines = body.split('\n');
103
+ while (body.length > MAX_MEMORY_BYTES && lines.length > 4) {
104
+ lines.shift();
105
+ body = lines.join('\n');
106
+ }
107
+ }
108
+ try {
109
+ mkdirSync(dirname(target), { recursive: true });
110
+ writeFileSync(target, header + body, 'utf8');
111
+ return target;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
56
116
  }
57
117
  function collectUpward(start, visit) {
58
118
  let dir = resolve(start);
@@ -15,12 +15,16 @@ export class ToolRegistry {
15
15
  ctx;
16
16
  constructor(init) {
17
17
  this.ctx = { cwd: init.cwd, canWrite: init.canWrite ?? true };
18
- this.register(readFileTool);
19
- this.register(editFileTool);
20
- this.register(writeFileTool);
21
- this.register(listDirTool);
22
- this.register(globTool);
23
- this.register(grepTool);
18
+ if (init.filesystem ?? true) {
19
+ this.register(readFileTool);
20
+ this.register(editFileTool);
21
+ this.register(writeFileTool);
22
+ this.register(listDirTool);
23
+ this.register(globTool);
24
+ }
25
+ if (init.search ?? true) {
26
+ this.register(grepTool);
27
+ }
24
28
  if (init.withGit ?? true) {
25
29
  for (const t of gitTools)
26
30
  this.register(t);
@@ -33,7 +33,11 @@ export function InkApp({ controller }) {
33
33
  const theme = store.theme ?? buildTheme('tokyonight');
34
34
  const menuing = store.input.currentBuffer().startsWith('/');
35
35
  const menuLines = menuing ? store.slashMatches().length + 1 : 0;
36
- const modelLines = store.modelPicker.open ? Math.min(store.pickRows.length, 12) + 3 : 0;
36
+ const modelLines = store.modelPicker.open
37
+ ? store.keyEntry
38
+ ? 8
39
+ : Math.min(store.pickRows.length, 12) + 3
40
+ : 0;
37
41
  const dockLines = store.dockOpen
38
42
  ? store.agents.size === 0
39
43
  ? 2
@@ -4,7 +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 } from './picker.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';
8
11
  export class TuiApp {
9
12
  store = new AppStore();
10
13
  theme;
@@ -87,28 +90,48 @@ export class TuiApp {
87
90
  this.models = [...list];
88
91
  this.store.models = [...list];
89
92
  }
90
- /** Rebuild the grouped picker rows (e.g. after a provider/model switch). */
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
+ */
91
98
  async refreshModelPicker() {
92
- if (!this.pickBuilder)
93
- return;
99
+ const store = this.store;
100
+ const stage = store.pickStage;
101
+ const provider = store.pickProviderProviderId;
94
102
  try {
95
- const rows = await this.pickBuilder();
96
- this.store.pickRows = rows;
103
+ const rows = await (this.pickBuilder
104
+ ? this.pickBuilder(stage, provider, this.models)
105
+ : this.defaultRows(stage, provider));
106
+ store.pickRows = rows;
97
107
  if (rows.length === 0)
98
- this.store.modelPicker.index = 0;
99
- else if (this.store.modelPicker.index >= rows.length)
100
- this.store.modelPicker.index = rows.length - 1;
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
+ }
101
116
  }
102
117
  catch {
103
- this.store.pickRows = [];
118
+ store.pickRows = [];
104
119
  }
105
120
  }
106
- /** Open the /model overlay, refreshing rows first so the list is current. */
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. */
107
127
  async openModelPicker() {
108
- await this.refreshModelPicker();
109
128
  const store = this.store;
110
- if (store.pickRows.length === 0 && store.models.length > 0) {
111
- // Fallback when no builder: derive item rows from the routed models.
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) {
112
135
  const seen = new Set();
113
136
  for (const m of store.models) {
114
137
  const provider = m.split('/')[0] ?? 'orbitx';
@@ -118,7 +141,6 @@ export class TuiApp {
118
141
  store.pickRows.push({ kind: 'item', label: m, id: m, provider });
119
142
  }
120
143
  }
121
- store.modelPicker.index = findPickerIndex(store.pickRows, store.route?.model, this.config.model.primary);
122
144
  store.modelPicker.open = true;
123
145
  store.refresh();
124
146
  }
@@ -225,6 +247,18 @@ export class TuiApp {
225
247
  }
226
248
  this.store.refresh();
227
249
  });
250
+ // Automatic context compaction happened (agent loop) — surface it.
251
+ this.bus.on('onContextSummary', ({ droppedPairs, tokensBefore, tokensAfter }) => {
252
+ this.store.contextTokens = tokensAfter;
253
+ this.pushMessage({
254
+ id: `ctx-${Date.now()}`,
255
+ role: 'system',
256
+ content: droppedPairs > 0
257
+ ? `Context compacted: dropped ${droppedPairs} older turns (${tokensBefore} → ${tokensAfter} tokens).`
258
+ : `Context within budget (${tokensAfter} tokens).`,
259
+ createdAt: Date.now(),
260
+ });
261
+ });
228
262
  // Plan mode lifecycle.
229
263
  this.bus.on('onPlanProposed', ({ text }) => {
230
264
  const plan = this.store.plan;
@@ -519,6 +553,11 @@ export class TuiApp {
519
553
  }
520
554
  handleModelPickerKey(input, key) {
521
555
  const store = this.store;
556
+ // Inline key/custom-endpoint entry box: capture text, Enter advances.
557
+ if (store.keyEntry) {
558
+ this.handleKeyEntryKey(input, key);
559
+ return;
560
+ }
522
561
  const rows = store.pickRows;
523
562
  if (rows.length === 0) {
524
563
  store.modelPicker.open = false;
@@ -526,6 +565,13 @@ export class TuiApp {
526
565
  return;
527
566
  }
528
567
  if (key.escape) {
568
+ if (store.pickStage === 'models') {
569
+ // Back to provider list (stage 1), re-enter the source-of-truth rows.
570
+ store.pickStage = 'providers';
571
+ store.pickProviderProviderId = null;
572
+ void this.refreshModelPicker().then(() => store.refresh());
573
+ return;
574
+ }
529
575
  store.modelPicker.open = false;
530
576
  store.refresh();
531
577
  return;
@@ -547,6 +593,10 @@ export class TuiApp {
547
593
  store.refresh();
548
594
  return;
549
595
  }
596
+ if (store.pickStage === 'providers') {
597
+ void this.enterProviderScope(row);
598
+ return;
599
+ }
550
600
  store.modelPicker.open = false;
551
601
  store.refresh();
552
602
  void this.selectPickerModel(row);
@@ -555,6 +605,215 @@ export class TuiApp {
555
605
  if (input && !key.ctrl && !key.meta)
556
606
  store.refresh();
557
607
  }
608
+ /**
609
+ * Stage 1 Enter: open the chosen provider's key-entry (if missing a required
610
+ * key) or advance to its model list (stage 2).
611
+ */
612
+ async enterProviderScope(row) {
613
+ const store = this.store;
614
+ const providerRow = row;
615
+ const providerId = providerRow.providerId;
616
+ // Add-a-custom-endpoint flow.
617
+ if (providerId === '__add_custom__') {
618
+ store.keyEntry = { kind: 'endpoint-name', provider: '', value: '', cursorPos: 0, hint: 'name for this endpoint (e.g. lmstudio)' };
619
+ store.refresh();
620
+ return;
621
+ }
622
+ // Named key flows: provider needs a key and none is configured.
623
+ if (providerRow.needsKey) {
624
+ const p = providerId;
625
+ if (!isImportableDirectly(p)) {
626
+ this.note(`Provider "${providerId}" needs a key. Run \`orbit setup\` or set the env var.`);
627
+ return;
628
+ }
629
+ store.keyEntry = { kind: 'key-label', provider: p, value: '', cursorPos: 0, hint: `label for this ${p} key (e.g. "work")` };
630
+ store.refresh();
631
+ return;
632
+ }
633
+ await this.enterModelStage(providerId);
634
+ }
635
+ /** Advance to stage 2 (model list) for a provider, rebuilding rows live. */
636
+ async enterModelStage(providerId) {
637
+ const store = this.store;
638
+ store.pickStage = 'models';
639
+ store.pickProviderProviderId = providerId;
640
+ const rows = await (this.pickBuilder
641
+ ? this.pickBuilder('models', providerId, this.models)
642
+ : resolveModelRows(providerId, this.models));
643
+ // Custom endpoint with no discoverable models: let the user type the id.
644
+ if (providerId.startsWith('custom:') &&
645
+ rows.length === 1 &&
646
+ rows[0]?.kind === 'header' &&
647
+ /no models found/.test(rows[0].label)) {
648
+ const name = providerId.slice('custom:'.length);
649
+ store.keyEntry = {
650
+ kind: 'endpoint-fallback-model',
651
+ provider: name,
652
+ value: '',
653
+ cursorPos: 0,
654
+ hint: `model id served by ${name} (no live /v1/models response)`,
655
+ };
656
+ store.pickRows = rows;
657
+ store.refresh();
658
+ return;
659
+ }
660
+ store.pickRows = rows;
661
+ store.modelPicker.index = findPickerIndex(rows, store.route?.model, this.config.model.primary);
662
+ store.refresh();
663
+ }
664
+ /** Inline text boxes (key label/value, endpoint name/url/key, fallback model). */
665
+ handleKeyEntryKey(input, key) {
666
+ const store = this.store;
667
+ const e = store.keyEntry;
668
+ if (!e)
669
+ return;
670
+ const max = e.kind === 'key-value' ? 256 : 128;
671
+ if (key.escape) {
672
+ store.keyEntry = null;
673
+ store.refresh();
674
+ return;
675
+ }
676
+ if (key.return) {
677
+ void this.commitKeyEntry(e);
678
+ return;
679
+ }
680
+ if (key.backspace && e.value.length > 0) {
681
+ const pos = Math.max(0, e.cursorPos - 1);
682
+ e.value = e.value.slice(0, pos) + e.value.slice(e.cursorPos);
683
+ e.cursorPos = pos;
684
+ store.refresh();
685
+ return;
686
+ }
687
+ if (key.delete && e.cursorPos < e.value.length) {
688
+ e.value = e.value.slice(0, e.cursorPos) + e.value.slice(e.cursorPos + 1);
689
+ store.refresh();
690
+ return;
691
+ }
692
+ if (key.leftArrow) {
693
+ e.cursorPos = Math.max(0, e.cursorPos - 1);
694
+ store.refresh();
695
+ return;
696
+ }
697
+ if (key.rightArrow) {
698
+ e.cursorPos = Math.min(e.value.length, e.cursorPos + 1);
699
+ store.refresh();
700
+ return;
701
+ }
702
+ if (key.home) {
703
+ e.cursorPos = 0;
704
+ store.refresh();
705
+ return;
706
+ }
707
+ if (key.end) {
708
+ e.cursorPos = e.value.length;
709
+ store.refresh();
710
+ return;
711
+ }
712
+ if (key.ctrl && input === 'u') {
713
+ e.value = '';
714
+ e.cursorPos = 0;
715
+ store.refresh();
716
+ return;
717
+ }
718
+ if (input && !key.ctrl && !key.meta) {
719
+ if (e.value.length >= max) {
720
+ store.refresh();
721
+ return;
722
+ }
723
+ e.value = e.value.slice(0, e.cursorPos) + input + e.value.slice(e.cursorPos);
724
+ e.cursorPos += input.length;
725
+ store.refresh();
726
+ }
727
+ }
728
+ async commitKeyEntry(e) {
729
+ const store = this.store;
730
+ const val = e.value.trim();
731
+ switch (e.kind) {
732
+ case 'key-label':
733
+ if (!val) {
734
+ store.keyEntry = null;
735
+ store.refresh();
736
+ return;
737
+ }
738
+ store.keyEntry = {
739
+ kind: 'key-value',
740
+ provider: e.provider,
741
+ value: '',
742
+ cursorPos: 0,
743
+ hint: `paste the ${e.provider} API key (${val})`,
744
+ keyLabel: val,
745
+ };
746
+ store.refresh();
747
+ return;
748
+ case 'key-value': {
749
+ const p = e.provider;
750
+ const err = await saveNamedKey(p, e.keyLabel ?? '', val);
751
+ if (err) {
752
+ this.note(`Could not save key: ${err}`);
753
+ store.keyEntry = null;
754
+ store.refresh();
755
+ return;
756
+ }
757
+ store.keyEntry = null;
758
+ await this.enterModelStage(p);
759
+ return;
760
+ }
761
+ case 'endpoint-name':
762
+ if (!val) {
763
+ store.keyEntry = null;
764
+ store.refresh();
765
+ return;
766
+ }
767
+ store.keyEntry = { kind: 'endpoint-url', provider: val, value: '', cursorPos: 0, hint: 'OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1' };
768
+ store.refresh();
769
+ return;
770
+ case 'endpoint-url':
771
+ if (!val.startsWith('http')) {
772
+ this.note('Endpoint base URL must start with http:// or https://');
773
+ store.refresh();
774
+ return;
775
+ }
776
+ store.keyEntry = { kind: 'endpoint-key', provider: e.provider, value: '', cursorPos: 0, hint: 'optional API key (Enter to skip)', baseUrl: val };
777
+ store.refresh();
778
+ return;
779
+ case 'endpoint-key': {
780
+ const name = e.provider;
781
+ const err = await saveEndpoint({ name, baseUrl: e.baseUrl ?? '', key: val || undefined });
782
+ if (err) {
783
+ this.note(`Could not save endpoint: ${err}`);
784
+ store.keyEntry = null;
785
+ store.refresh();
786
+ return;
787
+ }
788
+ store.keyEntry = null;
789
+ // Refresh provider rows so the new endpoint appears, then jump to its models.
790
+ store.pickStage = 'providers';
791
+ await this.refreshModelPicker();
792
+ await this.enterModelStage(`custom:${name}`);
793
+ return;
794
+ }
795
+ case 'endpoint-fallback-model': {
796
+ store.keyEntry = null;
797
+ const name = e.provider;
798
+ if (val) {
799
+ rememberEndpointModel(name, val);
800
+ }
801
+ store.modelPicker.open = false;
802
+ store.refresh();
803
+ void this.selectEndpointModel(name, val);
804
+ return;
805
+ }
806
+ }
807
+ }
808
+ async selectEndpointModel(name, model) {
809
+ if (!model)
810
+ return;
811
+ const full = `${name}/${model}`;
812
+ rememberEndpointModel(name, model);
813
+ const result = await this.onCommand({ type: 'model', value: full });
814
+ if (result === null)
815
+ this.note(`Model → ${full}`);
816
+ }
558
817
  /** Step over the row list, skipping non-selectable header rows. */
559
818
  movePickerIndex(index, delta) {
560
819
  const rows = this.store.pickRows;
@@ -570,6 +829,13 @@ export class TuiApp {
570
829
  return index;
571
830
  }
572
831
  async selectPickerModel(item) {
832
+ // Custom endpoints: id is `<name>/<model>`; just switch the model (the
833
+ // endpoint's provider is registered by name). No provider switch needed.
834
+ if (item.provider.startsWith('custom:')) {
835
+ const name = item.provider.slice('custom:'.length);
836
+ await this.selectEndpointModel(name, item.id.slice(name.length + 1));
837
+ return;
838
+ }
573
839
  // Switching provider first lets the same Enter pick a model from a
574
840
  // provider that isn't active (e.g. gemini model while on openrouter).
575
841
  const currentProvider = this.store.route?.provider;
@@ -655,6 +921,8 @@ export class TuiApp {
655
921
  `/diff toggle unified diff view (or press d)`,
656
922
  `/skills list project skills (AGENTS.md / .orbit/skills)`,
657
923
  `/theme <name> change theme (${THEME_NAMES.join(' · ')})`,
924
+ `/compact summarize old context to free budget`,
925
+ `/remember <note> save a fact to MEMORY.md`,
658
926
  `/yolo auto-approve tools`,
659
927
  `/yes answer a permission prompt`,
660
928
  `/agents toggle agent dock (orchestrate mode)`,
@@ -753,6 +1021,30 @@ export class TuiApp {
753
1021
  store.yolo = true;
754
1022
  this.note('Permission mode → allow (yolo).');
755
1023
  return;
1024
+ case 'compact': {
1025
+ if (store.streaming) {
1026
+ this.note('Wait for the current turn to finish.');
1027
+ return;
1028
+ }
1029
+ this.note('Compacting context…');
1030
+ const result = await this.onCommand({ type: 'compact' });
1031
+ if (result) {
1032
+ store.contextTokens = parseTokenCount(result);
1033
+ this.note(result);
1034
+ }
1035
+ else
1036
+ this.note('Nothing to compact.');
1037
+ return;
1038
+ }
1039
+ case 'remember': {
1040
+ if (!arg) {
1041
+ this.note('Usage: /remember <note> — save a durable fact to MEMORY.md.');
1042
+ return;
1043
+ }
1044
+ const result = await this.onCommand({ type: 'remember', value: arg });
1045
+ this.note(result ?? 'Saved to memory.');
1046
+ return;
1047
+ }
756
1048
  case 'yes':
757
1049
  if (store.pendingAsk) {
758
1050
  const { resolve } = store.pendingAsk;
@@ -841,3 +1133,8 @@ function truncateForNote(text, max = 60) {
841
1133
  const t = text.replace(/\s+/g, ' ').trim();
842
1134
  return t.length <= max ? t : `${t.slice(0, max)}…`;
843
1135
  }
1136
+ /** Pull the trailing "N tokens" value out of a /compact result message. */
1137
+ function parseTokenCount(result) {
1138
+ const m = result.match(/(\d+) tokens?\.?$/);
1139
+ return m ? Number(m[1]) : null;
1140
+ }
@@ -19,5 +19,5 @@ export function Header({ width }) {
19
19
  const border = store.theme ? themeColor(store.theme, 'border') : '#565f89';
20
20
  const model = store.route ? `${store.route.provider}/${store.route.model}` : 'no route';
21
21
  const dir = shortPath(store.cwd || process.cwd());
22
- return (_jsxs(Box, { width: width, borderStyle: "single", borderColor: border, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { height: 1, children: [_jsx(Text, { color: accent, bold: true, wrap: "truncate-end", children: '>_ orbit' }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: ` (v${store.version})` }), _jsx(Text, { color: muted, wrap: "truncate-end", children: ` model: ${model}` }), _jsx(Text, { color: dim, wrap: "truncate-end", children: ' /model' })] }), _jsx(Box, { height: 1, children: _jsx(Text, { color: dim, wrap: "truncate-end", children: `directory: ${dir}` }) })] }));
22
+ return (_jsxs(Box, { width: width, borderStyle: "single", borderColor: border, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { height: 1, children: [_jsx(Text, { color: accent, bold: true, wrap: "truncate-end", children: '>_ orbit' }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: ` (v${store.version})` }), _jsx(Text, { color: muted, wrap: "truncate-end", children: ` model: ${model}` }), _jsx(Text, { color: dim, wrap: "truncate-end", children: ' /model' })] }), _jsxs(Box, { height: 1, children: [_jsx(Text, { color: dim, wrap: "truncate-end", children: `directory: ${dir}` }), store.contextTokens !== null && (_jsx(Text, { color: muted, wrap: "truncate-end", children: ` context: ${store.contextTokens} tok` }))] })] }));
23
23
  }
@@ -1,9 +1,9 @@
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
5
  import { isPickerHeader } from '../picker.js';
6
- /** Model selector overlay (opened by /model). Height includes the border. */
6
+ /** Model/provider selector overlay (opened by /model). Height includes border. */
7
7
  export function ModelPicker({ height }) {
8
8
  const store = useStore();
9
9
  const accent = store.theme ? themeColor(store.theme, 'accent') : '#7aa2f7';
@@ -13,8 +13,15 @@ export function ModelPicker({ height }) {
13
13
  const rows = store.pickRows;
14
14
  const idx = rows.length === 0 ? 0 : Math.min(Math.max(store.modelPicker.index, 0), rows.length - 1);
15
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" })] }));
22
+ }
16
23
  if (rows.length === 0) {
17
- return (_jsxs(Box, { borderStyle: "round", borderColor: dim, width: "100%", height: height, flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: dim, children: "no models available \u2014 configure a provider key first" }), _jsx(Text, { color: muted, children: "Esc close" })] }));
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" })] }));
18
25
  }
19
26
  const start = Math.max(0, Math.min(idx, rows.length - innerH));
20
27
  const windowRows = rows.slice(start, start + innerH);
@@ -23,6 +30,7 @@ export function ModelPicker({ height }) {
23
30
  if (isPickerHeader(r)) {
24
31
  return (_jsx(Box, { children: _jsx(Text, { color: attention, bold: true, wrap: "truncate-end", children: r.label }) }, `h-${at}`));
25
32
  }
26
- 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 })] }, r.id));
27
- }), start + innerH < rows.length && (_jsxs(Text, { color: muted, children: ["\u25BC ", rows.length - start - innerH, " more\u2026"] })), _jsx(Text, { color: muted, children: "\u2191\u2193 select \u00B7 Enter switch \u00B7 Esc close" })] }));
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' })] }));
28
36
  }