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.
@@ -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
+ }