lobstack 0.1.1
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/LICENSE +21 -0
- package/README.md +188 -0
- package/package.json +39 -0
- package/src/config.mjs +66 -0
- package/src/gateway.mjs +104 -0
- package/src/index.mjs +365 -0
- package/src/proxy.mjs +181 -0
- package/src/render.mjs +87 -0
- package/src/stream.mjs +70 -0
- package/src/tty.mjs +490 -0
- package/src/tui-view.mjs +621 -0
- package/src/tui.mjs +710 -0
package/src/tui.mjs
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive surface, and the two things it degrades into.
|
|
3
|
+
*
|
|
4
|
+
* Three code paths, chosen by what the terminal actually is:
|
|
5
|
+
*
|
|
6
|
+
* 1. `runTui` - a terminal on both ends and a TERM that can address a cursor.
|
|
7
|
+
* Alternate screen, raw keys, a persistent receipt and a running total.
|
|
8
|
+
* 2. `runLineMode` - a terminal, but not one that can be drawn on: TERM=dumb,
|
|
9
|
+
* or stdout redirected while stdin is still a keyboard. A plain prompt
|
|
10
|
+
* loop. Answers on stdout, receipts on stderr, exactly like `chat`.
|
|
11
|
+
* 3. `readAll` + `chat` - not a terminal at all. Handled by index.mjs, which
|
|
12
|
+
* treats piped stdin as the prompt and runs the one-shot path, so
|
|
13
|
+
* `echo hi | lobstack > out.txt` still puts only the answer in the file.
|
|
14
|
+
*
|
|
15
|
+
* The state lives here and the pixels live in tui-view.mjs. Keeping the frame
|
|
16
|
+
* a pure function of state is what lets the tests read the screen as text at
|
|
17
|
+
* any width without a pty.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createInterface } from 'node:readline/promises';
|
|
21
|
+
import { streamCompletion, fetchModels, fetchUsage, GatewayError } from './gateway.mjs';
|
|
22
|
+
import { printReceipt, money, savingsLabel } from './render.mjs';
|
|
23
|
+
import { startProxy } from './proxy.mjs';
|
|
24
|
+
import { Terminal, detect, styler, MIN_WIDTH } from './tty.mjs';
|
|
25
|
+
import { frame, newSession, accrue } from './tui-view.mjs';
|
|
26
|
+
|
|
27
|
+
/** Read a whole stream. Used when stdin is a pipe, not a keyboard. */
|
|
28
|
+
export async function readAll(stream) {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
for await (const c of stream) chunks.push(c);
|
|
31
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* -- the full-screen UI ------------------------------------------------- */
|
|
35
|
+
|
|
36
|
+
class Tui {
|
|
37
|
+
constructor({ key, base, model, caps, stdout, stdin, initial }) {
|
|
38
|
+
this.key = key;
|
|
39
|
+
this.initial = initial || null;
|
|
40
|
+
this.base = base;
|
|
41
|
+
this.caps = caps;
|
|
42
|
+
this.sgr = styler(caps.color);
|
|
43
|
+
this.term = new Terminal({ stdout, stdin, caps });
|
|
44
|
+
this.models = null; // cached /models listing, fetched on first need
|
|
45
|
+
this.dirty = false;
|
|
46
|
+
this.paintTimer = null;
|
|
47
|
+
this.ticker = null;
|
|
48
|
+
this.abort = null;
|
|
49
|
+
this.proxyServer = null;
|
|
50
|
+
this.lastKeyAt = 0;
|
|
51
|
+
this.sincePrevKey = Infinity;
|
|
52
|
+
this.state = {
|
|
53
|
+
model: model || 'auto',
|
|
54
|
+
messages: [
|
|
55
|
+
{
|
|
56
|
+
role: 'system',
|
|
57
|
+
text:
|
|
58
|
+
'Every answer here comes back with a receipt: what the gateway served, what it ' +
|
|
59
|
+
'cost, and what the routing decision saved. Type a prompt, or /help.',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
input: '',
|
|
63
|
+
caret: 0,
|
|
64
|
+
history: [],
|
|
65
|
+
historyAt: -1,
|
|
66
|
+
scroll: 0,
|
|
67
|
+
overlay: null,
|
|
68
|
+
overlayScroll: 0,
|
|
69
|
+
picker: null,
|
|
70
|
+
streaming: false,
|
|
71
|
+
streamStartedAt: 0,
|
|
72
|
+
streamChars: 0,
|
|
73
|
+
last: null,
|
|
74
|
+
session: newSession(),
|
|
75
|
+
proxy: null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* -- lifecycle -- */
|
|
80
|
+
|
|
81
|
+
async run() {
|
|
82
|
+
this.term.enter();
|
|
83
|
+
this.term.onResize = () => this.paint(true);
|
|
84
|
+
this.term.onKey((str, key) => {
|
|
85
|
+
try {
|
|
86
|
+
this.onKey(str, key || {});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// A key handler that throws must not take the terminal with it.
|
|
89
|
+
this.say('error', err instanceof Error ? err.message : String(err));
|
|
90
|
+
this.paint();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
this.paint(true);
|
|
94
|
+
// `lobstack tui "a question"` opens the UI with that turn already in
|
|
95
|
+
// flight. Not awaited: the key loop has to be live while it streams.
|
|
96
|
+
if (this.initial) void this.send(this.initial);
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
this.done = resolve;
|
|
99
|
+
});
|
|
100
|
+
this.stopTicker();
|
|
101
|
+
this.proxyServer?.close();
|
|
102
|
+
this.term.restore();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
quit() {
|
|
106
|
+
this.abort?.abort();
|
|
107
|
+
this.done?.();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* -- painting -- */
|
|
111
|
+
|
|
112
|
+
/** Coalesce repaints. A stream can deliver dozens of deltas per frame time. */
|
|
113
|
+
paint(now = false) {
|
|
114
|
+
// Once the terminal is back in the user's hands, nothing may write to it.
|
|
115
|
+
// A cancelled stream settles *after* `run()` has restored, and its final
|
|
116
|
+
// repaint would otherwise spray escape sequences over their shell prompt.
|
|
117
|
+
if (this.term.restored) return;
|
|
118
|
+
if (now) {
|
|
119
|
+
clearTimeout(this.paintTimer);
|
|
120
|
+
this.paintTimer = null;
|
|
121
|
+
this.#paintNow();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.dirty = true;
|
|
125
|
+
if (this.paintTimer) return;
|
|
126
|
+
this.paintTimer = setTimeout(() => {
|
|
127
|
+
this.paintTimer = null;
|
|
128
|
+
if (this.dirty) this.#paintNow();
|
|
129
|
+
}, 24);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#paintNow() {
|
|
133
|
+
this.dirty = false;
|
|
134
|
+
const { lines, caret } = frame(this.state, {
|
|
135
|
+
columns: this.term.columns,
|
|
136
|
+
rows: this.term.rows,
|
|
137
|
+
caps: this.caps,
|
|
138
|
+
sgr: this.sgr,
|
|
139
|
+
});
|
|
140
|
+
this.term.paint(lines, caret);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** While a stream is open the elapsed-time readout has to keep moving. */
|
|
144
|
+
startTicker() {
|
|
145
|
+
this.stopTicker();
|
|
146
|
+
this.ticker = setInterval(() => this.paint(true), 200);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
stopTicker() {
|
|
150
|
+
if (this.ticker) clearInterval(this.ticker);
|
|
151
|
+
this.ticker = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* -- transcript -- */
|
|
155
|
+
|
|
156
|
+
say(role, text) {
|
|
157
|
+
this.state.messages.push({ role, text });
|
|
158
|
+
this.state.scroll = 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The conversation as the gateway needs it: display-only rows dropped. */
|
|
162
|
+
conversation() {
|
|
163
|
+
return this.state.messages
|
|
164
|
+
.filter((m) => (m.role === 'user' || m.role === 'assistant') && m.text.trim())
|
|
165
|
+
.map((m) => ({ role: m.role, content: m.text }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* -- keys -- */
|
|
169
|
+
|
|
170
|
+
onKey(str, key) {
|
|
171
|
+
const s = this.state;
|
|
172
|
+
const name = key.name;
|
|
173
|
+
// How long since the previous keystroke, before this one updates it. See
|
|
174
|
+
// `enter()`: it is the only way this program tells a paste from typing.
|
|
175
|
+
this.sincePrevKey = Date.now() - this.lastKeyAt;
|
|
176
|
+
this.lastKeyAt = Date.now();
|
|
177
|
+
|
|
178
|
+
if (s.picker) return this.pickerKey(str, key);
|
|
179
|
+
|
|
180
|
+
if (s.overlay) {
|
|
181
|
+
if (name === 'pageup') s.overlayScroll = Math.max(0, s.overlayScroll - this.page());
|
|
182
|
+
else if (name === 'pagedown') s.overlayScroll += this.page();
|
|
183
|
+
else {
|
|
184
|
+
s.overlay = null;
|
|
185
|
+
s.overlayScroll = 0;
|
|
186
|
+
}
|
|
187
|
+
return this.paint(true);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (key.ctrl) {
|
|
191
|
+
switch (name) {
|
|
192
|
+
case 'c':
|
|
193
|
+
// Cancel first, quit second. Killing the process on the keystroke
|
|
194
|
+
// that was meant to stop a runaway answer loses the receipt for a
|
|
195
|
+
// call that has already been billed.
|
|
196
|
+
if (s.streaming) {
|
|
197
|
+
this.abort?.abort();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
return this.quit();
|
|
201
|
+
case 'd':
|
|
202
|
+
if (!s.input) return this.quit();
|
|
203
|
+
s.input = s.input.slice(0, s.caret) + s.input.slice(s.caret + 1);
|
|
204
|
+
return this.paint();
|
|
205
|
+
case 'l':
|
|
206
|
+
this.term.screen.invalidate();
|
|
207
|
+
return this.paint(true);
|
|
208
|
+
case 'a':
|
|
209
|
+
s.caret = 0;
|
|
210
|
+
return this.paint();
|
|
211
|
+
case 'e':
|
|
212
|
+
s.caret = s.input.length;
|
|
213
|
+
return this.paint();
|
|
214
|
+
case 'u':
|
|
215
|
+
s.input = s.input.slice(s.caret);
|
|
216
|
+
s.caret = 0;
|
|
217
|
+
return this.paint();
|
|
218
|
+
case 'k':
|
|
219
|
+
s.input = s.input.slice(0, s.caret);
|
|
220
|
+
return this.paint();
|
|
221
|
+
case 'w': {
|
|
222
|
+
const head = s.input.slice(0, s.caret).replace(/\s*\S*$/, '');
|
|
223
|
+
s.input = head + s.input.slice(s.caret);
|
|
224
|
+
s.caret = head.length;
|
|
225
|
+
return this.paint();
|
|
226
|
+
}
|
|
227
|
+
default:
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
switch (name) {
|
|
233
|
+
case 'return':
|
|
234
|
+
case 'enter':
|
|
235
|
+
return this.enter(key);
|
|
236
|
+
case 'backspace':
|
|
237
|
+
if (s.caret > 0) {
|
|
238
|
+
s.input = s.input.slice(0, s.caret - 1) + s.input.slice(s.caret);
|
|
239
|
+
s.caret -= 1;
|
|
240
|
+
}
|
|
241
|
+
return this.paint();
|
|
242
|
+
case 'delete':
|
|
243
|
+
s.input = s.input.slice(0, s.caret) + s.input.slice(s.caret + 1);
|
|
244
|
+
return this.paint();
|
|
245
|
+
case 'left':
|
|
246
|
+
s.caret = Math.max(0, s.caret - 1);
|
|
247
|
+
return this.paint();
|
|
248
|
+
case 'right':
|
|
249
|
+
s.caret = Math.min(s.input.length, s.caret + 1);
|
|
250
|
+
return this.paint();
|
|
251
|
+
case 'home':
|
|
252
|
+
s.caret = 0;
|
|
253
|
+
return this.paint();
|
|
254
|
+
case 'end':
|
|
255
|
+
s.caret = s.input.length;
|
|
256
|
+
return this.paint();
|
|
257
|
+
case 'up':
|
|
258
|
+
return this.recall(1);
|
|
259
|
+
case 'down':
|
|
260
|
+
return this.recall(-1);
|
|
261
|
+
case 'pageup':
|
|
262
|
+
s.scroll += this.page();
|
|
263
|
+
return this.paint(true);
|
|
264
|
+
case 'pagedown':
|
|
265
|
+
s.scroll = Math.max(0, s.scroll - this.page());
|
|
266
|
+
return this.paint(true);
|
|
267
|
+
case 'escape':
|
|
268
|
+
s.scroll = 0;
|
|
269
|
+
return this.paint(true);
|
|
270
|
+
case 'tab':
|
|
271
|
+
return this.complete();
|
|
272
|
+
default:
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Printable input. `str` can be several characters when a terminal
|
|
277
|
+
// delivers a paste or a multi-byte sequence in one go.
|
|
278
|
+
if (str && !key.meta && !/^[\u0000-\u001f\u007f]$/.test(str)) {
|
|
279
|
+
s.input = s.input.slice(0, s.caret) + str + s.input.slice(s.caret);
|
|
280
|
+
s.caret += str.length;
|
|
281
|
+
return this.paint();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
page() {
|
|
286
|
+
return Math.max(1, this.term.rows - 10);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Enter: send, or add a newline.
|
|
291
|
+
*
|
|
292
|
+
* A trailing backslash means "keep going", and Alt+Enter does too where the
|
|
293
|
+
* terminal sends it (many do not send anything distinguishable for
|
|
294
|
+
* Shift+Enter, so that one is not promised).
|
|
295
|
+
*
|
|
296
|
+
* The timing check is for pastes. Bracketed paste mode would be the tidy
|
|
297
|
+
* answer, but it is another private mode to leave switched on if this process
|
|
298
|
+
* dies badly, and the failure there is a terminal that wraps every future
|
|
299
|
+
* paste in escape markers. Instead: a newline arriving within 10ms of a
|
|
300
|
+
* printable character was not typed by a human - 100 characters a second is
|
|
301
|
+
* not a person - so it is treated as part of the paste rather than as send.
|
|
302
|
+
* Only when raw mode is actually on, since without it everything arrives in
|
|
303
|
+
* one burst by definition.
|
|
304
|
+
*/
|
|
305
|
+
enter(key) {
|
|
306
|
+
const s = this.state;
|
|
307
|
+
const pasted = Boolean(this.term.in.isTTY) && this.sincePrevKey < 10;
|
|
308
|
+
const continued = s.input.endsWith('\\');
|
|
309
|
+
if (key.meta || continued || pasted) {
|
|
310
|
+
if (continued) {
|
|
311
|
+
s.input = s.input.slice(0, -1);
|
|
312
|
+
s.caret = Math.min(s.caret, s.input.length);
|
|
313
|
+
}
|
|
314
|
+
s.input = s.input.slice(0, s.caret) + '\n' + s.input.slice(s.caret);
|
|
315
|
+
s.caret += 1;
|
|
316
|
+
return this.paint();
|
|
317
|
+
}
|
|
318
|
+
const line = s.input;
|
|
319
|
+
if (!line.trim()) return;
|
|
320
|
+
s.input = '';
|
|
321
|
+
s.caret = 0;
|
|
322
|
+
s.history.unshift(line);
|
|
323
|
+
s.historyAt = -1;
|
|
324
|
+
s.scroll = 0;
|
|
325
|
+
if (line.trim().startsWith('/')) return void this.command(line.trim());
|
|
326
|
+
if (s.streaming) {
|
|
327
|
+
this.say('system', 'still streaming the last answer - Ctrl+C cancels it.');
|
|
328
|
+
return this.paint(true);
|
|
329
|
+
}
|
|
330
|
+
return void this.send(line);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
recall(step) {
|
|
334
|
+
const s = this.state;
|
|
335
|
+
const next = s.historyAt + step;
|
|
336
|
+
if (next < -1 || next >= s.history.length) return;
|
|
337
|
+
s.historyAt = next;
|
|
338
|
+
s.input = next === -1 ? '' : s.history[next];
|
|
339
|
+
s.caret = s.input.length;
|
|
340
|
+
this.paint();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Tab: finish a slash command, or open the picker on an empty line. */
|
|
344
|
+
complete() {
|
|
345
|
+
const s = this.state;
|
|
346
|
+
if (!s.input.trim()) return void this.openPicker();
|
|
347
|
+
if (!s.input.startsWith('/')) return;
|
|
348
|
+
const names = [
|
|
349
|
+
'/model',
|
|
350
|
+
'/models',
|
|
351
|
+
'/spend',
|
|
352
|
+
'/receipt',
|
|
353
|
+
'/proxy',
|
|
354
|
+
'/new',
|
|
355
|
+
'/clear',
|
|
356
|
+
'/help',
|
|
357
|
+
'/quit',
|
|
358
|
+
];
|
|
359
|
+
const hit = names.filter((n) => n.startsWith(s.input.trim()));
|
|
360
|
+
if (hit.length === 1) {
|
|
361
|
+
s.input = `${hit[0]} `;
|
|
362
|
+
s.caret = s.input.length;
|
|
363
|
+
} else if (hit.length > 1) {
|
|
364
|
+
this.say('system', hit.join(' '));
|
|
365
|
+
}
|
|
366
|
+
this.paint(true);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* -- the model picker -- */
|
|
370
|
+
|
|
371
|
+
async openPicker(filter = '') {
|
|
372
|
+
if (!this.models) {
|
|
373
|
+
this.say('system', 'fetching the model list...');
|
|
374
|
+
this.paint(true);
|
|
375
|
+
try {
|
|
376
|
+
this.models = await fetchModels(this.base, this.key);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
this.models = [];
|
|
379
|
+
return this.fail(err);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
this.state.picker = { filter, index: 0, items: this.pickerItems(filter) };
|
|
383
|
+
this.paint(true);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
pickerItems(filter) {
|
|
387
|
+
const f = filter.toLowerCase();
|
|
388
|
+
const price = (m) => {
|
|
389
|
+
const p = m.price_per_mtok || {};
|
|
390
|
+
const n = (v) => (typeof v === 'number' ? `$${v}` : '-');
|
|
391
|
+
return `${n(p.input)} / ${n(p.output)} per Mtok`;
|
|
392
|
+
};
|
|
393
|
+
const rows = [
|
|
394
|
+
{ id: 'auto', tier: 'router', price: 'the router picks, and the receipt says which' },
|
|
395
|
+
...(this.models || []).map((m) => ({ id: m.id, tier: m.tier || '', price: price(m) })),
|
|
396
|
+
];
|
|
397
|
+
return rows.filter((r) => r.id.toLowerCase().includes(f));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
pickerKey(str, key) {
|
|
401
|
+
const p = this.state.picker;
|
|
402
|
+
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
403
|
+
this.state.picker = null;
|
|
404
|
+
return this.paint(true);
|
|
405
|
+
}
|
|
406
|
+
if (key.name === 'up') p.index = Math.max(0, p.index - 1);
|
|
407
|
+
else if (key.name === 'down') p.index = Math.min(p.items.length - 1, p.index + 1);
|
|
408
|
+
else if (key.name === 'return' || key.name === 'enter') {
|
|
409
|
+
const picked = p.items[p.index];
|
|
410
|
+
this.state.picker = null;
|
|
411
|
+
if (picked) {
|
|
412
|
+
this.state.model = picked.id;
|
|
413
|
+
this.say('system', `model set to ${picked.id}.`);
|
|
414
|
+
}
|
|
415
|
+
} else if (key.name === 'backspace') {
|
|
416
|
+
p.filter = p.filter.slice(0, -1);
|
|
417
|
+
p.items = this.pickerItems(p.filter);
|
|
418
|
+
p.index = 0;
|
|
419
|
+
} else if (str && !key.ctrl && !key.meta && !/^[\u0000-\u001f\u007f]$/.test(str)) {
|
|
420
|
+
p.filter += str;
|
|
421
|
+
p.items = this.pickerItems(p.filter);
|
|
422
|
+
p.index = 0;
|
|
423
|
+
}
|
|
424
|
+
this.paint(true);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* -- commands -- */
|
|
428
|
+
|
|
429
|
+
async command(line) {
|
|
430
|
+
const [cmd, ...rest] = line.slice(1).split(/\s+/);
|
|
431
|
+
const arg = rest.join(' ').trim();
|
|
432
|
+
const s = this.state;
|
|
433
|
+
try {
|
|
434
|
+
switch (cmd) {
|
|
435
|
+
case 'help':
|
|
436
|
+
case '?':
|
|
437
|
+
s.overlay = 'help';
|
|
438
|
+
s.overlayScroll = 0;
|
|
439
|
+
break;
|
|
440
|
+
case 'quit':
|
|
441
|
+
case 'exit':
|
|
442
|
+
case 'q':
|
|
443
|
+
return this.quit();
|
|
444
|
+
case 'clear':
|
|
445
|
+
s.messages = [];
|
|
446
|
+
this.term.screen.invalidate();
|
|
447
|
+
break;
|
|
448
|
+
case 'new':
|
|
449
|
+
s.messages = [{ role: 'system', text: 'new conversation. Session totals kept.' }];
|
|
450
|
+
break;
|
|
451
|
+
case 'model':
|
|
452
|
+
if (!arg) return void this.openPicker();
|
|
453
|
+
s.model = arg;
|
|
454
|
+
this.say('system', `model set to ${arg}.`);
|
|
455
|
+
break;
|
|
456
|
+
case 'models':
|
|
457
|
+
await this.showModels();
|
|
458
|
+
break;
|
|
459
|
+
case 'spend':
|
|
460
|
+
await this.showSpend(Number(arg || 7));
|
|
461
|
+
break;
|
|
462
|
+
case 'receipt':
|
|
463
|
+
this.showReceipt();
|
|
464
|
+
break;
|
|
465
|
+
case 'proxy':
|
|
466
|
+
await this.startProxy(Number(arg || 8787));
|
|
467
|
+
break;
|
|
468
|
+
default:
|
|
469
|
+
this.say('error', `no such command "/${cmd}". /help lists them.`);
|
|
470
|
+
}
|
|
471
|
+
} catch (err) {
|
|
472
|
+
this.fail(err);
|
|
473
|
+
}
|
|
474
|
+
this.paint(true);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async showModels() {
|
|
478
|
+
this.models = await fetchModels(this.base, this.key);
|
|
479
|
+
const w = Math.max(...this.models.map((m) => (m.id || '').length), 5);
|
|
480
|
+
const price = (v) => (typeof v === 'number' ? `$${v}` : '-');
|
|
481
|
+
const lines = this.models.map((m) => {
|
|
482
|
+
const p = m.price_per_mtok || {};
|
|
483
|
+
return `${(m.id || '').padEnd(w)} ${String(m.tier || '').padEnd(9)} ${price(p.input).padEnd(6)} ${price(p.output)}`;
|
|
484
|
+
});
|
|
485
|
+
this.say(
|
|
486
|
+
'system',
|
|
487
|
+
[`${'MODEL'.padEnd(w)} ${'TIER'.padEnd(9)} IN/M OUT/M`, ...lines].join('\n'),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async showSpend(days) {
|
|
492
|
+
const body = await fetchUsage(this.base, this.key, Number.isFinite(days) ? days : 7);
|
|
493
|
+
if (body.enabled === false) {
|
|
494
|
+
this.say('error', body.message || 'usage reporting is not enabled on this deployment.');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const sum = body.summary || {};
|
|
498
|
+
// Same rule as everywhere else: a saving the gateway measured against a
|
|
499
|
+
// plan ceiling is not the same claim as one against a model you named. The
|
|
500
|
+
// usage API reports a single figure, so it is labelled for what it is
|
|
501
|
+
// rather than being called a saving outright.
|
|
502
|
+
const head =
|
|
503
|
+
`last ${days} days: ${sum.requests ?? 0} requests, ${money(Number(sum.cost_usd ?? 0))}` +
|
|
504
|
+
(Number(sum.savings_usd ?? 0) > 0
|
|
505
|
+
? `, routing saved ${money(Number(sum.savings_usd))} against the baselines the gateway recorded`
|
|
506
|
+
: '');
|
|
507
|
+
const rows = (body.groups ?? []).map(
|
|
508
|
+
(g) =>
|
|
509
|
+
` ${String(g.key ?? '').padEnd(24)} ${String(g.requests ?? 0).padStart(6)} ${money(Number(g.cost_usd ?? 0))}`,
|
|
510
|
+
);
|
|
511
|
+
this.say('system', [head, ...rows].join('\n'));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
showReceipt() {
|
|
515
|
+
const last = this.state.last;
|
|
516
|
+
if (!last) return this.say('system', 'no call yet.');
|
|
517
|
+
const s = this.state.session;
|
|
518
|
+
this.say(
|
|
519
|
+
'system',
|
|
520
|
+
[
|
|
521
|
+
JSON.stringify({ x_lobstack: last.receipt, usage: last.usage }, null, 2),
|
|
522
|
+
'',
|
|
523
|
+
`session: ${s.calls} calls, ` +
|
|
524
|
+
`${s.priced ? money(s.costUsd) : 'unpriced'}` +
|
|
525
|
+
`${s.priced && s.unpriced ? ` plus ${s.unpriced} unpriced` : ''}, ` +
|
|
526
|
+
`${s.inTok}/${s.outTok} tokens` +
|
|
527
|
+
(s.savedNamed > 0 ? `, saved ${money(s.savedNamed)} against models you named` : '') +
|
|
528
|
+
(s.savedCeiling > 0
|
|
529
|
+
? `, ${money(s.savedCeiling)} against your plan ceiling (a different claim)`
|
|
530
|
+
: ''),
|
|
531
|
+
].join('\n'),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Run the OpenAI-compatible proxy inside this process.
|
|
537
|
+
*
|
|
538
|
+
* This is the reason the TUI is worth more than a chat window: point Cursor
|
|
539
|
+
* or Aider at the port and every call it makes lands in this transcript with
|
|
540
|
+
* its price, in the same running total as what you type here.
|
|
541
|
+
*/
|
|
542
|
+
async startProxy(port) {
|
|
543
|
+
if (this.proxyServer) {
|
|
544
|
+
this.say('system', `already serving on ${this.state.proxy.port}.`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
this.proxyServer = await startProxy({
|
|
548
|
+
key: this.key,
|
|
549
|
+
base: this.base,
|
|
550
|
+
port,
|
|
551
|
+
quiet: true,
|
|
552
|
+
onReceipt: ({ receipt, usage, ms, path }) => {
|
|
553
|
+
this.state.last = { receipt, usage, model: receipt?.served_model, ms };
|
|
554
|
+
accrue(this.state.session, { receipt, usage });
|
|
555
|
+
const saving = savingsLabel(receipt);
|
|
556
|
+
this.say(
|
|
557
|
+
'system',
|
|
558
|
+
`proxy ${path} -> ${receipt?.served_model ?? '?'} ${money(receipt?.cost_usd)}` +
|
|
559
|
+
(saving ? ` ${saving.label} ${money(saving.amount)}` : ''),
|
|
560
|
+
);
|
|
561
|
+
this.paint(true);
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
this.state.proxy = { port };
|
|
565
|
+
this.say(
|
|
566
|
+
'system',
|
|
567
|
+
`serving http://127.0.0.1:${port}/v1 - loopback only, and your key stays in this ` +
|
|
568
|
+
`process. Set OPENAI_BASE_URL to it and OPENAI_API_KEY to anything.`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
fail(err) {
|
|
573
|
+
if (err instanceof GatewayError) {
|
|
574
|
+
this.say('error', err.hint ? `${err.message}\n${err.hint}` : err.message);
|
|
575
|
+
} else {
|
|
576
|
+
this.say('error', err instanceof Error ? err.message : String(err));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/* -- a call -- */
|
|
581
|
+
|
|
582
|
+
async send(text) {
|
|
583
|
+
const s = this.state;
|
|
584
|
+
this.say('user', text);
|
|
585
|
+
const bubble = { role: 'assistant', text: '' };
|
|
586
|
+
s.messages.push(bubble);
|
|
587
|
+
s.streaming = true;
|
|
588
|
+
s.streamStartedAt = Date.now();
|
|
589
|
+
s.streamChars = 0;
|
|
590
|
+
this.abort = new AbortController();
|
|
591
|
+
this.startTicker();
|
|
592
|
+
this.paint(true);
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
const out = await streamCompletion(this.base, this.key, {
|
|
596
|
+
model: s.model,
|
|
597
|
+
// `conversation()` already ends with the turn just pushed, and skips
|
|
598
|
+
// the empty assistant bubble the stream is about to fill.
|
|
599
|
+
messages: this.conversation(),
|
|
600
|
+
signal: this.abort.signal,
|
|
601
|
+
client: 'lobstack-cli-tui',
|
|
602
|
+
onText: (t) => {
|
|
603
|
+
bubble.text += t;
|
|
604
|
+
s.streamChars += t.length;
|
|
605
|
+
this.paint();
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
s.last = {
|
|
609
|
+
receipt: out.receipt,
|
|
610
|
+
usage: out.usage,
|
|
611
|
+
model: out.model,
|
|
612
|
+
ms: Date.now() - s.streamStartedAt,
|
|
613
|
+
};
|
|
614
|
+
accrue(s.session, out);
|
|
615
|
+
if (!out.receipt) {
|
|
616
|
+
this.say('system', 'no receipt on that response - the endpoint did not send one.');
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
if (this.abort.signal.aborted) {
|
|
620
|
+
// The tokens up to the cancel were produced and will be billed. Saying
|
|
621
|
+
// "cancelled" and showing no cost would be the friendlier lie.
|
|
622
|
+
this.say('system', 'cancelled. Whatever the provider already generated was still billed.');
|
|
623
|
+
} else {
|
|
624
|
+
if (!bubble.text) s.messages.splice(s.messages.indexOf(bubble), 1);
|
|
625
|
+
this.fail(err);
|
|
626
|
+
}
|
|
627
|
+
} finally {
|
|
628
|
+
s.streaming = false;
|
|
629
|
+
this.abort = null;
|
|
630
|
+
this.stopTicker();
|
|
631
|
+
this.paint(true);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export async function runTui({ key, base, model, caps, stdout, stdin, initial }) {
|
|
637
|
+
const tui = new Tui({
|
|
638
|
+
key,
|
|
639
|
+
base,
|
|
640
|
+
model,
|
|
641
|
+
initial,
|
|
642
|
+
caps: caps || detect(),
|
|
643
|
+
stdout: stdout || process.stdout,
|
|
644
|
+
stdin: stdin || process.stdin,
|
|
645
|
+
});
|
|
646
|
+
if (tui.term.columns < MIN_WIDTH) {
|
|
647
|
+
// Not a refusal - the layout stacks below MIN_WIDTH and stays correct. It
|
|
648
|
+
// is worth saying once, because a cramped frame looks like a bug.
|
|
649
|
+
tui.say(
|
|
650
|
+
'system',
|
|
651
|
+
`this window is ${tui.term.columns} columns; the layout stacks below ${MIN_WIDTH}.`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
await tui.run();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/* -- line mode ---------------------------------------------------------- */
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* The fallback for a terminal that cannot be drawn on.
|
|
661
|
+
*
|
|
662
|
+
* TERM=dumb has no cursor addressing, and stdout redirected to a file must not
|
|
663
|
+
* receive escape sequences. Both still deserve a conversation, so this is the
|
|
664
|
+
* one-shot `chat` path in a loop: the prompt and the receipt on stderr, the
|
|
665
|
+
* answer on stdout, which keeps `lobstack tui > transcript.txt` honest.
|
|
666
|
+
*/
|
|
667
|
+
export async function runLineMode({ key, base, model }) {
|
|
668
|
+
const session = newSession();
|
|
669
|
+
const rl = createInterface({
|
|
670
|
+
input: process.stdin,
|
|
671
|
+
// stderr, not stdout: stdout may be a file the user wants to keep clean.
|
|
672
|
+
output: process.stderr,
|
|
673
|
+
terminal: Boolean(process.stdin.isTTY),
|
|
674
|
+
});
|
|
675
|
+
process.stderr.write(
|
|
676
|
+
`lobstack - line mode (this terminal cannot be drawn on; the full UI needs cursor addressing)\n` +
|
|
677
|
+
`model ${model}. Blank line or Ctrl+D to leave.\n\n`,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
for (;;) {
|
|
681
|
+
let line;
|
|
682
|
+
try {
|
|
683
|
+
line = await rl.question('> ');
|
|
684
|
+
} catch {
|
|
685
|
+
break; // Ctrl+D closes the interface
|
|
686
|
+
}
|
|
687
|
+
if (!line || !line.trim()) break;
|
|
688
|
+
try {
|
|
689
|
+
const out = await streamCompletion(base, key, {
|
|
690
|
+
model,
|
|
691
|
+
messages: [{ role: 'user', content: line }],
|
|
692
|
+
onText: (t) => process.stdout.write(t),
|
|
693
|
+
});
|
|
694
|
+
process.stdout.write('\n');
|
|
695
|
+
printReceipt({ receipt: out.receipt, usage: out.usage, model: out.model });
|
|
696
|
+
accrue(session, out);
|
|
697
|
+
process.stderr.write(
|
|
698
|
+
` session ${session.calls} calls ` +
|
|
699
|
+
`${session.priced ? money(session.costUsd) : 'unpriced'}` +
|
|
700
|
+
`${session.priced && session.unpriced ? ` +${session.unpriced} unpriced` : ''}` +
|
|
701
|
+
`${session.savedNamed > 0 ? ` saved ${money(session.savedNamed)}` : ''}` +
|
|
702
|
+
`${session.savedCeiling > 0 ? ` vs ceiling ${money(session.savedCeiling)}` : ''}\n\n`,
|
|
703
|
+
);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
706
|
+
process.stderr.write(`error ${msg}\n${err?.hint ? ` ${err.hint}\n` : ''}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
rl.close();
|
|
710
|
+
}
|