@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.
- package/dist/prompts/system.js +7 -0
- package/dist/src/cli/orchestrate.js +7 -1
- package/dist/src/cli/run.js +104 -10
- package/dist/src/core/agent/agent-loop.js +34 -7
- package/dist/src/core/context/context-manager.js +40 -6
- package/dist/src/core/llm/endpoints.js +110 -0
- package/dist/src/core/llm/index.js +16 -0
- package/dist/src/core/llm/sanitize.js +47 -0
- package/dist/src/core/llm/secrets.js +61 -3
- package/dist/src/core/project-context.js +62 -2
- package/dist/src/core/tools/registry.js +10 -6
- package/dist/src/tui/InkApp.js +5 -1
- package/dist/src/tui/app.js +312 -15
- package/dist/src/tui/components/Header.js +1 -1
- package/dist/src/tui/components/ModelPicker.js +13 -5
- package/dist/src/tui/picker.js +127 -39
- package/dist/src/tui/store.js +10 -0
- package/dist/src/utils/platform.js +3 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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);
|
package/dist/src/tui/InkApp.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/src/tui/app.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
else if (
|
|
100
|
-
|
|
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
|
-
|
|
118
|
+
store.pickRows = [];
|
|
104
119
|
}
|
|
105
120
|
}
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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' })] }),
|
|
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 {
|
|
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
|
|
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: [
|
|
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
|
-
|
|
27
|
-
|
|
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
|
}
|