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-view.mjs
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame, as a pure function of state.
|
|
3
|
+
*
|
|
4
|
+
* `frame(state, opts)` takes the whole UI state and returns the exact array of
|
|
5
|
+
* rows to paint plus where the caret goes. It touches no terminal and no
|
|
6
|
+
* globals, which is what makes the layout testable: the tests render at 80
|
|
7
|
+
* columns and at 40 columns with `sgr` set to the identity and read the result
|
|
8
|
+
* as text, the same way a human reads the screen.
|
|
9
|
+
*
|
|
10
|
+
* The rule that shapes every line here: the receipt is not decoration. A chat
|
|
11
|
+
* pane is worth nothing on its own - every tool has one - so the price of the
|
|
12
|
+
* last call and the running total for the session are the two things that are
|
|
13
|
+
* never scrolled away, never collapsed to save a row, and never rounded into
|
|
14
|
+
* something friendlier than the truth.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { money, savingsLabel } from './render.mjs';
|
|
18
|
+
import { MIN_WIDTH, usableWidth, clip, width, wrapText } from './tty.mjs';
|
|
19
|
+
|
|
20
|
+
/* -- composition -------------------------------------------------------- */
|
|
21
|
+
|
|
22
|
+
/** A styled run of text. `style` is a key `styler()` understands, or null. */
|
|
23
|
+
export const seg = (text, style = null) => ({ text: String(text), style });
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Join segments into one row, clipping to `w` cells and styling as it goes.
|
|
27
|
+
*
|
|
28
|
+
* Clipping happens on the plain text before any escape is added, so a style
|
|
29
|
+
* never counts towards the width and a truncated row never ends inside an
|
|
30
|
+
* escape sequence.
|
|
31
|
+
*/
|
|
32
|
+
export function compose(segments, w, sgr) {
|
|
33
|
+
let out = '';
|
|
34
|
+
let used = 0;
|
|
35
|
+
for (const s of segments) {
|
|
36
|
+
if (used >= w) break;
|
|
37
|
+
const text = width(s.text) > w - used ? clip(s.text, w - used) : s.text;
|
|
38
|
+
if (!text) continue;
|
|
39
|
+
out += s.style ? sgr(s.style, text) : text;
|
|
40
|
+
used += width(text);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Left group against right group, first pair that fits.
|
|
47
|
+
*
|
|
48
|
+
* The candidates are ordered by what matters, not by what is on the left: the
|
|
49
|
+
* running total is the reason to look at this screen, so a narrow terminal
|
|
50
|
+
* gives up the word "lobstack" and then the model name before it gives up the
|
|
51
|
+
* money. A right group is never silently clipped - it is swapped for a shorter
|
|
52
|
+
* one, or the pair is abandoned for the next candidate.
|
|
53
|
+
*/
|
|
54
|
+
function spread(candidates, w, sgr) {
|
|
55
|
+
for (const [left, right] of candidates) {
|
|
56
|
+
const lw = segW(left);
|
|
57
|
+
const rw = segW(right);
|
|
58
|
+
if (!rw) {
|
|
59
|
+
if (lw <= w) return compose(left, w, sgr);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (lw + 2 + rw <= w) return compose([...left, seg(' '.repeat(w - lw - rw)), ...right], w, sgr);
|
|
63
|
+
}
|
|
64
|
+
return compose(candidates[0][0], w, sgr);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rule = (w, caps, sgr) => sgr('dim', (caps.unicode ? '─' : '-').repeat(w));
|
|
68
|
+
|
|
69
|
+
/* -- the running session total ------------------------------------------ */
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The number in the top right, and the whole reason to watch this screen.
|
|
73
|
+
*
|
|
74
|
+
* Three honesty constraints, all of them load-bearing:
|
|
75
|
+
*
|
|
76
|
+
* - A session whose only calls came back unpriced shows `unpriced`, not
|
|
77
|
+
* `$0.000000`. Summing nulls as zero is how you end up confidently
|
|
78
|
+
* reporting a free afternoon against a real invoice.
|
|
79
|
+
* - Unpriced calls are counted out loud next to a priced total, so the total
|
|
80
|
+
* is never mistaken for "everything so far".
|
|
81
|
+
* - `saved` and `vs ceiling` are separate tallies and are never added
|
|
82
|
+
* together. They answer different questions - see render.mjs - and one
|
|
83
|
+
* combined "total saved" would be the overstatement the receipt exists to
|
|
84
|
+
* prevent.
|
|
85
|
+
*/
|
|
86
|
+
export function sessionSegments(s, { long = true, savings = true } = {}) {
|
|
87
|
+
const out = [];
|
|
88
|
+
if (!s.calls) return [seg('no calls yet', 'dim')];
|
|
89
|
+
|
|
90
|
+
out.push(seg(`${s.calls}${long ? (s.calls === 1 ? ' call' : ' calls') : 'x'}`, 'dim'));
|
|
91
|
+
out.push(seg(' '));
|
|
92
|
+
out.push(seg(s.priced ? money(s.costUsd) : 'unpriced', s.priced ? 'bold' : 'warn'));
|
|
93
|
+
if (s.priced && s.unpriced) {
|
|
94
|
+
out.push(seg(` +${s.unpriced} unpriced`, 'warn'));
|
|
95
|
+
}
|
|
96
|
+
if (savings && s.savedNamed > 0) {
|
|
97
|
+
out.push(seg(' saved ', 'dim'), seg(money(s.savedNamed), 'good'));
|
|
98
|
+
}
|
|
99
|
+
if (savings && s.savedCeiling > 0) {
|
|
100
|
+
out.push(seg(' vs ceiling ', 'dim'), seg(money(s.savedCeiling), 'accent'));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A fresh, empty session tally. */
|
|
106
|
+
export const newSession = () => ({
|
|
107
|
+
calls: 0,
|
|
108
|
+
priced: 0,
|
|
109
|
+
unpriced: 0,
|
|
110
|
+
costUsd: 0,
|
|
111
|
+
savedNamed: 0,
|
|
112
|
+
savedCeiling: 0,
|
|
113
|
+
inTok: 0,
|
|
114
|
+
outTok: 0,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fold one receipt into the session tally.
|
|
119
|
+
*
|
|
120
|
+
* A null `cost_usd` increments `unpriced` and adds nothing to `costUsd`; it is
|
|
121
|
+
* never coerced. The saving lands in exactly one of the two buckets, chosen by
|
|
122
|
+
* the same `savingsLabel` the single-shot receipt uses.
|
|
123
|
+
*/
|
|
124
|
+
export function accrue(session, { receipt, usage }) {
|
|
125
|
+
session.calls += 1;
|
|
126
|
+
if (typeof receipt?.cost_usd === 'number') {
|
|
127
|
+
session.priced += 1;
|
|
128
|
+
session.costUsd += receipt.cost_usd;
|
|
129
|
+
} else {
|
|
130
|
+
session.unpriced += 1;
|
|
131
|
+
}
|
|
132
|
+
const saving = savingsLabel(receipt);
|
|
133
|
+
if (saving) {
|
|
134
|
+
if (saving.named) session.savedNamed += saving.amount;
|
|
135
|
+
else session.savedCeiling += saving.amount;
|
|
136
|
+
}
|
|
137
|
+
if (usage) {
|
|
138
|
+
session.inTok += Number(usage.prompt_tokens || 0);
|
|
139
|
+
session.outTok += Number(usage.completion_tokens || 0);
|
|
140
|
+
}
|
|
141
|
+
return session;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* -- the receipt pane --------------------------------------------------- */
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The persistent receipt for the last call, as exactly `maxRows` rows.
|
|
148
|
+
*
|
|
149
|
+
* This is the only part of the screen that is measured before it is drawn
|
|
150
|
+
* rather than clipped after. At 80 columns - the width that actually matters -
|
|
151
|
+
* the full receipt is 103 cells and does not fit on one row, and the first
|
|
152
|
+
* thing a naive clip throws away is the end of the line, which is where the
|
|
153
|
+
* saving is. So the content is assembled as three pieces, each with a fallback
|
|
154
|
+
* that is shorter rather than truncated, and the layout picks the widest set
|
|
155
|
+
* that fits:
|
|
156
|
+
*
|
|
157
|
+
* identity what was served, and what was asked for
|
|
158
|
+
* price tokens, cost, saving, latency - the cost and the saving never
|
|
159
|
+
* drop, the tokens and the latency do
|
|
160
|
+
* caveat why the saving is or is not a like-for-like comparison, or why
|
|
161
|
+
* there is no price at all. Never dropped while there is a row.
|
|
162
|
+
*/
|
|
163
|
+
export function receiptRows(state, w, maxRows, caps) {
|
|
164
|
+
if (state.streaming) return fold(streamingRows(state, caps), maxRows);
|
|
165
|
+
const last = state.last;
|
|
166
|
+
if (!last) {
|
|
167
|
+
return fold(
|
|
168
|
+
[[seg(' no call yet.', 'dim'), seg(' Type a prompt and press Enter.', 'dim')]],
|
|
169
|
+
maxRows,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const r = last.receipt;
|
|
174
|
+
const served = r?.served_model || last.model || 'unknown';
|
|
175
|
+
const asked = r?.requested_model;
|
|
176
|
+
const saving = savingsLabel(r);
|
|
177
|
+
const priced = typeof r?.cost_usd === 'number';
|
|
178
|
+
|
|
179
|
+
/* identity */
|
|
180
|
+
const idFull = [seg(' '), seg('model ', 'dim'), seg(served, 'heading')];
|
|
181
|
+
const idWithAsked =
|
|
182
|
+
asked && r?.routed ? [...idFull, seg(' asked ', 'dim'), seg(asked)] : idFull;
|
|
183
|
+
const identity = segW(idWithAsked) <= w ? idWithAsked : idFull;
|
|
184
|
+
|
|
185
|
+
/* price. `money(null)` is the word "unpriced": a null cost is a charge the
|
|
186
|
+
gateway could not price, not a free call, and $0.00 would write it off. */
|
|
187
|
+
const cost = [seg('cost ', 'dim'), seg(money(r?.cost_usd), priced ? 'bold' : 'warn')];
|
|
188
|
+
if (saving) {
|
|
189
|
+
cost.push(
|
|
190
|
+
seg(` ${saving.label} `, 'dim'),
|
|
191
|
+
seg(money(saving.amount), saving.named ? 'good' : 'accent'),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const tokens = last.usage
|
|
195
|
+
? [
|
|
196
|
+
seg('tokens ', 'dim'),
|
|
197
|
+
seg(`${last.usage.prompt_tokens ?? '?'}/${last.usage.completion_tokens ?? '?'}`),
|
|
198
|
+
seg(' '),
|
|
199
|
+
]
|
|
200
|
+
: [];
|
|
201
|
+
const latency = last.ms ? [seg(` in ${last.ms}ms`, 'dim')] : [];
|
|
202
|
+
// A clipped money value is not a shortened number, it is a different and
|
|
203
|
+
// smaller one: "$0.004400" cut to "$0.004" reads as four tenths of the real
|
|
204
|
+
// saving. So the fallbacks drop labels, then the tokens, then the saving
|
|
205
|
+
// itself - anything rather than let a figure be truncated.
|
|
206
|
+
const bare = cost.map((c, i) => (i === 0 ? seg('', null) : c));
|
|
207
|
+
const price = widest(w, [
|
|
208
|
+
[seg(' '), ...tokens, ...cost, ...latency],
|
|
209
|
+
[seg(' '), ...tokens, ...cost],
|
|
210
|
+
[seg(' '), ...cost],
|
|
211
|
+
[seg(' '), ...bare],
|
|
212
|
+
[seg(' '), seg(money(r?.cost_usd), priced ? 'bold' : 'warn')],
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
/* caveat */
|
|
216
|
+
const caveat = caveatRow(r, saving, w);
|
|
217
|
+
|
|
218
|
+
/* fit */
|
|
219
|
+
const merged = [...identity, seg(' '), ...price.slice(1)];
|
|
220
|
+
const fits = segW(merged) <= w;
|
|
221
|
+
if (maxRows >= 3) {
|
|
222
|
+
return fold(fits ? [merged, caveat, footnote(state, r)] : [identity, price, caveat], maxRows);
|
|
223
|
+
}
|
|
224
|
+
if (maxRows === 2) {
|
|
225
|
+
return fold(fits ? [merged, caveat] : [compactRow(served, cost), caveat], maxRows);
|
|
226
|
+
}
|
|
227
|
+
return fold([fits ? merged : compactRow(served, cost)], maxRows);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* While a stream is open.
|
|
232
|
+
*
|
|
233
|
+
* There is no running dollar figure here, on purpose. Cost is read off the
|
|
234
|
+
* gateway's last frame, never computed from token counts against a local rate
|
|
235
|
+
* card - that is exactly the bug that printed $0.00 for three months next to a
|
|
236
|
+
* correct invoice. So until the receipt lands this reports only what it
|
|
237
|
+
* honestly knows: elapsed time, and how much text has arrived.
|
|
238
|
+
*/
|
|
239
|
+
function streamingRows(state, caps) {
|
|
240
|
+
const secs = ((Date.now() - state.streamStartedAt) / 1000).toFixed(1);
|
|
241
|
+
return [
|
|
242
|
+
[
|
|
243
|
+
seg(` ${caps.unicode ? '\u00b7' : '*'} `, 'accent'),
|
|
244
|
+
seg('streaming', 'accent'),
|
|
245
|
+
seg(' asked ', 'dim'),
|
|
246
|
+
seg(state.model),
|
|
247
|
+
seg(` ${secs}s ${state.streamChars} chars`, 'dim'),
|
|
248
|
+
],
|
|
249
|
+
[seg(' the price arrives with the last frame of the stream', 'dim')],
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Model and price only: the fallback when a row has to carry both. */
|
|
254
|
+
const compactRow = (served, cost) => [seg(' '), seg(served, 'heading'), seg(' '), ...cost];
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Every caveat has a long form and a short one, so a narrow terminal loses
|
|
258
|
+
* words rather than meaning. The `plan_ceiling` note in particular has to
|
|
259
|
+
* survive at 40 columns: a saving measured against a model nobody asked for is
|
|
260
|
+
* the one thing this receipt exists to be honest about.
|
|
261
|
+
*/
|
|
262
|
+
function caveatRow(r, saving, w) {
|
|
263
|
+
const pick = (forms, style = 'dim') => {
|
|
264
|
+
for (const f of forms) if (width(f) <= w) return [seg(f, style)];
|
|
265
|
+
return [seg(forms[forms.length - 1], style)];
|
|
266
|
+
};
|
|
267
|
+
if (r?.baseline_reason === 'plan_ceiling' && r.baseline_model) {
|
|
268
|
+
return pick([
|
|
269
|
+
` vs ${r.baseline_model}, the priciest model your plan allows - not one you asked for`,
|
|
270
|
+
` vs ${r.baseline_model}, the priciest model your plan allows`,
|
|
271
|
+
` ceiling baseline: ${r.baseline_model}`,
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
if (r && r.priced === false) {
|
|
275
|
+
return pick([
|
|
276
|
+
' the gateway could not price this model, so no cost is claimed',
|
|
277
|
+
' gateway could not price this model',
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
if (!r) {
|
|
281
|
+
return pick(
|
|
282
|
+
[
|
|
283
|
+
' no receipt on this response - the endpoint did not send one',
|
|
284
|
+
' no receipt on this response',
|
|
285
|
+
],
|
|
286
|
+
'warn',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (saving?.named && r.baseline_model) {
|
|
290
|
+
return pick([
|
|
291
|
+
` against ${r.baseline_model}, the model you named`,
|
|
292
|
+
` named: ${r.baseline_model}`,
|
|
293
|
+
]);
|
|
294
|
+
}
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The spare row at wide widths: the request id, and the session's tokens. */
|
|
299
|
+
function footnote(state, r) {
|
|
300
|
+
const bits = [];
|
|
301
|
+
if (r?.request_id) bits.push(`request ${r.request_id}`);
|
|
302
|
+
const s = state.session;
|
|
303
|
+
if (s.inTok || s.outTok) bits.push(`session ${s.inTok}/${s.outTok} tokens`);
|
|
304
|
+
return bits.length ? [seg(` ${bits.join(' ')}`, 'dim')] : [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const segW = (segments) => segments.reduce((n, s) => n + width(s.text), 0);
|
|
308
|
+
|
|
309
|
+
/** The first candidate that fits, or the last one, clipped. */
|
|
310
|
+
function widest(w, candidates) {
|
|
311
|
+
for (const c of candidates) if (segW(c) <= w) return c;
|
|
312
|
+
return candidates[candidates.length - 1];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Pad or trim to exactly `maxRows`, so the layout above never shifts. */
|
|
316
|
+
function fold(rows, maxRows) {
|
|
317
|
+
const out = rows.slice(0, maxRows);
|
|
318
|
+
while (out.length < maxRows) out.push([]);
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* -- the transcript ----------------------------------------------------- */
|
|
323
|
+
|
|
324
|
+
const LABEL = { user: 'you', assistant: 'lob', system: 'sys', error: 'err' };
|
|
325
|
+
const LABEL_STYLE = { user: 'you', assistant: 'accent', system: 'dim', error: 'bad' };
|
|
326
|
+
|
|
327
|
+
/** Every transcript row, oldest first, already wrapped to `w`. */
|
|
328
|
+
export function transcriptRows(state, w) {
|
|
329
|
+
const gutter = w >= 16 ? 5 : 0;
|
|
330
|
+
const textW = Math.max(1, w - gutter - 1);
|
|
331
|
+
const rows = [];
|
|
332
|
+
for (const m of state.messages) {
|
|
333
|
+
const label = LABEL[m.role] || 'sys';
|
|
334
|
+
const style = LABEL_STYLE[m.role] || 'dim';
|
|
335
|
+
const lines = wrapText(m.text, textW);
|
|
336
|
+
for (let i = 0; i < lines.length; i++) {
|
|
337
|
+
const head = gutter
|
|
338
|
+
? [seg(' '), seg(i === 0 ? label.padEnd(gutter - 1) : ' '.repeat(gutter - 1), style)]
|
|
339
|
+
: [];
|
|
340
|
+
rows.push([...head, seg(lines[i], m.role === 'error' ? 'bad' : null)]);
|
|
341
|
+
}
|
|
342
|
+
rows.push([]); // one blank row between turns, so a long answer has an edge
|
|
343
|
+
}
|
|
344
|
+
if (rows.length) rows.pop();
|
|
345
|
+
return rows;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* -- overlays ----------------------------------------------------------- */
|
|
349
|
+
|
|
350
|
+
const KEYS = [
|
|
351
|
+
['Enter', 'send the prompt'],
|
|
352
|
+
['\\ at end of line', 'keep typing on a new line (Alt+Enter too, where the terminal sends it)'],
|
|
353
|
+
['Ctrl+C', 'cancel a streaming answer; quit when nothing is streaming'],
|
|
354
|
+
['Ctrl+D', 'quit'],
|
|
355
|
+
['Tab', 'complete a slash command, or open the model picker on an empty line'],
|
|
356
|
+
['Up / Down', 'walk back through what you sent'],
|
|
357
|
+
['PgUp / PgDn', 'scroll the transcript; Esc returns to the live tail'],
|
|
358
|
+
['Ctrl+L', 'repaint, for when something else wrote over the screen'],
|
|
359
|
+
['Ctrl+A / Ctrl+E', 'start / end of line'],
|
|
360
|
+
['Ctrl+U / Ctrl+K / Ctrl+W', 'clear line / kill to end / delete a word'],
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
const COMMANDS = [
|
|
364
|
+
['/model [name]', 'set the model, or open the picker'],
|
|
365
|
+
['/models', 'what the gateway will serve, with prices'],
|
|
366
|
+
['/spend [days]', 'what you have actually spent, from the usage API'],
|
|
367
|
+
['/receipt', 'every field of the last receipt, verbatim'],
|
|
368
|
+
['/proxy [port]', 'serve the OpenAI-compatible endpoint here, and watch it bill'],
|
|
369
|
+
['/new', 'forget the conversation; keep the session totals'],
|
|
370
|
+
['/clear', 'clear the screen; keep the conversation'],
|
|
371
|
+
['/help', 'this'],
|
|
372
|
+
['/quit', 'leave'],
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
function helpRows(w) {
|
|
376
|
+
const rows = [[seg(' Keys', 'heading')]];
|
|
377
|
+
const pad = w >= 64 ? 26 : 0;
|
|
378
|
+
for (const [k, v] of KEYS) {
|
|
379
|
+
if (pad) rows.push([seg(' '), seg(k.padEnd(pad), 'bold'), seg(v, 'dim')]);
|
|
380
|
+
else rows.push([seg(' '), seg(k, 'bold')], [seg(' '), seg(v, 'dim')]);
|
|
381
|
+
}
|
|
382
|
+
rows.push([], [seg(' Commands', 'heading')]);
|
|
383
|
+
for (const [k, v] of COMMANDS) {
|
|
384
|
+
if (pad) rows.push([seg(' '), seg(k.padEnd(pad), 'bold'), seg(v, 'dim')]);
|
|
385
|
+
else rows.push([seg(' '), seg(k, 'bold')], [seg(' '), seg(v, 'dim')]);
|
|
386
|
+
}
|
|
387
|
+
rows.push([], [seg(' Esc or any key closes this.', 'dim')]);
|
|
388
|
+
return rows;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function pickerRows(state, w) {
|
|
392
|
+
const p = state.picker;
|
|
393
|
+
const rows = [
|
|
394
|
+
[
|
|
395
|
+
seg(' pick a model', 'heading'),
|
|
396
|
+
seg(p.filter ? ` filter ${p.filter}` : ' type to filter, Enter to choose', 'dim'),
|
|
397
|
+
],
|
|
398
|
+
[],
|
|
399
|
+
];
|
|
400
|
+
const items = p.items;
|
|
401
|
+
if (!items.length) {
|
|
402
|
+
rows.push([seg(' nothing matches ', 'dim'), seg(p.filter)]);
|
|
403
|
+
return rows;
|
|
404
|
+
}
|
|
405
|
+
for (let i = 0; i < items.length; i++) {
|
|
406
|
+
const it = items[i];
|
|
407
|
+
const on = i === p.index;
|
|
408
|
+
// A marker, not just a colour. On NO_COLOR and TERM=dumb the highlight is
|
|
409
|
+
// the only thing telling you which row Enter will take.
|
|
410
|
+
const mark = on ? '>' : ' ';
|
|
411
|
+
const cells = [
|
|
412
|
+
seg(` ${mark} `, on ? 'accent' : 'dim'),
|
|
413
|
+
seg(w >= 56 ? it.id.padEnd(28) : it.id, on ? 'bold' : null),
|
|
414
|
+
];
|
|
415
|
+
if (w >= 56) {
|
|
416
|
+
cells.push(seg(String(it.tier || '').padEnd(10), 'dim'), seg(it.price || '', 'dim'));
|
|
417
|
+
}
|
|
418
|
+
rows.push(cells);
|
|
419
|
+
}
|
|
420
|
+
return rows;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* -- the input line ---------------------------------------------------- */
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Hard-wrap the input by cells and say exactly where the caret lands.
|
|
427
|
+
*
|
|
428
|
+
* Word wrapping is wrong here: the caret has to sit over the character it will
|
|
429
|
+
* edit, so the only correct wrap is the one the terminal itself would do.
|
|
430
|
+
*/
|
|
431
|
+
export function layoutInput(text, caret, w, prompt) {
|
|
432
|
+
const lines = [];
|
|
433
|
+
let cur = '';
|
|
434
|
+
let curW = 0;
|
|
435
|
+
let off = 0;
|
|
436
|
+
let cRow = 0;
|
|
437
|
+
let cCol = width(prompt);
|
|
438
|
+
const avail = Math.max(1, w - width(prompt));
|
|
439
|
+
|
|
440
|
+
const mark = () => {
|
|
441
|
+
cRow = lines.length;
|
|
442
|
+
cCol = (lines.length === 0 ? width(prompt) : 0) + curW;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
for (const ch of text) {
|
|
446
|
+
const cw = width(ch);
|
|
447
|
+
const limit = lines.length === 0 ? avail : w;
|
|
448
|
+
if (curW + cw > limit) {
|
|
449
|
+
lines.push(cur);
|
|
450
|
+
cur = '';
|
|
451
|
+
curW = 0;
|
|
452
|
+
}
|
|
453
|
+
if (off === caret) mark();
|
|
454
|
+
cur += ch;
|
|
455
|
+
curW += cw;
|
|
456
|
+
off += ch.length;
|
|
457
|
+
}
|
|
458
|
+
if (off === caret) mark();
|
|
459
|
+
lines.push(cur);
|
|
460
|
+
return { lines, caretRow: cRow, caretCol: cCol };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* -- the frame ---------------------------------------------------------- */
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Rows are allocated from the bottom up, and the receipt is allocated before
|
|
467
|
+
* the transcript. A 10-row terminal loses conversation history, not the price
|
|
468
|
+
* of the last call, because the price is the thing you cannot get back by
|
|
469
|
+
* scrolling.
|
|
470
|
+
*/
|
|
471
|
+
export function frame(state, { columns, rows, caps, sgr }) {
|
|
472
|
+
const w = usableWidth(columns);
|
|
473
|
+
const h = Math.max(1, rows);
|
|
474
|
+
const out = [];
|
|
475
|
+
|
|
476
|
+
// A terminal this small cannot hold the layout. Rather than corrupt it, drop
|
|
477
|
+
// to the two rows that matter - the price and the prompt - and spend anything
|
|
478
|
+
// left over on the tail of the conversation. Still correct, still usable.
|
|
479
|
+
if (h <= 3 || w < 12) {
|
|
480
|
+
const receipt = receiptRows(state, w, 1, caps).map((r) => compose(r, w, sgr));
|
|
481
|
+
const prompt = promptFor(state, caps);
|
|
482
|
+
const inp = layoutInput(state.input, state.caret, w, prompt);
|
|
483
|
+
const input = compose(
|
|
484
|
+
[seg(prompt, 'accent'), seg(inp.lines[inp.lines.length - 1])],
|
|
485
|
+
w,
|
|
486
|
+
sgr,
|
|
487
|
+
);
|
|
488
|
+
const budget = Math.max(0, h - receipt.length - 1);
|
|
489
|
+
const all = transcriptRows(state, w);
|
|
490
|
+
const tail = all.slice(Math.max(0, all.length - budget)).map((r) => compose(r, w, sgr));
|
|
491
|
+
while (tail.length < budget) tail.unshift('');
|
|
492
|
+
const lines = [...tail, ...receipt, input].slice(-h);
|
|
493
|
+
return { lines, caret: { row: lines.length, col: Math.min(w, inp.caretCol) + 1 } };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Measured against the real terminal width, not the one reserved column
|
|
497
|
+
// fewer, so a 40-column window is not treated as narrower than the minimum.
|
|
498
|
+
const showFooter = h >= 14 && columns >= MIN_WIDTH;
|
|
499
|
+
const showRules = h >= 10;
|
|
500
|
+
// Three rows when there is room: identity, price, caveat. See receiptRows.
|
|
501
|
+
const receiptH = h >= 14 ? 3 : h >= 11 ? 2 : 1;
|
|
502
|
+
const inputMax = h >= 14 ? 4 : 2;
|
|
503
|
+
|
|
504
|
+
const prompt = promptFor(state, caps);
|
|
505
|
+
const inp = layoutInput(state.input, state.caret, w, prompt);
|
|
506
|
+
// Keep the caret on screen when the prompt is taller than its window.
|
|
507
|
+
const firstInput = Math.max(0, Math.min(inp.caretRow - inputMax + 1, inp.lines.length - inputMax));
|
|
508
|
+
const inputLines = inp.lines.slice(firstInput, firstInput + inputMax);
|
|
509
|
+
const inputH = inputLines.length;
|
|
510
|
+
|
|
511
|
+
const chromeH = 1 + (showRules ? 2 : 0) + receiptH + inputH + (showFooter ? 1 : 0);
|
|
512
|
+
const bodyH = Math.max(0, h - chromeH);
|
|
513
|
+
|
|
514
|
+
/* header */
|
|
515
|
+
const badges = [];
|
|
516
|
+
if (state.proxy) badges.push(seg(` proxy :${state.proxy.port}`, 'good'));
|
|
517
|
+
if (state.scroll > 0) badges.push(seg(` scrolled ${state.scroll}`, 'warn'));
|
|
518
|
+
const titled = [seg(' lobstack', 'heading'), seg(' '), seg(state.model, 'accent'), ...badges];
|
|
519
|
+
const bare = [seg(' '), seg(state.model, 'accent'), ...badges];
|
|
520
|
+
const sess = (o) => sessionSegments(state.session, o);
|
|
521
|
+
out.push(
|
|
522
|
+
spread(
|
|
523
|
+
[
|
|
524
|
+
[titled, sess({ long: true })],
|
|
525
|
+
[titled, sess({ long: false })],
|
|
526
|
+
[bare, sess({ long: false })],
|
|
527
|
+
[bare, sess({ long: false, savings: false })],
|
|
528
|
+
[[seg(' ')], sess({ long: false, savings: false })],
|
|
529
|
+
],
|
|
530
|
+
w,
|
|
531
|
+
sgr,
|
|
532
|
+
),
|
|
533
|
+
);
|
|
534
|
+
if (showRules) out.push(rule(w, caps, sgr));
|
|
535
|
+
|
|
536
|
+
/* body: an overlay if one is open, otherwise the transcript */
|
|
537
|
+
if (bodyH > 0) {
|
|
538
|
+
const body = state.overlay === 'help' ? helpRows(w) : state.picker ? pickerRows(state, w) : null;
|
|
539
|
+
if (body) {
|
|
540
|
+
// Overlays keep their own scroll-free window: the top matters most.
|
|
541
|
+
const start = state.picker
|
|
542
|
+
? Math.max(0, Math.min(state.picker.index - bodyH + 4, body.length - bodyH))
|
|
543
|
+
: Math.min(state.overlayScroll || 0, Math.max(0, body.length - bodyH));
|
|
544
|
+
for (let i = 0; i < bodyH; i++) {
|
|
545
|
+
const row = body[start + i];
|
|
546
|
+
out.push(row ? compose(row, w, sgr) : '');
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
const all = transcriptRows(state, w);
|
|
550
|
+
// `scroll` counts rows held back from the bottom, so 0 is always the
|
|
551
|
+
// live tail and a streaming answer stays pinned there.
|
|
552
|
+
const maxScroll = Math.max(0, all.length - bodyH);
|
|
553
|
+
const back = Math.min(state.scroll, maxScroll);
|
|
554
|
+
const end = all.length - back;
|
|
555
|
+
const start = Math.max(0, end - bodyH);
|
|
556
|
+
const slice = all.slice(start, end);
|
|
557
|
+
// Pad at the top so the conversation grows down from the rule instead of
|
|
558
|
+
// jumping when the first answer arrives.
|
|
559
|
+
for (let i = slice.length; i < bodyH; i++) out.push('');
|
|
560
|
+
for (const row of slice) out.push(compose(row, w, sgr));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* receipt */
|
|
565
|
+
if (showRules) out.push(rule(w, caps, sgr));
|
|
566
|
+
for (const row of receiptRows(state, w, receiptH, caps)) out.push(compose(row, w, sgr));
|
|
567
|
+
|
|
568
|
+
/* input */
|
|
569
|
+
const inputStart = out.length;
|
|
570
|
+
for (let i = 0; i < inputH; i++) {
|
|
571
|
+
const isFirst = firstInput + i === 0;
|
|
572
|
+
out.push(
|
|
573
|
+
compose(
|
|
574
|
+
[seg(isFirst ? prompt : ' '.repeat(width(prompt)), 'accent'), seg(inputLines[i])],
|
|
575
|
+
w,
|
|
576
|
+
sgr,
|
|
577
|
+
),
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/* footer */
|
|
582
|
+
if (showFooter) out.push(compose(footerSegments(state, w), w, sgr));
|
|
583
|
+
|
|
584
|
+
const caretRow = inputStart + (inp.caretRow - firstInput) + 1;
|
|
585
|
+
const caret =
|
|
586
|
+
state.picker || state.overlay
|
|
587
|
+
? null
|
|
588
|
+
: { row: Math.min(caretRow, h), col: Math.min(w, inp.caretCol) + 1 };
|
|
589
|
+
|
|
590
|
+
return { lines: out.slice(0, h), caret };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function promptFor(state, caps) {
|
|
594
|
+
if (state.streaming) return caps.unicode ? '… ' : '. ';
|
|
595
|
+
return caps.unicode ? '› ' : '> ';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function footerSegments(state, w) {
|
|
599
|
+
const hint = (k, v) => [seg(k, 'bold'), seg(` ${v}`, 'dim'), seg(' ')];
|
|
600
|
+
if (state.picker) {
|
|
601
|
+
return [seg(' '), ...hint('Enter', 'choose'), ...hint('Esc', 'cancel'), ...hint('Up/Dn', 'move')];
|
|
602
|
+
}
|
|
603
|
+
if (state.overlay) return [seg(' '), ...hint('Esc', 'close')];
|
|
604
|
+
if (w < 56) {
|
|
605
|
+
return [
|
|
606
|
+
seg(' '),
|
|
607
|
+
...hint('^C', state.streaming ? 'cancel' : 'quit'),
|
|
608
|
+
...hint('Tab', 'model'),
|
|
609
|
+
seg('/help', 'bold'),
|
|
610
|
+
];
|
|
611
|
+
}
|
|
612
|
+
return [
|
|
613
|
+
seg(' '),
|
|
614
|
+
...hint('^C', state.streaming ? 'cancel' : 'quit'),
|
|
615
|
+
...hint('Tab', 'model'),
|
|
616
|
+
...hint('PgUp', 'scroll'),
|
|
617
|
+
...hint('^L', 'redraw'),
|
|
618
|
+
seg('/help', 'bold'),
|
|
619
|
+
seg(' for the rest', 'dim'),
|
|
620
|
+
];
|
|
621
|
+
}
|