jaitus 0.3.2 → 0.3.4

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/src/tui-ink.js CHANGED
@@ -1,1998 +1,2119 @@
1
- // ─── The Jaitus app view ────────────────────────────────────────────────────────
2
- //
3
- // One conversation, full bleed, with exactly four pieces of permanent chrome: a header
4
- // line, the transcript, the composer, and a status line. There is no dashboard, no
5
- // sidebar and no second column, because the thing being read is prose and code and
6
- // both of those want the full width of the window.
7
- //
8
- // Three decisions are worth knowing before changing anything here:
9
- //
10
- // • **Entries keep their source, not their pixels.** `entries.js` renders rows on
11
- // demand for the current width and caches them against it. Resizing re-lays every
12
- // message correctly, and scrolling is exact because the row count is already known
13
- // — the previous renderer estimated it and drifted.
14
- // • **Colour survives.** Everything the rest of the CLI prints already carries ANSI.
15
- // It is parsed back into styled spans rather than stripped, so a red ✗ printed by
16
- // a command written two years ago is still red here.
17
- // • **Motion is one timer.** Ink 7 consolidates every `useAnimation` into a single
18
- // render cycle, which is the only reason a spinner, a shimmering label, a drifting
19
- // hairline and a blinking caret can all run without the frame rate suffering.
20
- //
21
- // Reduced motion (`/animations off`, `JAITUS_NO_ANIMATION=1`) is a first-class mode:
22
- // every animated widget has a still frame that reads correctly on its own.
23
-
24
- import React from 'react';
25
- import { Box, Spacer, Text, render, useAnimation, useApp, useCursor, useInput, usePaste, useWindowSize } from 'ink';
26
- import { palette, resetPalette, mix, ramp } from './tui/theme.js';
27
- import { makeEntry, rowsFor, invalidate } from './tui/entries.js';
28
- import { plain, stringWidth, truncateRow, wrapRow, rowText, inverseRange } from './tui/ansi.js';
29
- import { Spinner, Shimmer, GradientText, FlowingRule, Meter, Elapsed, Caret, Rows, Row, Reveal, motionOn, publishSettings } from './tui/widgets.js';
30
- import { wordRows, wordSize } from './tui/wordmark.js';
31
- import { enableMouse, disableMouse, parseMouse, isMouse, isMouseFragment, createHitMap } from './tui/mouse.js';
32
- import { openUrl } from './open.js';
33
- import { MASCOT_ROWS, startupTip } from './brand.js';
34
-
35
- const h = React.createElement;
36
- const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
37
- const ascii = () => process.env.JAITUS_ASCII === '1';
38
- const g = (uni, plainText) => (ascii() ? plainText : uni);
39
-
40
- // How many transcript rows the chrome costs. Kept as one table so a change to the
41
- // layout cannot silently desynchronise the scroll maths from what is on screen.
42
- // The header is one row plus its hairline. Kept here as one table so the layout and the
43
- // hit map cannot disagree about where anything is — the controls sit on row 1, which is
44
- // what registerHeaderHits assumes.
45
- const CHROME = { header: 2, status: 1, composerFrame: 3, gapAboveComposer: 1 };
46
-
47
- const MODE_LABEL = {
48
- default: 'ask',
49
- acceptEdits: 'auto-edit',
50
- plan: 'plan',
51
- bypassPermissions: 'bypass',
52
- };
53
-
54
- // ─── Fuzzy matching for the composer menu ───────────────────────────────────────
55
- //
56
- // Subsequence rather than prefix, so `/dr` finds `/deep-research` and `@idx` finds
57
- // `src/index.js`. Consecutive and word-boundary hits score higher, which is what stops
58
- // a long path from outranking the file you obviously meant.
59
- function fuzzy(needle, haystack) {
60
- const n = needle.toLowerCase();
61
- const stack = haystack.toLowerCase();
62
- if (!n) return { score: 1, hits: [] };
63
- const hits = [];
64
- let score = 0;
65
- let at = 0;
66
- let streak = 0;
67
- for (const ch of n) {
68
- const found = stack.indexOf(ch, at);
69
- if (found === -1) return null;
70
- const boundary = found === 0 || /[^a-z0-9]/.test(stack[found - 1]);
71
- streak = found === at ? streak + 1 : 0;
72
- score += 1 + streak * 2 + (boundary ? 3 : 0);
73
- hits.push(found);
74
- at = found + 1;
75
- }
76
- // Shorter candidates win ties: `/ask` should beat `/architect` for `ask`.
77
- return { score: score - haystack.length * 0.05, hits };
78
- }
79
-
80
- function highlightMatch(text, hits, base, accent) {
81
- const set = new Set(hits);
82
- const spans = [];
83
- let run = '';
84
- let runHit = false;
85
- [...String(text)].forEach((ch, index) => {
86
- const hit = set.has(index);
87
- if (hit !== runHit && run) { spans.push({ t: run, color: runHit ? accent : base, bold: runHit }); run = ''; }
88
- runHit = hit;
89
- run += ch;
90
- });
91
- if (run) spans.push({ t: run, color: runHit ? accent : base, bold: runHit });
92
- return spans;
93
- }
94
-
95
- /**
96
- * Rank one command against what has been typed.
97
- *
98
- * The name is matched first and scores highest, but the summary is searched too, because
99
- * the name of a command is only obvious once you already know it — typing `context`
100
- * finds `/compact`, and `diff` finds `/code-review`. A summary hit always scores below
101
- * every name hit, so typing a real command's letters never buries it under something
102
- * that merely mentions those letters in its description.
103
- */
104
- function scoreCommand(fragment, name, summary) {
105
- const bare = name.replace(/^\//, '');
106
- const byName = fuzzy(fragment, bare);
107
- if (byName) return { score: byName.score + 100, hits: byName.hits.map((i) => i + 1), where: 'name' };
108
- if (!summary || fragment.length < 2) return null;
109
- // Word-prefix rather than subsequence on the summary: a loose subsequence over a
110
- // sentence matches nearly everything, and a menu that matches everything is a menu
111
- // that helps with nothing.
112
- const words = summary.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
113
- const needle = fragment.toLowerCase();
114
- const at = words.findIndex((w) => w.startsWith(needle));
115
- if (at === -1) return null;
116
- return { score: 20 - at - (words[at].length - needle.length) * 0.1, hits: [], where: 'summary' };
117
- }
118
-
119
- function menuItemsFor(input, commands, fileProvider, cursor) {
120
- const upto = input.slice(0, cursor);
121
- const slash = /(?:^|\s)\/([^\s]*)$/.exec(upto);
122
- if (slash) {
123
- const fragment = slash[1];
124
- return commands
125
- .map((command) => {
126
- const name = typeof command === 'string' ? command : command.name;
127
- const summary = typeof command === 'string' ? '' : command.summary || '';
128
- const match = scoreCommand(fragment, name, summary);
129
- return match ? { kind: 'command', value: name, label: name, summary, score: match.score, hits: match.hits, where: match.where } : null;
130
- })
131
- .filter(Boolean)
132
- .sort((a, b) => b.score - a.score)
133
- .slice(0, 9);
134
- }
135
- const at = upto.lastIndexOf('@');
136
- if (at >= 0 && !/\s/.test(upto.slice(at + 1))) {
137
- const fragment = upto.slice(at + 1);
138
- const files = fileProvider?.(fragment) || [];
139
- return files
140
- .map((file) => {
141
- const match = fuzzy(fragment.split('/').pop() || '', String(file).split('/').pop() || String(file));
142
- return { kind: 'file', value: file, label: String(file), summary: '', score: match ? match.score : 0, hits: [] };
143
- })
144
- .sort((a, b) => b.score - a.score)
145
- .slice(0, 9);
146
- }
147
- return [];
148
- }
149
-
150
- // ─── Chrome ─────────────────────────────────────────────────────────────────────
151
-
152
- function Header({ state, width }) {
153
- const p = palette();
154
- const m = state.meta;
155
- const project = String(m.cwd || '.').split(/[\\/]/).filter(Boolean).pop() || '.';
156
- const mode = MODE_LABEL[m.mode] || 'ask';
157
- const modeColor = m.mode === 'bypassPermissions' ? p.red : m.mode === 'plan' ? p.yellow : m.mode === 'acceptEdits' ? p.teal : p.muted;
158
- const narrow = width < 78;
159
-
160
- // ── no mark in the header, and this is why ──
161
- //
162
- // The Jaitus J is a SQUARE mark. A half-block cell holds two square pixels, so a
163
- // faithful square mark needs its height in pixel rows and half that in terminal rows:
164
- // the real glyph, sampled from logo.png, degrades below about 6×6 and is properly
165
- // itself at 8×8 — which is 8 cells by 4 rows, or 6 by 3 at a push. The header has one
166
- // row for its title.
167
- //
168
- // Three marks were tried at one and two rows. Each was an abstraction of the logo
169
- // rather than the logo, and each read as some other product's badge sitting next to
170
- // this one's — correctly rejected. Half a square mark is not a small version of it.
171
- //
172
- // So the header carries the BRAND TOKEN instead: the coral chip with the J reversed
173
- // out of it, which is the same mark the web header and the classic renderer use, and
174
- // which is the one form of the logo that is honest at a single row — it is a colour
175
- // field with a letter in it, not a squashed drawing of a square mark. Beside it the
176
- // name in caps. Plain mixed-case `Jaitus` in the corner read as a window title; a
177
- // coral field does not read as anything but a logo.
178
- //
179
- // The chip is also the only piece that survives every degradation — it is a space, a
180
- // letter and a space, so it is not tofu on a raster console and not a box on a
181
- // monochrome one. The real mark still lives where it has room: the welcome, and now
182
- // the banner entry at the top of the transcript.
183
- const ruleWidth = Math.max(4, width - 2);
184
- // Below this the name is dropped and the chip carries it alone: the header's right
185
- // side is controls, and squeezing six more characters in ahead of them is what makes
186
- // Ink wrap the row and turn a two-row header into three.
187
- const showName = width >= 62;
188
-
189
- const left = h(Box, { flexDirection: 'row' },
190
- h(Text, { color: '#ffffff', backgroundColor: p.brand, bold: true }, ' J '),
191
- showName ? h(GradientText, { from: p.brandSoft, to: p.teal, bold: true }, ' JAITUS') : null,
192
- h(Text, { color: p.faint }, ` ${g('▸', '/')} `),
193
- h(Text, { color: p.muted }, project),
194
- m.branch && width >= 78 ? h(Text, { color: p.faint }, ` ${g('⎇', 'b:')} ${m.branch}`) : null,
195
- );
196
-
197
- const right = h(Box, { flexDirection: 'row' },
198
- h(Text, { color: modeColor, bold: true }, mode.toUpperCase()),
199
- h(Text, { color: p.faint }, ' · '),
200
- h(Text, { color: p.muted }, narrow ? shortModel(m.model) : String(m.model || 'auto')),
201
- narrow ? null : h(Text, { color: p.faint }, ' · '),
202
- narrow ? null : h(Meter, { value: m.ctxUsed || 0, max: m.ctxMax || 1, width: 8, danger: true }),
203
- narrow ? null : h(Text, { color: p.dim }, ` ${m.ctx || '0 tok'}`),
204
- );
205
-
206
- return h(Box, { flexDirection: 'column', width },
207
- h(Box, { flexDirection: 'row', paddingX: 1 }, left, h(Spacer), right),
208
- // Idle, the hairline is not flat grey any more: it starts brand-tinted under the
209
- // chip and fades into the line colour across the window. It is the cheapest way to
210
- // make the header band belong to this product rather than to any terminal app —
211
- // one row that was already being drawn, now carrying the identity.
212
- h(Box, { paddingX: 1 },
213
- h(FlowingRule, {
214
- width: ruleWidth,
215
- active: state.busy,
216
- from: state.busy ? p.brand : mix(p.line, p.brand, 0.5),
217
- to: state.busy ? p.teal : p.line,
218
- })),
219
- );
220
- }
221
-
222
- const shortModel = (id) => String(id || 'auto').split(/[/:]/).pop().slice(0, 18);
223
-
224
- /**
225
- * The fresh-session moment.
226
- *
227
- * Deliberately not a splash screen with a feature list: the wordmark, one line saying
228
- * what this is, and three things you can type. Everything else the user needs is one
229
- * `/` away and the menu will show it to them.
230
- */
231
- function Hero({ state, width, height }) {
232
- const p = palette();
233
-
234
- // ── the intro ──
235
- //
236
- // One pass, under a second, then it settles and stays settled. The pieces arrive in
237
- // the order you should read them — the lockup, then what this is, then the three
238
- // things you can type. Under reduced motion everything is simply already there.
239
- const motion = motionOn();
240
- const { time } = useAnimation({ interval: 40, isActive: motion && !state.introDone });
241
- if (motion && time >= 900 && !state.introDone) state.introDone = true;
242
- const elapsed = state.introDone ? 900 : time;
243
- const phase = (from, to) => (motion && !state.introDone
244
- ? Math.max(0, Math.min(1, (elapsed - from) / (to - from)))
245
- : 1);
246
-
247
- const markIn = phase(0, 420);
248
- const lineIn = phase(280, 640);
249
- const tipsIn = phase(420, 900);
250
-
251
- // ── the lockup ──
252
- //
253
- // Mark and logotype on one baseline, left-aligned at exactly the indent transcript
254
- // entries use, so the first answer appears directly beneath the branding with no
255
- // jump. Both are four terminal rows: the mark is the DESIGNED mascot rather than the
256
- // photographic badge downsampled, and the letterforms are drawn on a pixel grid and
257
- // rendered with half-blocks — one cell per pixel is what made the old wordmark read
258
- // as ASCII art, because a terminal cell is twice as tall as it is wide.
259
- const mascot = MASCOT_ROWS();
260
- const markInk = ramp(mix(p.bg, p.brandDeep, markIn), mix(p.bg, p.brand, markIn), mascot.length);
261
- const mark = mascot.map((row, index) => [{ t: row, color: markInk[index] }]);
262
- const markWidth = Math.max(...mascot.map((row) => row.length));
263
-
264
- const word = wordRows('JAITUS', (column, total) =>
265
- mix(p.bg, mix(p.brand, p.teal, total > 1 ? column / (total - 1) : 0), markIn));
266
- const wordWidth = wordSize('JAITUS').width;
267
-
268
- // Everything below is measured against what will fit, and dropped from the bottom up
269
- // — the tips first, then the tagline — so a short window shows less rather than
270
- // overflowing into the composer.
271
- const showMark = width >= wordWidth + markWidth + 8;
272
- const showWord = width >= wordWidth + 6;
273
- const lockupRows = showWord ? word.length : 1;
274
- const room = (rows) => height >= lockupRows + rows;
275
-
276
- const tips = [
277
- [`${g('›', '>')} `, 'describe a change', 'it reads the code before touching it'],
278
- ['/ ', 'browse commands', 'research, review, commit, rewind'],
279
- ['@ ', 'pull in a file', 'fuzzy-matched from this directory'],
280
- ];
281
-
282
- return h(Box, { flexDirection: 'column', width, height, paddingX: 1, justifyContent: 'flex-end' },
283
- h(Box, { flexDirection: 'row' },
284
- showMark ? h(Box, { marginRight: 2, flexDirection: 'column' }, h(Rows, { rows: mark })) : null,
285
- showWord
286
- ? h(Box, { flexDirection: 'column' }, h(Rows, { rows: word }))
287
- : h(GradientText, { from: p.brand, to: p.teal, bold: true }, 'JAITUS'),
288
- ),
289
- room(3) ? h(Box, { marginTop: 1 },
290
- h(Text, { color: mix(p.bg, p.dim, lineIn) },
291
- `a coding agent in your terminal ${g('·', '-')} v${state.meta.version || ''}`)) : null,
292
- room(8) ? h(Box, { marginTop: 1, flexDirection: 'column' },
293
- tips.map(([marker, title, note], index) => {
294
- const on = Math.max(0, Math.min(1, tipsIn * 3 - index));
295
- return h(Box, { key: index, flexDirection: 'row' },
296
- h(Text, { color: mix(p.bg, p.brand, on), bold: true }, marker),
297
- h(Text, { color: mix(p.bg, p.text, on) }, title.padEnd(20)),
298
- width >= 70 ? h(Text, { color: mix(p.bg, p.faint, on) }, note) : null,
299
- );
300
- })) : null,
301
- room(10) ? h(Box, { marginTop: 1 },
302
- h(Text, { color: mix(p.bg, p.faint, tipsIn), italic: true }, `tip: ${startupTip()}`)) : null,
303
- );
304
- }
305
-
306
- /**
307
- * Lay the transcript out.
308
- *
309
- * Separate from the component that draws it, and called by `App` BEFORE any of its
310
- * children render. Everything derived from the layout — the scroll ceiling, the screen
311
- * map the drag handler reads, the click regions, the sticky prompt — has to be known
312
- * before the chrome that displays it is rendered. Computing them inside the transcript
313
- * component meant the header above it was drawn from the PREVIOUS frame's numbers, so
314
- * the pinned prompt was always one scroll behind.
315
- */
316
- function layoutTranscript(state, width, height, top) {
317
- const inner = Math.max(24, width - 2);
318
- const rows = [];
319
- // Where each prompt starts, so the sticky header can name the one whose answer is
320
- // currently on screen rather than always the newest.
321
- const prompts = [];
322
- for (const entry of state.transcript) {
323
- const built = rowsFor(entry, inner);
324
- if (!built.length) continue;
325
- if (rows.length) rows.push([]);
326
- if (entry.kind === 'user') prompts.push({ at: rows.length, text: String(entry.data.text || '') });
327
- rows.push(...built);
328
- }
329
- if (state.live && state.live.text) {
330
- const built = rowsFor(state.live.entry, inner);
331
- if (built.length) {
332
- if (rows.length) rows.push([]);
333
- rows.push(...built);
334
- }
335
- }
336
-
337
- // The scroll ceiling. It was previously applied only where the rows were drawn, while
338
- // the controller let the offset grow without bound — so scrolling up past the top kept
339
- // incrementing a number nothing could see, and scrolling back down did nothing at all
340
- // until it had counted its way home. That is the "scroll gets stuck" report: it was
341
- // not stuck, it was somewhere off the top of a list that had already ended.
342
- const maxOffset = Math.max(0, rows.length - height);
343
- state.maxScroll = maxOffset;
344
- const offset = state.viewingTranscript ? clamp(state.transcriptOffset, 0, maxOffset) : 0;
345
- // Snap the stored offset to what will actually be drawn: this is the only place that
346
- // knows how many rows exist at this width, so it is the authority, and writing the
347
- // clamped value back is what keeps the controller's bound self-correcting.
348
- state.transcriptOffset = offset;
349
- if (!offset) state.viewingTranscript = false;
350
-
351
- const end = rows.length - offset;
352
- const first = Math.max(0, end - height);
353
- const visible = rows.slice(first, end);
354
- // Bottom-anchored: a short conversation sits at the composer, not floating at the top
355
- // of an empty screen with the input a dozen rows below it.
356
- const pad = Math.max(0, height - visible.length);
357
- const screenTop = top + pad + 1;
358
-
359
- // The drag handler needs the text under the pointer, and it must be the text actually
360
- // on screen rather than the transcript's idea of it.
361
- state.screen = { top: screenTop, left: 2, rows: visible };
362
-
363
- // The sticky header names the prompt whose answer you are looking at — the last one
364
- // that has scrolled off the top. Scroll far enough that the prompt itself is back on
365
- // screen and there is nothing to pin; scroll past it and it becomes the one before.
366
- const owning = prompts.filter((entry) => entry.at < first).at(-1);
367
- state.stickyPrompt = owning ? owning.text : '';
368
-
369
- return { visible, pad, screenTop };
370
- }
371
-
372
- /**
373
- * How to select text, in words that are true for THIS terminal.
374
- *
375
- * Dragging selects inside the app everywhere and copies on release. What differs is the
376
- * way back to the terminal's own selection while the app has the mouse: Shift in most
377
- * terminals, Option in iTerm2 — and nothing at all in macOS Terminal, which has no
378
- * bypass key. "shift+drag selects" was shown to everyone, and on a Mac it is simply
379
- * wrong.
380
- */
381
- export function selectHint(env = process.env, platform = process.platform) {
382
- const term = env.TERM_PROGRAM || '';
383
- if (term === 'iTerm.app') return 'drag copies · ⌥ drag: native';
384
- if (term === 'Apple_Terminal') return 'drag or double-click copies';
385
- if (platform === 'darwin' && !term) return 'drag copies · ⌥ drag: native';
386
- return 'drag copies · shift drag: native';
387
- }
388
-
389
- /** Register every URL on screen as a click target. */
390
- function registerLinkHits(visible, screenTop, hits) {
391
- if (!hits) return;
392
- visible.forEach((row, index) => {
393
- const text = rowText(row);
394
- for (const match of text.matchAll(URL_IN_TEXT)) {
395
- const url = match[0].replace(/[.,;:)\]]+$/, '');
396
- hits.add({
397
- x1: 2 + match.index,
398
- x2: 2 + match.index + url.length - 1,
399
- y1: screenTop + index,
400
- y2: screenTop + index,
401
- action: { type: 'link', url },
402
- });
403
- }
404
- });
405
- }
406
-
407
- function Transcript({ state, width, height, layout }) {
408
- const painted = paintSelection(layout.visible, state.selection, layout.screenTop, 2);
409
- return h(Box, { flexDirection: 'column', width, height, paddingX: 1, overflow: 'hidden' },
410
- layout.pad ? h(Box, { height: layout.pad }) : null,
411
- h(Rows, { rows: painted }),
412
- );
413
- }
414
-
415
- // Trailing punctuation is stripped by the caller: "see https://x.com/a." ends a sentence
416
- // far more often than it names a path called `.`.
417
- const URL_IN_TEXT = /https?:\/\/[^\s<>"'`]+/g;
418
-
419
- /**
420
- * Draw the drag selection.
421
- *
422
- * Mouse reporting takes the terminal's own highlight away, so if the app does not draw
423
- * one there is no feedback at all while dragging — which is indistinguishable from
424
- * selection being broken, because from the user's side it is.
425
- */
426
- function paintSelection(rows, selection, screenTop, left) {
427
- if (!selection || !selection.anchor || !selection.focus) return rows;
428
- const a = selection.anchor;
429
- const b = selection.focus;
430
- const [from, to] = (a.y < b.y || (a.y === b.y && a.x <= b.x)) ? [a, b] : [b, a];
431
- if (from.y === to.y && from.x === to.x) return rows;
432
-
433
- return rows.map((row, index) => {
434
- const y = screenTop + index;
435
- if (y < from.y || y > to.y) return row;
436
- const startCol = y === from.y ? Math.max(0, from.x - left) : 0;
437
- const endCol = y === to.y ? Math.max(0, to.x - left + 1) : rowText(row).length;
438
- return inverseRange(row, startCol, endCol);
439
- });
440
- }
441
-
442
- /**
443
- * The line that proves the thing is alive.
444
- *
445
- * It carries four facts the user actually asks for while waiting: that work is
446
- * happening, what it is, how long it has been, and how to stop it. The previous
447
- * version carried only the first.
448
- */
449
- function WorkBar({ state, width }) {
450
- const p = palette();
451
- if (!state.busy && !state.status) return null;
452
- const label = plain(state.status) || 'Working';
453
- const narrow = width < 70;
454
- return h(Box, { flexDirection: 'row', paddingX: 1 },
455
- h(Spinner, { kind: ascii() ? 'ascii' : 'braille' }),
456
- h(Text, null, ' '),
457
- h(Shimmer, { from: p.brand, to: p.brandSoft }, truncatePlain(label, Math.max(12, width - (narrow ? 22 : 46)))),
458
- h(Spacer),
459
- state.turnStartedAt ? h(Elapsed, { since: state.turnStartedAt }) : null,
460
- state.turnTokens ? h(Text, { color: p.faint }, ` ${state.turnTokens.toLocaleString()} tok`) : null,
461
- narrow ? null : h(Text, { color: p.faint }, ` esc ${g('↩', '')}stop`),
462
- );
463
- }
464
-
465
- const truncatePlain = (text, width) => (stringWidth(text) <= width ? text : `${String(text).slice(0, Math.max(1, width - 1))}…`);
466
-
467
- function Menu({ state, width }) {
468
- const p = palette();
469
- if (!state.menu.length) return null;
470
- const labelWidth = Math.min(28, Math.max(...state.menu.map((item) => stringWidth(item.label))) + 2);
471
- return h(Box, { flexDirection: 'column', marginBottom: 1 },
472
- state.menu.map((item, index) => {
473
- const selected = index === state.menuIndex;
474
- const spans = [
475
- { t: selected ? `${g('▸', '>')} ` : ' ', color: p.brand, bold: true },
476
- ...highlightMatch(item.label, item.hits || [], selected ? p.text : p.muted, p.brand),
477
- ];
478
- const used = stringWidth(item.label) + 2;
479
- if (item.summary && width > 60) {
480
- spans.push({ t: ' '.repeat(Math.max(1, labelWidth - used + 2)) });
481
- spans.push({ t: item.summary, color: p.faint });
482
- }
483
- return h(Box, {
484
- key: `${item.value}-${index}`,
485
- backgroundColor: selected ? p.raised : undefined,
486
- }, h(Row, { spans: truncateRow(spans, width - 2) }));
487
- }),
488
- );
489
- }
490
-
491
- function Composer({ state, width }) {
492
- const p = palette();
493
- const inner = Math.max(16, width - 4);
494
- const bang = state.input.startsWith('!');
495
- const promptMark = bang ? '! ' : `${g('›', '>')} `;
496
- const promptColor = bang ? p.yellow : p.brand;
497
-
498
- // A paste placeholder is a chip, not prose: it stands for content that is really
499
- // there and will be sent, so it reads as an object rather than as something you typed
500
- // and might have to delete.
501
- const composed = [];
502
- let at = 0;
503
- for (const match of state.input.matchAll(/\[#\d+ pasted [^\]]*\]/g)) {
504
- if (match.index > at) composed.push({ t: state.input.slice(at, match.index), color: p.text });
505
- composed.push({ t: match[0], color: p.purple, backgroundColor: p.raised });
506
- at = match.index + match[0].length;
507
- }
508
- if (at < state.input.length) composed.push({ t: state.input.slice(at), color: p.text });
509
-
510
- const lines = state.input ? wrapRow(composed, inner - 2) : [[]];
511
- const placeholder = state.placeholder || 'Describe a change, ask a question, or / for commands';
512
-
513
- // The caret is drawn as its own element on the line that owns the cursor, so it
514
- // blinks without the text around it re-rendering.
515
- const caretLine = state.input ? lineOfCursor(state.input, state.cursor, inner - 2) : 0;
516
-
517
- const borderColor = state.busy ? p.brand : state.menu.length ? p.teal : p.line;
518
-
519
- return h(Box, {
520
- flexDirection: 'column',
521
- borderStyle: ascii() ? 'classic' : 'round',
522
- borderColor,
523
- paddingX: 1,
524
- width,
525
- },
526
- h(Menu, { state, width: inner }),
527
- h(Box, { flexDirection: 'row' },
528
- h(Text, { color: promptColor, bold: true }, promptMark),
529
- h(Box, { flexDirection: 'column', width: inner - 2 },
530
- lines.map((row, index) => h(Box, { key: index, flexDirection: 'row' },
531
- h(Row, { spans: row, wrap: 'wrap' }),
532
- // The caret is always present, including at an empty prompt. Hiding it there
533
- // was meant to keep it off the placeholder, but the result is a prompt with
534
- // no cursor at all — there is nothing on screen saying the app is waiting for
535
- // typing, which is the one thing a prompt has to say. The placeholder sits
536
- // AFTER the caret instead, so the caret is at the insertion point either way.
537
- index === caretLine ? h(Caret, { color: p.brand, char: g('▏', '_') }) : null,
538
- !state.input && index === 0
539
- ? h(Text, { color: p.faint, italic: true, wrap: 'truncate-end' }, placeholder)
540
- : null,
541
- )),
542
- ),
543
- ),
544
- state.queued.length
545
- ? h(Box, { marginTop: 1 }, h(Text, { color: p.purple }, `${g('⏎', '>')} ${state.queued.length} message${state.queued.length === 1 ? '' : 's'} queued — they send when this turn finishes`))
546
- : null,
547
- state.attachments.length
548
- ? h(Box, { marginTop: 1 }, h(Text, { color: p.purple }, state.attachments.map((a) => `${g('⧉', '[img]')} ${a.name || 'image'}`).join(' ')))
549
- : null,
550
- );
551
- }
552
-
553
- function lineOfCursor(input, cursor, width) {
554
- const rows = wrapRow([{ t: input }], width);
555
- let seen = 0;
556
- for (let i = 0; i < rows.length; i++) {
557
- const text = rows[i].map((s) => s.t).join('');
558
- seen += text.length;
559
- if (cursor <= seen) return i;
560
- seen += 1; // the space the wrap consumed
561
- }
562
- return Math.max(0, rows.length - 1);
563
- }
564
-
565
- function StatusBar({ state, width }) {
566
- const p = palette();
567
- if (state.flash) {
568
- const tone = { ok: p.teal, warn: p.yellow, info: p.muted }[state.flash.tone] || p.muted;
569
- const mark = { ok: g('✓', '+'), warn: '!', info: g('›', '>') }[state.flash.tone] || g('›', '>');
570
- return h(Box, { paddingX: 1 },
571
- h(Text, { color: tone, bold: true }, `${mark} `),
572
- h(Text, { color: tone, wrap: 'truncate-end' }, plain(state.flash.text)));
573
- }
574
- if (state.statusLine) {
575
- return h(Box, { paddingX: 1 }, h(Text, { color: p.muted, wrap: 'truncate-end' }, plain(state.statusLine)));
576
- }
577
- const bits = state.viewingTranscript
578
- ? [
579
- [`${g('▲', '^')} history`, p.yellow],
580
- [state.newMessages ? `${state.newMessages} new below` : 'scrolled back', p.muted],
581
- ['ctrl+end to live', p.faint],
582
- ]
583
- : width < 70
584
- ? [['ctrl+p palette', p.faint], ['shift+tab mode', p.faint]]
585
- : [
586
- ['ctrl+p palette', p.faint],
587
- ['shift+tab mode', p.faint],
588
- ['ctrl+l clear', p.faint],
589
- ['esc esc rewind', p.faint],
590
- // Mouse reporting takes the terminal's own text selection away, so the way to
591
- // get it back has to be on screen — otherwise the first thing a new user
592
- // notices is that they can no longer copy anything.
593
- state.mouse ? [selectHint(), p.faint] : ['ctrl+d debug', p.faint],
594
- ];
595
- const spans = [];
596
- bits.forEach(([text, color], index) => {
597
- if (index) spans.push({ t: ` ${g('·', '|')} `, color: p.faint });
598
- spans.push({ t: text, color });
599
- });
600
- const right = [];
601
- if (state.spent?.turns) {
602
- right.push({ t: `${state.spent.turns} turn${state.spent.turns === 1 ? '' : 's'}`, color: p.faint });
603
- right.push({ t: ` ${g('·', '|')} `, color: p.faint });
604
- right.push({ t: `${((state.spent.input || 0) + (state.spent.cached || 0) + (state.spent.output || 0)).toLocaleString()} tok`, color: p.faint });
605
- }
606
- return h(Box, { flexDirection: 'row', paddingX: 1, width },
607
- h(Row, { spans }),
608
- h(Spacer),
609
- h(Row, { spans: right }),
610
- );
611
- }
612
-
613
- // ─── Overlays ───────────────────────────────────────────────────────────────────
614
-
615
- /**
616
- * Exact height, so the box can be exactly centred.
617
- *
618
- * Ink will not tell you how tall a Box is until after it has laid it out, and an
619
- * absolutely-positioned element has to be given its `top` before that happens. So the
620
- * height is computed the same way Yoga will: borders, padding, and every row and margin
621
- * in order. Guessing it — which is what a constant like `6 + options` was — puts the
622
- * overlay a few rows off centre and the error grows with the content.
623
- */
624
- function dialogHeight(questionRows, previewRows, optionCount) {
625
- return 2 // border top + bottom
626
- + 2 // paddingY
627
- + 1 // title
628
- + 1 + questionRows
629
- + (previewRows ? 1 + previewRows : 0)
630
- + 1 + optionCount
631
- + 1 + 1; // gap + hint
632
- }
633
-
634
- function Dialog({ state, width, height, hits }) {
635
- const p = palette();
636
- const dialog = state.dialog;
637
- if (!dialog) return null;
638
- const boxWidth = Math.min(Math.max(48, Math.floor(width * 0.78)), width - 4);
639
- const previewRows = (dialog.preview || []).slice(0, 12);
640
- const questionRows = wrapRow([{ t: plain(dialog.question) }], boxWidth - 6).length;
641
- const rows = dialogHeight(questionRows, previewRows.length, dialog.options.length);
642
- const accent = dialog.tone === 'danger' ? p.red : p.brand;
643
-
644
- const top = Math.max(0, Math.round((height - rows) / 2));
645
- const left = Math.max(0, Math.round((width - boxWidth) / 2));
646
-
647
- // The whole box swallows clicks first, so nothing lands on the header behind it; the
648
- // option rows are registered after and therefore win inside their own row. The
649
- // arithmetic mirrors dialogHeight() exactly — terminal rows are 1-based, hence the +1.
650
- hits?.add({ x1: left + 1, x2: left + boxWidth, y1: top + 1, y2: top + rows, action: { type: 'swallow' } });
651
- const optionsTop = top + 2 + 1 + 1 + questionRows + (previewRows.length ? 1 + previewRows.length : 0) + 1;
652
- dialog.options.forEach((option, index) => {
653
- hits?.add({
654
- x1: left + 1, x2: left + boxWidth, y1: optionsTop + index + 1, y2: optionsTop + index + 1,
655
- action: { type: 'dialogOption', index },
656
- });
657
- });
658
-
659
- return h(Box, { position: 'absolute', top, left, width: boxWidth },
660
- h(Reveal, { rows },
661
- h(Box, {
662
- flexDirection: 'column',
663
- width: boxWidth,
664
- borderStyle: ascii() ? 'classic' : 'round',
665
- borderColor: accent,
666
- backgroundColor: p.surface,
667
- paddingX: 2,
668
- paddingY: 1,
669
- },
670
- h(Box, { flexDirection: 'row' },
671
- h(Text, { color: accent, bold: true }, `${g('◆', '#')} ${dialog.title || 'Permission required'}`),
672
- h(Spacer),
673
- h(Text, { color: p.faint }, `${dialog.options.length} option${dialog.options.length === 1 ? '' : 's'}`),
674
- ),
675
- h(Box, { marginTop: 1, flexDirection: 'column' },
676
- wrapRow([{ t: plain(dialog.question), color: p.text }], boxWidth - 6)
677
- .map((row, index) => h(Row, { key: index, spans: row }))),
678
- previewRows.length
679
- ? h(Box, { marginTop: 1, flexDirection: 'column' },
680
- previewRows.map((line, index) => h(Row, {
681
- key: index,
682
- spans: truncateRow(previewSpans(line, p), boxWidth - 6),
683
- })))
684
- : null,
685
- h(Box, { marginTop: 1, flexDirection: 'column' },
686
- dialog.options.map((option, index) => {
687
- const selected = index === dialog.index;
688
- return h(Box, { key: `${option.value}-${index}`, backgroundColor: selected ? p.raised : undefined },
689
- h(Text, { color: selected ? accent : p.muted, bold: selected },
690
- `${selected ? g(' ▸ ', ' > ') : ' '}${index + 1}. ${plain(option.label)}`));
691
- })),
692
- h(Box, { marginTop: 1 },
693
- h(Text, { color: p.faint }, `${g('↑↓', 'up/dn')} choose · 1-9 jump · enter confirm · esc ${dialog.hideComment ? 'cancel' : 'deny'}`)),
694
- ),
695
- ),
696
- );
697
- }
698
-
699
- function previewSpans(line, p) {
700
- const text = plain(line);
701
- if (text.startsWith('+')) return [{ t: text, color: p.addFg, backgroundColor: p.addBg }];
702
- if (text.startsWith('-')) return [{ t: text, color: p.delFg, backgroundColor: p.delBg }];
703
- if (text.startsWith('@@')) return [{ t: text, color: p.hunkFg }];
704
- return [{ t: text, color: p.muted }];
705
- }
706
-
707
- function Panel({ state, width, height, hits }) {
708
- const p = palette();
709
- const panel = state.panel;
710
- if (!panel) return null;
711
- const boxWidth = Math.min(Math.max(52, Math.floor(width * 0.84)), width - 4);
712
- // Leave room for the box's own chrome (8 rows) plus a margin, and never grow past
713
- // what the content needs — a palette with four matches should be four rows tall, not
714
- // a half-screen box with an empty lower half.
715
- const maxRows = Math.max(4, Math.min(height - 10, 18));
716
-
717
- let body;
718
- let bodyRows;
719
- let firstVisible = 0;
720
- if (panel.palette) {
721
- // Keep the selection in view without letting the window run past either end.
722
- const visible = Math.min(maxRows, panel.options.length);
723
- firstVisible = Math.max(0, Math.min(panel.index - Math.floor(visible / 2), panel.options.length - visible));
724
- const view = panel.options.slice(firstVisible, firstVisible + visible);
725
- bodyRows = Math.max(1, view.length);
726
- body = view.map((option, index) => {
727
- const globalIndex = firstVisible + index;
728
- const selected = globalIndex === panel.index;
729
- const name = typeof option === 'string' ? option : option.name;
730
- const summary = typeof option === 'string' ? '' : option.summary || '';
731
- return h(Box, { key: `${name}-${index}`, backgroundColor: selected ? p.raised : undefined, flexDirection: 'row' },
732
- h(Text, { color: selected ? p.brand : p.muted, bold: selected }, `${selected ? g(' ▸ ', ' > ') : ' '}${name.padEnd(Math.min(26, boxWidth - 10))}`),
733
- summary ? h(Text, { color: p.faint, wrap: 'truncate-end' }, summary) : null,
734
- );
735
- });
736
- if (!view.length) {
737
- body = [h(Text, { key: 'none', color: p.faint, italic: true }, `nothing matches “${panel.filter || ''}”`)];
738
- bodyRows = 1;
739
- }
740
- } else {
741
- const rows = (panel.rows || []).slice(-maxRows);
742
- const lines = (panel.lines || []).slice(-maxRows);
743
- bodyRows = Math.max(1, rows.length || lines.length);
744
- body = rows.length
745
- ? [h(Rows, { key: 'rows', rows })]
746
- : lines.map((line, index) => h(Text, { key: index, color: p.text, wrap: 'truncate-end' }, plain(line)));
747
- }
748
-
749
- // border 2 + padY 2 + title 1 + gap 1 + body + gap 1 + hint 1
750
- const rows = 8 + bodyRows;
751
- const top = Math.max(0, Math.round((height - rows) / 2));
752
- const left = Math.max(0, Math.round((width - boxWidth) / 2));
753
-
754
- hits?.add({ x1: left + 1, x2: left + boxWidth, y1: top + 1, y2: top + rows, action: { type: 'swallow' } });
755
- if (panel.palette) {
756
- const bodyTop = top + 2 + 1 + 1;
757
- for (let i = 0; i < bodyRows; i++) {
758
- hits?.add({
759
- x1: left + 1, x2: left + boxWidth, y1: bodyTop + i + 1, y2: bodyTop + i + 1,
760
- action: { type: 'paletteRow', index: firstVisible + i },
761
- });
762
- }
763
- }
764
-
765
- return h(Box, { position: 'absolute', top, left, width: boxWidth },
766
- h(Reveal, { rows },
767
- h(Box, {
768
- flexDirection: 'column',
769
- width: boxWidth,
770
- borderStyle: ascii() ? 'classic' : 'round',
771
- borderColor: p.teal,
772
- backgroundColor: p.surface,
773
- paddingX: 2,
774
- paddingY: 1,
775
- },
776
- h(Box, { flexDirection: 'row' },
777
- h(Text, { color: p.teal, bold: true }, plain(panel.title)),
778
- h(Spacer),
779
- panel.palette ? h(Text, { color: p.faint }, `${panel.options.length}`) : null,
780
- ),
781
- h(Box, { marginTop: 1, flexDirection: 'column' }, body),
782
- h(Box, { marginTop: 1 }, h(Text, { color: p.faint }, panel.palette
783
- ? `type to filter · ${g('↑↓', 'up/dn')} choose · enter run · esc close`
784
- : `${g('↑↓', 'up/dn')} scroll · esc close`)),
785
- ),
786
- ),
787
- );
788
- }
789
-
790
- // ─── The app ────────────────────────────────────────────────────────────────────
791
-
792
- function App({ controller }) {
793
- // Ink's hook answers in `{columns, rows}`, not `{width, height}`. Destructuring the
794
- // wrong names yields two `undefined`s that propagate into every wrap and clamp as
795
- // NaN — comparisons against NaN are all false, so the layout does not throw, it just
796
- // silently breaks one word per line. Worth naming, because nothing in the output
797
- // points at the hook.
798
- const size = useWindowSize();
799
- const width = Math.max(30, size?.columns || process.stdout.columns || 80);
800
- const height = Math.max(10, size?.rows || process.stdout.rows || 24);
801
- const { exit } = useApp();
802
- const [, setTick] = React.useState(0);
803
- const p = palette();
804
-
805
- React.useEffect(() => {
806
- controller.attach(setTick);
807
- return () => controller.attach(() => {});
808
- }, [controller]);
809
-
810
- useCursor({ visible: false });
811
- useInput((input, key) => controller.handleKey(input, key, exit));
812
- // Bracketed paste. Without it a pasted block arrives as ordinary input, and the first
813
- // newline in it SUBMITS — so pasting a stack trace sent its first line and left the
814
- // rest in the box. Ink keeps paste off the useInput channel entirely while this is
815
- // mounted, so the two cannot race.
816
- usePaste((text) => controller.handlePaste(text));
817
-
818
- // A resize invalidates anything expressed in screen coordinates. The transcript
819
- // re-wraps at the new width, so a selection made at the old one now highlights
820
- // unrelated text, and the hit map is rebuilt from scratch on the next frame anyway.
821
- React.useEffect(() => { controller.onResize(width, height); }, [controller, width, height]);
822
-
823
- const state = controller.state;
824
- const composerRows = CHROME.composerFrame
825
- + (state.menu.length ? state.menu.length + 1 : 0)
826
- + Math.max(0, composerTextRows(state, width) - 1)
827
- + (state.queued.length ? 2 : 0)
828
- + (state.attachments.length ? 2 : 0);
829
- const workRows = state.busy || state.status ? 1 : 0;
830
- // A single pinned line showing what was last asked, while the transcript is scrolled
831
- // away from it. Scrolling back through a long answer and losing track of the question
832
- // it was answering is the specific thing this fixes.
833
- const stickyRows = state.viewingTranscript ? 1 : 0;
834
- const transcriptHeight = Math.max(3, height - CHROME.header - CHROME.status - composerRows - workRows - CHROME.gapAboveComposer - stickyRows);
835
-
836
- // The welcome stands until there is real conversation. Anything chrome-ish — a
837
- // notice, a stray pre-formatted print from an older code path — must not suppress it:
838
- // one such line was enough to make the whole welcome screen, logo included, invisible,
839
- // and nothing about the result suggested why.
840
- const CONVERSATION = new Set(['user', 'assistant', 'tool', 'diff', 'todo', 'error']);
841
- const showHero = state.home && !state.live && !state.transcript.some((e) => CONVERSATION.has(e.kind));
842
-
843
- // Immediate-mode hit testing: the regions are rebuilt from the same numbers that
844
- // produce the layout, every frame. Anything measured after the fact would be a second
845
- // source of truth for where things are, and the two would drift.
846
- const hits = controller.hits;
847
- hits.reset();
848
- const transcriptTop = CHROME.header + stickyRows; // 0-based row above the transcript
849
- // Laid out here, before anything renders, so the header above the transcript is drawn
850
- // from THIS frame's numbers rather than the previous one's.
851
- const layout = showHero
852
- ? { visible: [], pad: 0, screenTop: transcriptTop + 1 }
853
- : layoutTranscript(state, width, transcriptHeight, transcriptTop);
854
- hits.add({ x1: 1, x2: width, y1: transcriptTop + 1, y2: transcriptTop + transcriptHeight, action: { type: 'transcript' } });
855
- registerLinkHits(layout.visible, layout.screenTop, hits);
856
- registerHeaderHits(state, width, hits);
857
- hits.add({ x1: 1, x2: width, y1: height, y2: height, action: { type: 'status' } });
858
- if (state.menu.length) {
859
- const menuTop = height - CHROME.status - composerRows + 1; // inside the composer border
860
- state.menu.forEach((_item, index) => {
861
- hits.add({ x1: 2, x2: width - 1, y1: menuTop + index + 1, y2: menuTop + index + 1, action: { type: 'menuRow', index } });
862
- });
863
- }
864
-
865
- return h(Box, { flexDirection: 'column', width: '100%', height: '100%' },
866
- h(Header, { state, width }),
867
- stickyRows ? h(StickyPrompt, { state, width }) : null,
868
- showHero
869
- ? h(Hero, { state, width, height: transcriptHeight })
870
- : h(Transcript, { state, width, height: transcriptHeight, layout }),
871
- h(Box, { height: CHROME.gapAboveComposer }),
872
- h(WorkBar, { state, width }),
873
- h(Box, { paddingX: 1 }, h(Composer, { state, width: width - 2 })),
874
- h(StatusBar, { state, width }),
875
- h(Panel, { state, width, height, hits }),
876
- h(Dialog, { state, width, height, hits }),
877
- );
878
- }
879
-
880
- function composerTextRows(state, width) {
881
- if (!state.input) return 1;
882
- return Math.min(8, wrapRow([{ t: state.input }], Math.max(8, width - 8)).length);
883
- }
884
-
885
- /**
886
- * The prompt currently being answered on screen.
887
- *
888
- * Published by the transcript each frame rather than read from the end of the list: the
889
- * useful line while scrolling back through a long session is the question THIS part of
890
- * the conversation was answering, not whatever was asked most recently. Scroll far
891
- * enough up and it becomes the previous exchange's question; scroll to where the prompt
892
- * itself is visible and there is nothing to pin.
893
- */
894
- function StickyPrompt({ state, width }) {
895
- const p = palette();
896
- const text = String(state.stickyPrompt || '').replace(/\s+/g, ' ').trim();
897
- return h(Box, { flexDirection: 'row', paddingX: 1, width, backgroundColor: p.raised },
898
- h(Text, { color: p.brand, bold: true }, `${g('▌', '|')} `),
899
- h(Text, { color: text ? p.muted : p.faint, wrap: 'truncate-end' },
900
- text || 'the start of this conversation'),
901
- h(Spacer),
902
- h(Text, { color: p.faint }, ` ${g('↑', '^')} scrolled`),
903
- );
904
- }
905
-
906
- /**
907
- * The header's right-hand side is a row of controls, not a readout — the mode, the
908
- * model and the context meter all do something when clicked.
909
- *
910
- * The columns are computed from the same strings the header renders, walking right to
911
- * left from the edge, so a click lands on the thing it looks like it is on. Anything
912
- * here that stops matching Header() shows up immediately as a click that misses — which
913
- * is why tests/tui-render.js slices the rendered row at these exact columns.
914
- */
915
- function registerHeaderHits(state, width, hits) {
916
- const m = state.meta;
917
- const narrow = width < 78;
918
- const mode = MODE_LABEL[m.mode] || 'ask';
919
- const model = narrow ? shortModel(m.model) : String(m.model || 'auto');
920
-
921
- let right = width - 1; // 1-based column of the last cell (paddingX 1)
922
- if (!narrow) {
923
- const meter = 8 + 1 + stringWidth(m.ctx || '0 tok');
924
- hits.add({ x1: right - meter + 1, x2: right, y1: 1, y2: 1, action: { type: 'context' } });
925
- right -= meter + 5; // the ' · ' separator
926
- }
927
- hits.add({ x1: right - stringWidth(model) + 1, x2: right, y1: 1, y2: 1, action: { type: 'model' } });
928
- right -= stringWidth(model) + 5;
929
- hits.add({ x1: right - mode.length + 1, x2: right, y1: 1, y2: 1, action: { type: 'mode' } });
930
- }
931
-
932
- // ─── Controller ─────────────────────────────────────────────────────────────────
933
-
934
- export function createTui({ slashCommands = [], fileProvider = null, meta = {}, onModeCycle = null, settings = null } = {}) {
935
- publishSettings(settings);
936
-
937
- const commands = slashCommands.map((command) => (typeof command === 'string' ? { name: command, summary: '' } : command));
938
-
939
- const state = {
940
- active: false, busy: false, home: true,
941
- input: '', cursor: 0, placeholder: '',
942
- menu: [], menuIndex: -1,
943
- history: [], historyIndex: -1, draft: null,
944
- queued: [], attachments: [],
945
- status: '', statusLine: '',
946
- // A short notice — "Copied 40 characters", "Opened <link>" — shown on the status
947
- // line for a moment. Deliberately NOT `status`: that is the live working status the
948
- // work bar draws with a spinner, so a notice put there looked like a task running,
949
- // and clearing it could wipe the real status of a turn in flight.
950
- flash: null,
951
- transcript: [], live: null, activity: [],
952
- transcriptOffset: 0, newMessages: 0, viewingTranscript: false,
953
- dialog: null, panel: null, mouse: false, selection: null, screen: null, maxScroll: 0, pastes: [],
954
- onLine: null, onEscape: null, onRewind: null, onCommand: null, onCopy: null, onModeCycle,
955
- debug: !!process.env.JAITUS_DEBUG, introDone: false,
956
- turnStartedAt: null, turnTokens: 0,
957
- spent: { turns: 0, input: 0, cached: 0, output: 0 },
958
- meta: { model: 'loading…', mode: 'default', ctx: '0 tok', ctxUsed: 0, ctxMax: 1, intent: 'build', ...meta },
959
- };
960
-
961
- let inkInstance = null;
962
- let update = () => {};
963
- let pending = null;
964
- let exited = false;
965
- const hits = createHitMap();
966
- const scrollStep = () => {
967
- const n = Number(settings?.scrollSpeed);
968
- return Math.max(1, Math.round((Number.isFinite(n) && n > 0 ? n : 1) * 3));
969
- };
970
-
971
- /**
972
- * Coalesce notifications to one per animation frame.
973
- *
974
- * A streaming response calls print thousands of times a second. Re-rendering per
975
- * chunk pins a core and makes typing lag behind the keyboard; batching at 16ms costs
976
- * nothing visually and keeps input responsive under a full-speed stream.
977
- */
978
- function notify() {
979
- if (pending) return;
980
- pending = setTimeout(() => { pending = null; update((value) => value + 1); }, 16);
981
- if (pending.unref) pending.unref();
982
- }
983
- function notifyNow() {
984
- if (pending) { clearTimeout(pending); pending = null; }
985
- update((value) => value + 1);
986
- }
987
-
988
- /**
989
- * The brand lockup, as transcript row zero.
990
- *
991
- * Deliberately an ENTRY rather than chrome. It scrolls like anything else, so it is
992
- * never sticky and never costs a row of the working area — and because it is the
993
- * first thing in the scrollback it is still there at the top of a thousand-turn
994
- * session. The welcome screen owns the same lockup for the fresh-session moment; this
995
- * is what keeps it on screen once real conversation replaces that.
996
- */
997
- function seedBanner() {
998
- if (process.env.JAITUS_NO_BANNER) return;
999
- if (state.transcript[0]?.kind === 'banner') return;
1000
- state.transcript.unshift(makeEntry('banner', { version: state.meta.version }));
1001
- }
1002
-
1003
- let flashTimer = null;
1004
- /** Show a notice on the status line for `ms`, then take it down — with a redraw. */
1005
- function flash(text, tone = 'ok', ms = 1600) {
1006
- if (flashTimer) clearTimeout(flashTimer);
1007
- state.flash = { text: String(text), tone };
1008
- notifyNow();
1009
- flashTimer = setTimeout(() => {
1010
- flashTimer = null;
1011
- state.flash = null;
1012
- // notifyNow, not a bare assignment: the old clear changed state without asking
1013
- // for a redraw, so the notice stayed on screen until something else repainted.
1014
- notifyNow();
1015
- }, ms);
1016
- if (flashTimer.unref) flashTimer.unref();
1017
- }
1018
-
1019
- function push(kind, data) {
1020
- const entry = makeEntry(kind, data);
1021
- state.transcript.push(entry);
1022
- // A transcript is not a log: 600 entries is far more than anyone scrolls back
1023
- // through, and every one held is rows cached against the current width. The banner
1024
- // is exempt — trimming from the front would quietly delete the brand off the top of
1025
- // any long session, which is the exact failure this entry exists to fix.
1026
- if (state.transcript.length > 600) {
1027
- const keep = state.transcript[0]?.kind === 'banner' ? 1 : 0;
1028
- state.transcript.splice(keep, state.transcript.length - 600);
1029
- }
1030
- if (state.viewingTranscript) state.newMessages += 1;
1031
- notify();
1032
- return entry;
1033
- }
1034
-
1035
- function refreshMenu() {
1036
- state.menu = state.dialog || state.panel ? [] : menuItemsFor(state.input, commands, fileProvider, state.cursor);
1037
- state.menuIndex = state.menu.length ? clamp(state.menuIndex < 0 ? 0 : state.menuIndex, 0, state.menu.length - 1) : -1;
1038
- }
1039
-
1040
- function insert(text) {
1041
- state.input = state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor);
1042
- state.cursor += text.length;
1043
- refreshMenu();
1044
- notifyNow();
1045
- }
1046
-
1047
- function clearInput() {
1048
- state.input = '';
1049
- state.cursor = 0;
1050
- state.menu = [];
1051
- state.menuIndex = -1;
1052
- notifyNow();
1053
- }
1054
-
1055
- /**
1056
- * Take the highlighted suggestion.
1057
- *
1058
- * `run` submits it instead of completing it, which is what Enter does on a command:
1059
- * typing `/re`, pressing Enter and having the composer fill in `/review ` so you can
1060
- * press Enter a second time is a completion, not a shortcut. Tab still completes, for
1061
- * when the command needs an argument. A file suggestion is never run — `@` is always
1062
- * building up a message.
1063
- */
1064
- function chooseMenu({ run = false } = {}) {
1065
- const item = state.menu[state.menuIndex];
1066
- if (!item) return false;
1067
- const trigger = item.kind === 'command' ? '/' : '@';
1068
- const at = state.input.lastIndexOf(trigger, Math.max(0, state.cursor - 1));
1069
- const prefix = at >= 0 ? state.input.slice(0, at) : '';
1070
- const suffix = state.input.slice(state.cursor);
1071
-
1072
- if (run && item.kind === 'command' && !prefix.trim() && !suffix.trim()) {
1073
- state.input = String(item.value);
1074
- state.cursor = state.input.length;
1075
- state.menu = [];
1076
- state.menuIndex = -1;
1077
- submit();
1078
- return true;
1079
- }
1080
-
1081
- state.input = `${prefix}${item.value} ${suffix}`;
1082
- state.cursor = prefix.length + String(item.value).length + 1;
1083
- refreshMenu();
1084
- notifyNow();
1085
- return true;
1086
- }
1087
-
1088
- /**
1089
- * A paste.
1090
- *
1091
- * Short pastes go in as text — that is what you meant, and a chip for eleven
1092
- * characters is a chip in the way. Anything long becomes a placeholder holding its
1093
- * place in the sentence, with the real content kept aside and substituted back when
1094
- * the message is sent. The alternative is a composer that has grown to forty rows and
1095
- * hidden the conversation you were pasting INTO.
1096
- */
1097
- function handlePaste(text) {
1098
- const value = String(text ?? '').replace(/\r\n?/g, '\n');
1099
- if (!value) return;
1100
- const lines = value.split('\n').length;
1101
-
1102
- if (lines <= 3 && value.length <= 240) {
1103
- insert(value);
1104
- return;
1105
- }
1106
-
1107
- const id = state.pastes.length + 1;
1108
- state.pastes.push({ id, text: value });
1109
- // The token has to survive being typed around, so it is bracketed and carries its
1110
- // own index; expansion matches on that rather than on position.
1111
- insert(`[#${id} pasted ${lines} line${lines === 1 ? '' : 's'}, ${value.length} chars]`);
1112
- }
1113
-
1114
- // ── a paste chip is one thing ──
1115
- //
1116
- // It looks like a single object, so it has to behave like one. Backspacing a character
1117
- // off the end leaves `[#1 pasted 120 lines, 969 char` — a token that no longer matches,
1118
- // so the content it stands for is silently orphaned and the literal text is what gets
1119
- // sent. Deleting it whole is both what you meant and the only way the placeholder and
1120
- // the thing it holds cannot come apart.
1121
- const CHIP = /\[#(\d+) pasted [^\]]*\]/;
1122
- const chipBefore = (text, cursor) => new RegExp(`${CHIP.source}$`).exec(text.slice(0, cursor));
1123
- const chipAfter = (text, cursor) => new RegExp(`^${CHIP.source}`).exec(text.slice(cursor));
1124
-
1125
- /** Forget the content a chip stood for, once the chip is gone. */
1126
- function dropPaste(id) {
1127
- state.pastes = state.pastes.filter((entry) => entry.id !== Number(id));
1128
- }
1129
-
1130
- /** Put the real content back in place of every placeholder. */
1131
- function expandPastes(line) {
1132
- if (!state.pastes.length) return line;
1133
- return line.replace(/\[#(\d+) pasted [^\]]*\]/g, (whole, id) => {
1134
- const blob = state.pastes.find((entry) => entry.id === Number(id));
1135
- return blob ? blob.text : whole;
1136
- });
1137
- }
1138
-
1139
- function submit() {
1140
- const typed = state.input;
1141
- const line = expandPastes(typed);
1142
- state.pastes = [];
1143
- clearInput();
1144
- if (!line.trim()) return;
1145
- // History keeps the TOKEN form. Pressing Up after sending a thousand-line paste
1146
- // should bring back the sentence you wrote, not the thousand lines.
1147
- if (state.history.at(-1) !== typed) state.history.push(typed);
1148
- state.historyIndex = state.history.length;
1149
- state.viewingTranscript = false;
1150
- state.transcriptOffset = 0;
1151
- state.newMessages = 0;
1152
- if (state.busy) state.queued.push(line);
1153
- else state.onLine?.(line);
1154
- notifyNow();
1155
- }
1156
-
1157
- function historyMove(direction) {
1158
- if (!state.history.length) return;
1159
- if (state.historyIndex === state.history.length) state.draft = state.input;
1160
- state.historyIndex = clamp(state.historyIndex + direction, 0, state.history.length);
1161
- state.input = state.historyIndex === state.history.length ? (state.draft || '') : state.history[state.historyIndex];
1162
- state.cursor = state.input.length;
1163
- refreshMenu();
1164
- notifyNow();
1165
- }
1166
-
1167
- function wordLeft() {
1168
- const upto = state.input.slice(0, state.cursor);
1169
- const match = /\S+\s*$/.exec(upto);
1170
- return match ? state.cursor - match[0].length : 0;
1171
- }
1172
- function wordRight() {
1173
- const rest = state.input.slice(state.cursor);
1174
- const match = /^\s*\S+/.exec(rest);
1175
- return match ? state.cursor + match[0].length : state.input.length;
1176
- }
1177
-
1178
- function resolveDialog(value) {
1179
- const dialog = state.dialog;
1180
- if (!dialog) return;
1181
- state.dialog = null;
1182
- refreshMenu();
1183
- dialog.resolve(value);
1184
- notifyNow();
1185
- }
1186
-
1187
- function resolvePanel() {
1188
- const panel = state.panel;
1189
- if (!panel) return;
1190
- state.panel = null;
1191
- refreshMenu();
1192
- panel.resolve?.();
1193
- notifyNow();
1194
- }
1195
-
1196
- let lastEscapeAt = 0;
1197
- // Drag state. A press is ambiguous until release: it may be a click on a control or
1198
- // the start of a text selection, and treating it as either one too early breaks the
1199
- // other.
1200
- let pressedAt = null;
1201
- let dragged = false;
1202
- // Counted so /debug can distinguish "mouse reporting was requested" from "the
1203
- // terminal is actually sending reports". Those are different facts and only the
1204
- // second one means the wheel works.
1205
- let mouseReports = 0;
1206
- let lastMouseAt = 0;
1207
- // Half a mouse report, waiting for the rest (see isMouseFragment).
1208
- let mouseFragment = null;
1209
- // Double- and triple-click: select a word, then a line — what every terminal does,
1210
- // and the first thing people try when they want one word out of the output.
1211
- let lastClick = null;
1212
- let clickCount = 0;
1213
-
1214
- /**
1215
- * Scroll the transcript. Positive is back into history.
1216
- *
1217
- * Clamped at BOTH ends against the real row count, which the transcript publishes
1218
- * each frame. Previously only the renderer clamped it: the offset itself grew without
1219
- * limit, so scrolling up past the top kept counting into a void and scrolling back
1220
- * down did nothing visible until it had counted all the way back. It read as the
1221
- * wheel sticking, at random, for a random length of time.
1222
- */
1223
- function scroll(rows) {
1224
- // The ceiling comes from the last frame. On the very first scroll after new content
1225
- // there has not been one yet, so a stale 0 would swallow the gesture — allow the
1226
- // move and let the renderer snap it back. It is authoritative; this is a hint.
1227
- const known = Math.max(0, state.maxScroll || 0);
1228
- const ceiling = known || (state.transcript.length ? Number.MAX_SAFE_INTEGER : 0);
1229
- const next = clamp(state.transcriptOffset + rows, 0, ceiling);
1230
- if (next === state.transcriptOffset) {
1231
- if (!next && state.viewingTranscript) { state.viewingTranscript = false; state.newMessages = 0; notifyNow(); }
1232
- return;
1233
- }
1234
- state.transcriptOffset = next;
1235
- state.viewingTranscript = next > 0;
1236
- if (!next) state.newMessages = 0;
1237
- notifyNow();
1238
- }
1239
-
1240
- /** Every screen cell between two points, as text. */
1241
- function selectedText() {
1242
- const sel = state.selection;
1243
- const screen = state.screen;
1244
- if (!sel?.anchor || !sel?.focus || !screen) return '';
1245
- const a = sel.anchor;
1246
- const b = sel.focus;
1247
- const [from, to] = (a.y < b.y || (a.y === b.y && a.x <= b.x)) ? [a, b] : [b, a];
1248
- const out = [];
1249
- screen.rows.forEach((row, index) => {
1250
- const y = screen.top + index;
1251
- if (y < from.y || y > to.y) return;
1252
- const text = rowText(row);
1253
- const start = y === from.y ? Math.max(0, from.x - screen.left) : 0;
1254
- const end = y === to.y ? Math.max(0, to.x - screen.left + 1) : text.length;
1255
- out.push(text.slice(start, end).replace(/\s+$/, ''));
1256
- });
1257
- return out.join('\n').replace(/\n+$/, '');
1258
- }
1259
-
1260
- /**
1261
- * Select the word or the whole line under a point, the way a double- or triple-click
1262
- * does in a terminal. Returns false when there is no text there.
1263
- */
1264
- function selectAt(point, unit) {
1265
- const screen = state.screen;
1266
- if (!screen) return false;
1267
- const row = screen.rows[point.y - screen.top];
1268
- if (!row) return false;
1269
- const text = rowText(row);
1270
- const col = point.x - screen.left;
1271
- let start, end;
1272
- if (unit === 'line') {
1273
- // Past the gutter: the message bullet and quote bars are decoration, not text.
1274
- start = text.length - text.replace(/^[\s●○◆▎│┃⎿]+/, '').length;
1275
- end = text.replace(/\s+$/, '').length - 1;
1276
- } else {
1277
- if (col < 0 || col >= text.length || /\s/.test(text[col])) return false;
1278
- // A "word" is what you would want to paste: a path, a URL, an identifier.
1279
- const inWord = (ch) => ch && !/[\s"'`()[\]{}<>,;]/.test(ch);
1280
- start = col; end = col;
1281
- while (inWord(text[start - 1])) start -= 1;
1282
- while (inWord(text[end + 1])) end += 1;
1283
- }
1284
- if (end < start) return false;
1285
- state.selection = { anchor: { x: screen.left + start, y: point.y }, focus: { x: screen.left + end, y: point.y } };
1286
- notifyNow();
1287
- void finishSelection();
1288
- return true;
1289
- }
1290
-
1291
- function clearSelection() {
1292
- if (!state.selection) return;
1293
- state.selection = null;
1294
- notifyNow();
1295
- }
1296
-
1297
- /**
1298
- * Finish a drag: copy what was highlighted.
1299
- *
1300
- * Copy-on-select is the terminal behaviour people expect, and taking the mouse away
1301
- * from the terminal took it with it. Doing it on release rather than on a menu keeps
1302
- * the muscle memory intact; `copyOnSelect off` turns it back into a plain highlight.
1303
- */
1304
- async function finishSelection() {
1305
- const text = selectedText();
1306
- if (!text.trim()) { clearSelection(); return; }
1307
- if (settings?.copyOnSelect === false) {
1308
- // Not "Ctrl+Shift+C": that is the TERMINAL's copy shortcut and it acts on the
1309
- // terminal's own selection, which does not exist while the app owns the mouse.
1310
- // Naming a shortcut that cannot work is worse than naming none.
1311
- flash(`${text.length} characters selected · jaitus config copyOnSelect on — to copy these automatically`, 'info', 4000);
1312
- return;
1313
- }
1314
- const ok = await (state.onCopy ? state.onCopy(text) : Promise.resolve(false));
1315
- if (ok) flash(`Copied ${text.length} character${text.length === 1 ? '' : 's'}`, 'ok', 1400);
1316
- else flash('Nothing could copy it — no clipboard tool found', 'warn', 3000);
1317
- // The highlight stays a moment so it is clear WHAT was copied, then goes.
1318
- const copied = state.selection;
1319
- setTimeout(() => { if (state.selection === copied) clearSelection(); }, 700);
1320
- }
1321
-
1322
- /**
1323
- * A click landed on something.
1324
- *
1325
- * Everything reachable on screen does what it looks like it does: the mode pill opens
1326
- * the mode picker, the model name opens the model picker, the context meter offers to
1327
- * compact. That is the whole point of drawing them as distinct objects — an indicator
1328
- * you cannot act on is a thing you have to go and find a command for.
1329
- */
1330
- function dispatchClick(region, event) {
1331
- if (!region) return;
1332
- switch (region.action.type) {
1333
- case 'swallow':
1334
- return;
1335
- case 'dialogOption': {
1336
- const d = state.dialog;
1337
- if (!d) return;
1338
- d.index = clamp(region.action.index, 0, d.options.length - 1);
1339
- return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
1340
- }
1341
- case 'paletteRow': {
1342
- const panel = state.panel;
1343
- if (!panel?.palette) return;
1344
- panel.index = clamp(region.action.index, 0, Math.max(0, panel.options.length - 1));
1345
- const chosen = panel.options[panel.index];
1346
- const name = typeof chosen === 'string' ? chosen : chosen?.name;
1347
- state.panel = null;
1348
- refreshMenu();
1349
- if (!name) return notifyNow();
1350
- state.input = name;
1351
- state.cursor = name.length;
1352
- return submit();
1353
- }
1354
- case 'menuRow': {
1355
- state.menuIndex = clamp(region.action.index, 0, state.menu.length - 1);
1356
- return chooseMenu({ run: true });
1357
- }
1358
- case 'mode':
1359
- return void state.onModeCycle?.();
1360
- case 'model':
1361
- return void state.onCommand?.('/model');
1362
- case 'context':
1363
- return void state.onCommand?.('/context');
1364
- case 'link':
1365
- // Only http(s) reaches here — see openUrl. A terminal will happily render
1366
- // `file:///` or `javascript:` in text that came from a model or a fetched page,
1367
- // and handing either to the shell runs something nobody agreed to.
1368
- if (openUrl(region.action.url)) {
1369
- flash(`Opened ${region.action.url.slice(0, 60)}`, 'info', 1600);
1370
- }
1371
- return;
1372
- case 'status':
1373
- return openPalette();
1374
- case 'transcript':
1375
- // A click in the body is how you get back to the live end after scrolling.
1376
- if (state.viewingTranscript && event.button === 'left') {
1377
- state.viewingTranscript = false;
1378
- state.transcriptOffset = 0;
1379
- state.newMessages = 0;
1380
- notifyNow();
1381
- }
1382
- return;
1383
- default:
1384
- }
1385
- }
1386
-
1387
- function openPalette() {
1388
- state.panel = {
1389
- title: 'COMMAND PALETTE', palette: true, filter: '',
1390
- all: commands, options: commands, index: 0, lines: [], resolve: () => {},
1391
- };
1392
- notifyNow();
1393
- }
1394
-
1395
- function handleKey(input, key, exit) {
1396
- if (!state.active) return;
1397
-
1398
- // ── the mouse arrives here too ──
1399
- //
1400
- // Ink does not recognise the reports, so it hands them through as ordinary input
1401
- // with the escape stripped. Without this guard, scrolling the wheel types
1402
- // `[<64;10;5M` into the composer.
1403
- if (mouseFragment) {
1404
- const fresh = Date.now() - mouseFragment.at < 300;
1405
- const joined = mouseFragment.text + input;
1406
- mouseFragment = null;
1407
- if (fresh && (isMouse(joined) || isMouseFragment(joined))) input = joined;
1408
- }
1409
- if (isMouseFragment(input)) {
1410
- mouseFragment = { text: input, at: Date.now() };
1411
- return;
1412
- }
1413
- if (isMouse(input)) {
1414
- mouseReports += 1;
1415
- lastMouseAt = Date.now();
1416
- const event = parseMouse(input);
1417
- if (!event) return;
1418
- if (event.kind === 'wheelUp') return scroll(scrollStep());
1419
- if (event.kind === 'wheelDown') return scroll(-scrollStep());
1420
-
1421
- if (event.kind === 'down' && event.button === 'left') {
1422
- // A press starts a possible drag AND a possible click. Which it was is only
1423
- // known on release, so nothing is dispatched yet — dispatching here made every
1424
- // attempt to select text also press whatever was under the cursor.
1425
- pressedAt = { x: event.x, y: event.y };
1426
- dragged = false;
1427
- clearSelection();
1428
- return;
1429
- }
1430
- if (event.kind === 'drag' && pressedAt) {
1431
- if (event.x !== pressedAt.x || event.y !== pressedAt.y) dragged = true;
1432
- state.selection = { anchor: pressedAt, focus: { x: event.x, y: event.y } };
1433
- return notifyNow();
1434
- }
1435
- if (event.kind === 'up') {
1436
- const start = pressedAt;
1437
- pressedAt = null;
1438
- if (!start) return;
1439
- if (dragged) { dragged = false; void finishSelection(); return; }
1440
- const now = Date.now();
1441
- const again = lastClick && now - lastClick.at < 450 && lastClick.y === start.y && Math.abs(lastClick.x - start.x) <= 1;
1442
- clickCount = again ? clickCount + 1 : 1;
1443
- lastClick = { x: start.x, y: start.y, at: now };
1444
- const region = hits.at(start.x, start.y);
1445
- const onText = !region || region.action.type === 'transcript' || region.action.type === 'swallow';
1446
- if (clickCount >= 2 && onText && selectAt(start, clickCount >= 3 ? 'line' : 'word')) return;
1447
- return dispatchClick(region, event);
1448
- }
1449
- return;
1450
- }
1451
-
1452
- // ── dialog has the keyboard ──
1453
- if (state.dialog) {
1454
- const d = state.dialog;
1455
- if (key.escape) return resolveDialog(undefined);
1456
- if (key.upArrow || key.up) { d.index = clamp(d.index - 1, 0, d.options.length - 1); return notifyNow(); }
1457
- if (key.downArrow || key.down) { d.index = clamp(d.index + 1, 0, d.options.length - 1); return notifyNow(); }
1458
- if (key.return || key.enter) return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
1459
- if (/^[1-9]$/.test(input)) {
1460
- const index = clamp(Number(input) - 1, 0, d.options.length - 1);
1461
- d.index = index;
1462
- // A number is a decision, not a cursor move: waiting for Enter after it is the
1463
- // most common reason a permission prompt feels unresponsive.
1464
- return resolveDialog({ ...d.options[index], comment: d.comment || '' });
1465
- }
1466
- return;
1467
- }
1468
-
1469
- // ── palette / panel has the keyboard ──
1470
- if (state.panel) {
1471
- const panel = state.panel;
1472
- if (panel.palette) {
1473
- if (key.upArrow || key.up) { panel.index = clamp(panel.index - 1, 0, panel.options.length - 1); return notifyNow(); }
1474
- if (key.downArrow || key.down) { panel.index = clamp(panel.index + 1, 0, panel.options.length - 1); return notifyNow(); }
1475
- if (key.return || key.enter) {
1476
- const chosen = panel.options[panel.index];
1477
- const name = typeof chosen === 'string' ? chosen : chosen?.name;
1478
- state.panel = null;
1479
- refreshMenu();
1480
- if (!name) return notifyNow();
1481
- // Run it. Picking a command out of a palette and then being handed it to
1482
- // press Enter on again is a step that exists for no reason. Shift+Enter is
1483
- // the escape hatch for a command you want to add an argument to.
1484
- state.input = name;
1485
- state.cursor = name.length;
1486
- if (key.shift) { state.input = `${name} `; state.cursor = state.input.length; return notifyNow(); }
1487
- submit();
1488
- return notifyNow();
1489
- }
1490
- if (key.escape) { state.panel = null; panel.resolve?.(); return notifyNow(); }
1491
- if (key.backspace || key.delete) {
1492
- panel.filter = String(panel.filter || '').slice(0, -1);
1493
- applyPaletteFilter(panel);
1494
- return notifyNow();
1495
- }
1496
- if (input && !key.ctrl && !key.meta) {
1497
- panel.filter = `${panel.filter || ''}${input}`;
1498
- applyPaletteFilter(panel);
1499
- return notifyNow();
1500
- }
1501
- return;
1502
- }
1503
- if (key.escape || key.return || key.enter) resolvePanel();
1504
- return;
1505
- }
1506
-
1507
- // Fast PTYs on Windows deliver a typed line and its carriage return in one chunk.
1508
- // Split here or the return becomes a literal character in the composer.
1509
- const newlineAt = String(input || '').search(/[\r\n]/);
1510
- if (newlineAt >= 0) {
1511
- const typed = String(input).slice(0, newlineAt);
1512
- const rest = String(input).slice(newlineAt + 1);
1513
- if (typed) insert(typed);
1514
- if (key.shift) insert('\n');
1515
- // This path went straight to submit(), which is why `/re` + Enter sometimes ran
1516
- // `/re` instead of the highlighted `/review`. Whether Enter arrives on its own or
1517
- // glued to the typed text is a matter of how fast the keystrokes were batched by
1518
- // the PTY — so the two paths have to make the same decision, or the shortcut
1519
- // works or does not work depending on typing speed.
1520
- else if (state.menu.length) chooseMenu({ run: true });
1521
- else submit();
1522
- if (rest) insert(rest);
1523
- return;
1524
- }
1525
-
1526
- // Ink reports Ctrl+<letter> as `key.ctrl` with the LETTER itself in `input`; its
1527
- // Key type has no `name` field at all. Every `key.name === 'l'` style check in the
1528
- // previous version of this file was therefore dead code — Ctrl+L, Ctrl+P, Ctrl+U,
1529
- // Ctrl+K, Ctrl+A and Ctrl+E all did nothing, silently, because a condition that is
1530
- // never true is indistinguishable from a shortcut nobody presses.
1531
- //
1532
- // `key.name` is still accepted so the controller tests (and the classic renderer's
1533
- // readline keypress objects) can drive the same handler.
1534
- const ctrlKey = key.ctrl ? String(key.name || input || '').toLowerCase() : '';
1535
-
1536
- if (ctrlKey === 'c') {
1537
- if (state.busy) state.onEscape?.();
1538
- else if (state.input) clearInput();
1539
- else flash('Type /exit to leave', 'info', 1500);
1540
- return;
1541
- }
1542
- if (key.shift && key.tab) { void state.onModeCycle?.(); return; }
1543
- if (ctrlKey === 'l') { api.clearTranscript(); return; }
1544
- if (ctrlKey === 'd') { api.toggleDebug(); return; }
1545
- if (ctrlKey === 'p') return openPalette();
1546
- if (key.escape) {
1547
- if (state.busy) return state.onEscape?.();
1548
- if (state.viewingTranscript) { state.viewingTranscript = false; state.transcriptOffset = 0; state.newMessages = 0; return notifyNow(); }
1549
- const now = Date.now();
1550
- if (lastEscapeAt && now - lastEscapeAt < 1200) {
1551
- lastEscapeAt = 0;
1552
- if (flashTimer) { clearTimeout(flashTimer); flashTimer = null; }
1553
- state.flash = null;
1554
- state.onRewind?.();
1555
- } else {
1556
- lastEscapeAt = now;
1557
- flash('Press Esc again to rewind', 'info', 1200);
1558
- }
1559
- return;
1560
- }
1561
-
1562
- // ── scrollback ──
1563
- if (key.pageUp || key.pageup) return scroll(10);
1564
- if (key.pageDown || key.pagedown) return scroll(-10);
1565
- if ((key.ctrl && (key.end || ctrlKey === 'g'))) {
1566
- state.viewingTranscript = false; state.transcriptOffset = 0; state.newMessages = 0;
1567
- return notifyNow();
1568
- }
1569
- if ((key.ctrl && key.home)) return scroll(100000);
1570
-
1571
- // ── menu ──
1572
- if ((key.upArrow || key.up) && state.menu.length) { state.menuIndex = clamp(state.menuIndex - 1, 0, state.menu.length - 1); return notifyNow(); }
1573
- if ((key.downArrow || key.down) && state.menu.length) { state.menuIndex = clamp(state.menuIndex + 1, 0, state.menu.length - 1); return notifyNow(); }
1574
- if (key.tab && state.menu.length) return chooseMenu();
1575
- if (key.return || key.enter) {
1576
- if (!key.shift && state.menu.length) return chooseMenu({ run: true });
1577
- if (key.shift) return insert('\n');
1578
- return submit();
1579
- }
1580
-
1581
- // ── line editing ──
1582
- if (key.leftArrow || key.left) {
1583
- if (key.ctrl || key.meta) state.cursor = wordLeft();
1584
- else {
1585
- // Step over a chip, not into it. A cursor sitting inside `[#1 pasted 120 …]`
1586
- // can only do damage: anything typed there breaks the token.
1587
- const chip = chipBefore(state.input, state.cursor);
1588
- state.cursor = Math.max(0, state.cursor - (chip ? chip[0].length : 1));
1589
- }
1590
- return notifyNow();
1591
- }
1592
- if (key.rightArrow || key.right) {
1593
- if (key.ctrl || key.meta) state.cursor = wordRight();
1594
- else {
1595
- const chip = chipAfter(state.input, state.cursor);
1596
- state.cursor = Math.min(state.input.length, state.cursor + (chip ? chip[0].length : 1));
1597
- }
1598
- return notifyNow();
1599
- }
1600
- if (key.home || (ctrlKey === 'a')) { state.cursor = 0; return notifyNow(); }
1601
- if (key.end || (ctrlKey === 'e')) { state.cursor = state.input.length; return notifyNow(); }
1602
- if (ctrlKey === 'w') {
1603
- // A chip is one word. Deleting "the last word" of `…, 969 chars]` would otherwise
1604
- // leave a broken token behind.
1605
- const chip = chipBefore(state.input, state.cursor);
1606
- const at = chip ? state.cursor - chip[0].length : wordLeft();
1607
- if (chip) dropPaste(chip[1]);
1608
- state.input = state.input.slice(0, at) + state.input.slice(state.cursor);
1609
- state.cursor = at;
1610
- refreshMenu();
1611
- return notifyNow();
1612
- }
1613
- if (key.backspace) {
1614
- if (state.cursor === 0) return;
1615
- const chip = chipBefore(state.input, state.cursor);
1616
- const eat = chip ? chip[0].length : 1;
1617
- if (chip) dropPaste(chip[1]);
1618
- state.input = state.input.slice(0, state.cursor - eat) + state.input.slice(state.cursor);
1619
- state.cursor -= eat;
1620
- refreshMenu();
1621
- return notifyNow();
1622
- }
1623
- if (key.delete) {
1624
- const chip = chipAfter(state.input, state.cursor);
1625
- const eat = chip ? chip[0].length : 1;
1626
- if (chip) dropPaste(chip[1]);
1627
- state.input = state.input.slice(0, state.cursor) + state.input.slice(state.cursor + eat);
1628
- refreshMenu();
1629
- return notifyNow();
1630
- }
1631
- if (ctrlKey === 'u') { state.input = state.input.slice(state.cursor); state.cursor = 0; refreshMenu(); return notifyNow(); }
1632
- if (ctrlKey === 'k') { state.input = state.input.slice(0, state.cursor); refreshMenu(); return notifyNow(); }
1633
- if (key.upArrow || key.up) return historyMove(-1);
1634
- if (key.downArrow || key.down) return historyMove(1);
1635
- if (input && !key.ctrl && !key.meta) insert(input);
1636
- }
1637
-
1638
- function applyPaletteFilter(panel) {
1639
- const filter = String(panel.filter || '');
1640
- if (!filter) { panel.options = panel.all; panel.index = 0; return; }
1641
- panel.options = panel.all
1642
- .map((command) => {
1643
- const name = typeof command === 'string' ? command : command.name;
1644
- const match = fuzzy(filter, name.replace(/^\//, ''));
1645
- return match ? { command, score: match.score } : null;
1646
- })
1647
- .filter(Boolean)
1648
- .sort((a, b) => b.score - a.score)
1649
- .map((entry) => entry.command);
1650
- panel.index = 0;
1651
- }
1652
-
1653
- const api = {
1654
- state,
1655
-
1656
- attach(fn) { update = fn; },
1657
- onEscape(fn) { state.onEscape = fn; },
1658
- onRewind(fn) { state.onRewind = fn; },
1659
- setMeta(next) { Object.assign(state.meta, next); notify(); },
1660
- setSpent(next) { Object.assign(state.spent, next || {}); notify(); },
1661
-
1662
- hits,
1663
- onCommand(fn) { state.onCommand = fn; },
1664
- onCopy(fn) { state.onCopy = fn; },
1665
- handlePaste,
1666
-
1667
- /**
1668
- * A resize is the one moment the screen has to be thrown away and redrawn.
1669
- *
1670
- * Ink clears only when the terminal gets NARROWER, on the reasoning that a wider
1671
- * one cannot leave anything behind. That is true of output that flows, and false of
1672
- * an app that draws a fixed number of rows: make the window shorter and the taller
1673
- * frame's lower rows stay on screen underneath the new one. That is the duplicated
1674
- * composer and the layout "breaking" on resize.
1675
- *
1676
- * Clearing on every size change costs one repaint during a drag-resize and removes
1677
- * the whole class of problem.
1678
- */
1679
- onResize(width, height) {
1680
- const last = state.lastSize;
1681
- state.lastSize = { width, height };
1682
- if (last && last.width === width && last.height === height) return;
1683
- // Anything expressed in screen coordinates is now about the wrong cells.
1684
- state.selection = null;
1685
- try {
1686
- // Both: `clear()` resets Ink's own idea of what it last wrote, and the explicit
1687
- // erase wipes cells Ink is no longer tracking. Ink erases only the lines it
1688
- // remembers writing, so after the window SHRINKS the rows of the taller frame
1689
- // below the new one are not in that set and would stay on screen.
1690
- inkInstance?.clear();
1691
- if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
1692
- } catch { /* mid-teardown */ }
1693
- notifyNow();
1694
- },
1695
-
1696
- /**
1697
- * What the mouse is actually doing, for `/debug`.
1698
- *
1699
- * Enabling reporting and receiving reports are different facts, and only the second
1700
- * one means the mouse works. A terminal that ignores the request looks identical
1701
- * from this side to one that is simply not being used — so the count is kept, and
1702
- * "requested but nothing received" is a thing the diagnostics can say out loud
1703
- * instead of leaving someone to guess.
1704
- */
1705
- mouseReport() {
1706
- return {
1707
- requested: !!state.mouse,
1708
- reportsSeen: mouseReports,
1709
- lastAt: lastMouseAt ? new Date(lastMouseAt).toISOString() : null,
1710
- };
1711
- },
1712
-
1713
- start({ onLine, history = [] }) {
1714
- state.onLine = onLine;
1715
- state.history = [...history];
1716
- state.historyIndex = state.history.length;
1717
- state.active = true;
1718
- seedBanner();
1719
- // `alternateScreen` is not a cosmetic preference — three separate defects came
1720
- // from not passing it.
1721
- //
1722
- // Rendering inline, Ink reflows its output in place against a window whose height
1723
- // it does not own. Resize the terminal and the previous frame is still on screen
1724
- // under the new one: that is the duplicated composer, the doubled prompt, and the
1725
- // "sometimes fullscreen, sometimes not" all at once.
1726
- //
1727
- // It also breaks the mouse. Every coordinate this file computes assumes row 1 is
1728
- // the top of the app, which is only true on a screen the app owns. Inline, the
1729
- // app sits below whatever scrollback happens to be there, so clicks and drag
1730
- // selection land on the wrong rows — by an amount that changes as the session
1731
- // grows, which is why it looked intermittent rather than broken.
1732
- inkInstance = render(h(App, { controller: api }), {
1733
- alternateScreen: true,
1734
- exitOnCtrlC: false,
1735
- patchConsole: false,
1736
- });
1737
- // Mouse reporting takes over the terminal's own selection, so it is opt-out and
1738
- // the status bar says how to get selection back. Enabled after render so Ink's
1739
- // own setup cannot overwrite the mode.
1740
- if (settings?.mouse !== false && !process.env.JAITUS_DISABLE_MOUSE) {
1741
- state.mouse = enableMouse(process.stdout);
1742
- }
1743
- notifyNow();
1744
- },
1745
-
1746
- stop() {
1747
- state.active = false;
1748
- exited = true;
1749
- if (pending) { clearTimeout(pending); pending = null; }
1750
- // Before unmount: the terminal keeps reporting after the process is gone
1751
- // otherwise, and every click in the shell afterwards prints escape codes.
1752
- disableMouse(process.stdout);
1753
- inkInstance?.unmount();
1754
- inkInstance = null;
1755
- },
1756
-
1757
- setMouse(on) {
1758
- if (on) state.mouse = enableMouse(process.stdout);
1759
- else { disableMouse(process.stdout); state.mouse = false; }
1760
- notifyNow();
1761
- return state.mouse;
1762
- },
1763
-
1764
- // ── structured entries ──────────────────────────────────────────────────────
1765
- user(text, attachments) { return push('user', { text, attachments }); },
1766
- assistant(text) { return push('assistant', { text }); },
1767
- notice(text, tone = 'dim') { return push('notice', { text, tone }); },
1768
- todo(items) { return push('todo', { items }); },
1769
- diff(file, patch, max) { return push('diff', { file, patch, max }); },
1770
-
1771
- /**
1772
- * A tool call. Returns a handle whose `update` mutates the same entry in place, so
1773
- * a row goes running → ok without a second line appearing under the first.
1774
- */
1775
- tool(data) {
1776
- const entry = push('tool', { status: 'running', ...data });
1777
- return {
1778
- id: entry.id,
1779
- update(next) {
1780
- Object.assign(entry.data, next);
1781
- entry._w = -1;
1782
- entry._rows = null;
1783
- notify();
1784
- },
1785
- };
1786
- },
1787
-
1788
- /**
1789
- * A failure, with everything needed to act on it.
1790
- *
1791
- * Errors were previously one dim line that scrolled past, so a session where every
1792
- * request was being rejected looked like a session where the model had nothing to
1793
- * say. They now get a red-barred block, a hint where one applies, and a folded
1794
- * stack that `/debug` or Ctrl+D unfolds.
1795
- */
1796
- error(error, context = {}) {
1797
- const value = error instanceof Error ? error : new Error(String(error?.message ?? error ?? 'Unknown error'));
1798
- const message = messageForError(value);
1799
- let hint = context.hint || hintForError(value);
1800
- // Our server messages often already say what to do ("run `jaitus login`"); the
1801
- // hint would then say it a second time, one line lower.
1802
- const flat = (t) => String(t || '').replace(/[`.]/g, '').toLowerCase().trim();
1803
- if (hint && flat(message).includes(flat(hint))) hint = '';
1804
- return push('error', {
1805
- title: context.title || titleForError(value),
1806
- message,
1807
- hint,
1808
- detail: context.detail || (value.requestId ? `request id ${value.requestId}` : ''),
1809
- // A service error's stack is this CLI's own call path — nothing in it explains
1810
- // what happened. Only a fault in the CLI keeps one, for /debug.
1811
- stack: value.name === 'JaitusError' ? '' : (value.stack || ''),
1812
- expanded: state.debug,
1813
- });
1814
- },
1815
-
1816
- toggleDebug() {
1817
- state.debug = !state.debug;
1818
- for (const entry of state.transcript) {
1819
- if (entry.kind === 'error') { entry.data.expanded = state.debug; entry._w = -1; entry._rows = null; }
1820
- }
1821
- flash(state.debug ? 'Debug on — stack traces expanded' : 'Debug off', 'info', 1600);
1822
- },
1823
-
1824
- // ── the legacy string surface ───────────────────────────────────────────────
1825
- //
1826
- // Most of the CLI still prints pre-formatted ANSI strings. Those keep working and
1827
- // keep their colour: `raw` parses the escapes back into spans rather than dropping
1828
- // them, which is what the previous renderer did.
1829
- print(value, kind = 'assistant') {
1830
- const text = String(value ?? '');
1831
- if (!plain(text).trim() && !/\n/.test(text)) return;
1832
- state.live = null;
1833
- if (kind === 'user') return push('raw', { ansi: text });
1834
- if (kind === 'error') {
1835
- return push('error', { title: 'Error', message: plain(text).replace(/^[✗x]\s*/, ''), stack: '', expanded: false });
1836
- }
1837
- return push('raw', { ansi: text });
1838
- },
1839
-
1840
- printStream(value) {
1841
- const text = String(value ?? '');
1842
- if (!text) return;
1843
- if (!state.live) {
1844
- state.live = { text: '', entry: makeEntry('assistant', { text: '', compact: true }) };
1845
- }
1846
- state.live.text += plain(text);
1847
- state.live.entry.data.text = state.live.text;
1848
- state.live.entry._w = -1;
1849
- state.live.entry._rows = null;
1850
- if (!state.turnStartedAt) state.turnStartedAt = Date.now();
1851
- notify();
1852
- },
1853
-
1854
- flushStream() {
1855
- if (state.live?.text?.trim()) {
1856
- const entry = state.live.entry;
1857
- entry.data.compact = false;
1858
- entry._w = -1;
1859
- entry._rows = null;
1860
- state.transcript.push(entry);
1861
- if (state.viewingTranscript) state.newMessages += 1;
1862
- }
1863
- state.live = null;
1864
- notify();
1865
- },
1866
-
1867
- clearTranscript() {
1868
- state.transcript = [];
1869
- seedBanner();
1870
- state.live = null;
1871
- state.transcriptOffset = 0;
1872
- state.viewingTranscript = false;
1873
- state.newMessages = 0;
1874
- state.home = true;
1875
- notifyNow();
1876
- },
1877
-
1878
- setStatusLine(value) { state.statusLine = plain(value || ''); notify(); },
1879
- setBusy(value) {
1880
- state.busy = !!value;
1881
- if (value) { state.turnStartedAt = Date.now(); state.turnTokens = 0; }
1882
- else { state.status = ''; state.turnStartedAt = null; }
1883
- notifyNow();
1884
- },
1885
- setStatus(value) { state.status = plain(value); notify(); },
1886
- flash(text, tone, ms) { flash(plain(text), tone, ms); },
1887
- clearStatus() { state.status = ''; notify(); },
1888
- setTurnTokens(n) { state.turnTokens = n | 0; notify(); },
1889
- setPlaceholder(text) { state.placeholder = String(text || ''); notify(); },
1890
- setHome(value) { state.home = !!value; notify(); },
1891
-
1892
- queue(line) { state.queued.push(line); notify(); },
1893
- takeQueued() { const queued = state.queued; state.queued = []; notify(); return queued; },
1894
- hasQueued() { return state.queued.length > 0; },
1895
-
1896
- addAttachment(attachment) { state.attachments.push(attachment); notify(); },
1897
- drainAttachments() { const list = state.attachments; state.attachments = []; notify(); return list.map((a) => a.block).filter(Boolean); },
1898
-
1899
- askDialog(question, options, preview = [], opts = {}) {
1900
- return new Promise((resolve) => {
1901
- state.dialog = {
1902
- question: plain(question),
1903
- options: options.map((option) => (option && typeof option === 'object'
1904
- ? { label: plain(option.label ?? option.value), value: option.value }
1905
- : { label: plain(option), value: option })),
1906
- preview: preview.map(plain),
1907
- index: 0,
1908
- resolve,
1909
- title: opts.title,
1910
- tone: opts.tone,
1911
- hideComment: opts.hideComment,
1912
- comment: '',
1913
- };
1914
- state.menu = [];
1915
- notifyNow();
1916
- });
1917
- },
1918
-
1919
- showPanel(title, lines) {
1920
- return new Promise((resolve) => {
1921
- state.panel = {
1922
- title: plain(title),
1923
- lines: (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line)),
1924
- rows: null,
1925
- resolve,
1926
- };
1927
- notifyNow();
1928
- });
1929
- },
1930
-
1931
- updatePanel(lines) {
1932
- if (!state.panel) return;
1933
- state.panel.lines = (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line));
1934
- notify();
1935
- },
1936
-
1937
- /** A theme change recolours every cached row, so they all have to be rebuilt. */
1938
- reskin() {
1939
- resetPalette();
1940
- invalidate(state.transcript);
1941
- if (state.live?.entry) { state.live.entry._w = -1; state.live.entry._rows = null; }
1942
- notifyNow();
1943
- },
1944
-
1945
- recordActivity(event) {
1946
- const existing = event.id ? state.activity.find((item) => item.id === event.id) : null;
1947
- if (existing) Object.assign(existing, event, { updatedAt: Date.now() });
1948
- else {
1949
- state.activity.push({
1950
- id: event.id || `${Date.now()}-${state.activity.length}`,
1951
- name: event.name,
1952
- label: String(event.label || event.name || ''),
1953
- status: event.status || 'running',
1954
- detail: event.detail || '',
1955
- updatedAt: Date.now(),
1956
- });
1957
- }
1958
- if (state.activity.length > 200) state.activity.shift();
1959
- notify();
1960
- return existing?.id || state.activity.at(-1)?.id;
1961
- },
1962
- activitySnapshot() { return state.activity.map((item) => ({ ...item })); },
1963
-
1964
- bellIfAway() {
1965
- // A terminal bell is the only cross-platform "I need you" a CLI has. Suppressed
1966
- // under reduced motion, which is also where people who do not want surprises are.
1967
- if (!motionOn()) return;
1968
- try { process.stdout.write(''); } catch { /* a bell is never worth an error */ }
1969
- },
1970
- animationsOn() { return state.active && motionOn(); },
1971
- redraw() { notifyNow(); },
1972
-
1973
- get exited() { return exited; },
1974
- handleKey,
1975
- };
1976
-
1977
- return api;
1978
- }
1979
-
1980
- // A JaitusError brings its own title and hint, written in errors.js. Anything else is a
1981
- // fault inside this CLI: it gets a plain title rather than its class name ("TypeError"
1982
- // means nothing to a user), and its real message and stack wait behind /debug.
1983
- function titleForError(error) {
1984
- if (error.name === 'JaitusError' && error.title) return error.title;
1985
- if (error.name === 'AbortError') return 'Interrupted';
1986
- return 'Something went wrong in Jaitus';
1987
- }
1988
-
1989
- function hintForError(error) {
1990
- if (error.name === 'JaitusError') return error.hint || '';
1991
- if (error.name === 'AbortError') return '';
1992
- return 'Try again. /debug shows the details if it keeps happening.';
1993
- }
1994
-
1995
- function messageForError(error) {
1996
- if (error.name === 'JaitusError' || error.name === 'AbortError') return error.message || '';
1997
- return 'This is a fault in the Jaitus CLI, not in your project.';
1998
- }
1
+ // ─── The Jaitus app view ────────────────────────────────────────────────────────
2
+ //
3
+ // One conversation, full bleed, with exactly four pieces of permanent chrome: a header
4
+ // line, the transcript, the composer, and a status line. There is no dashboard, no
5
+ // sidebar and no second column, because the thing being read is prose and code and
6
+ // both of those want the full width of the window.
7
+ //
8
+ // Three decisions are worth knowing before changing anything here:
9
+ //
10
+ // • **Entries keep their source, not their pixels.** `entries.js` renders rows on
11
+ // demand for the current width and caches them against it. Resizing re-lays every
12
+ // message correctly, and scrolling is exact because the row count is already known
13
+ // — the previous renderer estimated it and drifted.
14
+ // • **Colour survives.** Everything the rest of the CLI prints already carries ANSI.
15
+ // It is parsed back into styled spans rather than stripped, so a red ✗ printed by
16
+ // a command written two years ago is still red here.
17
+ // • **Motion is one timer.** Ink 7 consolidates every `useAnimation` into a single
18
+ // render cycle, which is the only reason a spinner, a shimmering label, a drifting
19
+ // hairline and a blinking caret can all run without the frame rate suffering.
20
+ //
21
+ // Reduced motion (`/animations off`, `JAITUS_NO_ANIMATION=1`) is a first-class mode:
22
+ // every animated widget has a still frame that reads correctly on its own.
23
+
24
+ import React from 'react';
25
+ import { Box, Spacer, Text, render, useAnimation, useApp, useCursor, useInput, usePaste, useWindowSize } from 'ink';
26
+ import { palette, resetPalette, mix, ramp } from './tui/theme.js';
27
+ import { makeEntry, rowsFor, invalidate } from './tui/entries.js';
28
+ import { plain, stringWidth, truncateRow, wrapRow, rowText, inverseRange } from './tui/ansi.js';
29
+ import { Spinner, Shimmer, GradientText, FlowingRule, Meter, Elapsed, Caret, Rows, Row, Reveal, motionOn, publishSettings } from './tui/widgets.js';
30
+ import { wordRows, wordSize } from './tui/wordmark.js';
31
+ import { enableMouse, disableMouse, parseMouse, isMouse, isMouseFragment, createHitMap } from './tui/mouse.js';
32
+ import { openUrl } from './open.js';
33
+ import { readClipboardText, readClipboardImage } from './tui/clipboard.js';
34
+ import { MASCOT_ROWS, startupTip } from './brand.js';
35
+
36
+ const h = React.createElement;
37
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
38
+ const ascii = () => process.env.JAITUS_ASCII === '1';
39
+ const g = (uni, plainText) => (ascii() ? plainText : uni);
40
+
41
+ // How many transcript rows the chrome costs. Kept as one table so a change to the
42
+ // layout cannot silently desynchronise the scroll maths from what is on screen.
43
+ // The header is one row plus its hairline. Kept here as one table so the layout and the
44
+ // hit map cannot disagree about where anything is — the controls sit on row 1, which is
45
+ // what registerHeaderHits assumes.
46
+ const CHROME = { header: 2, status: 1, composerFrame: 3, gapAboveComposer: 1 };
47
+
48
+ const MODE_LABEL = {
49
+ default: 'ask',
50
+ acceptEdits: 'auto-edit',
51
+ plan: 'plan',
52
+ bypassPermissions: 'bypass',
53
+ };
54
+
55
+ // ─── Fuzzy matching for the composer menu ───────────────────────────────────────
56
+ //
57
+ // Subsequence rather than prefix, so `/dr` finds `/deep-research` and `@idx` finds
58
+ // `src/index.js`. Consecutive and word-boundary hits score higher, which is what stops
59
+ // a long path from outranking the file you obviously meant.
60
+ function fuzzy(needle, haystack) {
61
+ const n = needle.toLowerCase();
62
+ const stack = haystack.toLowerCase();
63
+ if (!n) return { score: 1, hits: [] };
64
+ const hits = [];
65
+ let score = 0;
66
+ let at = 0;
67
+ let streak = 0;
68
+ for (const ch of n) {
69
+ const found = stack.indexOf(ch, at);
70
+ if (found === -1) return null;
71
+ const boundary = found === 0 || /[^a-z0-9]/.test(stack[found - 1]);
72
+ streak = found === at ? streak + 1 : 0;
73
+ score += 1 + streak * 2 + (boundary ? 3 : 0);
74
+ hits.push(found);
75
+ at = found + 1;
76
+ }
77
+ // Shorter candidates win ties: `/ask` should beat `/architect` for `ask`.
78
+ return { score: score - haystack.length * 0.05, hits };
79
+ }
80
+
81
+ function highlightMatch(text, hits, base, accent) {
82
+ const set = new Set(hits);
83
+ const spans = [];
84
+ let run = '';
85
+ let runHit = false;
86
+ [...String(text)].forEach((ch, index) => {
87
+ const hit = set.has(index);
88
+ if (hit !== runHit && run) { spans.push({ t: run, color: runHit ? accent : base, bold: runHit }); run = ''; }
89
+ runHit = hit;
90
+ run += ch;
91
+ });
92
+ if (run) spans.push({ t: run, color: runHit ? accent : base, bold: runHit });
93
+ return spans;
94
+ }
95
+
96
+ /**
97
+ * Rank one command against what has been typed.
98
+ *
99
+ * The name is matched first and scores highest, but the summary is searched too, because
100
+ * the name of a command is only obvious once you already know it — typing `context`
101
+ * finds `/compact`, and `diff` finds `/code-review`. A summary hit always scores below
102
+ * every name hit, so typing a real command's letters never buries it under something
103
+ * that merely mentions those letters in its description.
104
+ */
105
+ function scoreCommand(fragment, name, summary) {
106
+ const bare = name.replace(/^\//, '');
107
+ const byName = fuzzy(fragment, bare);
108
+ if (byName) return { score: byName.score + 100, hits: byName.hits.map((i) => i + 1), where: 'name' };
109
+ if (!summary || fragment.length < 2) return null;
110
+ // Word-prefix rather than subsequence on the summary: a loose subsequence over a
111
+ // sentence matches nearly everything, and a menu that matches everything is a menu
112
+ // that helps with nothing.
113
+ const words = summary.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
114
+ const needle = fragment.toLowerCase();
115
+ const at = words.findIndex((w) => w.startsWith(needle));
116
+ if (at === -1) return null;
117
+ return { score: 20 - at - (words[at].length - needle.length) * 0.1, hits: [], where: 'summary' };
118
+ }
119
+
120
+ function menuItemsFor(input, commands, fileProvider, cursor) {
121
+ const upto = input.slice(0, cursor);
122
+ const slash = /(?:^|\s)\/([^\s]*)$/.exec(upto);
123
+ if (slash) {
124
+ const fragment = slash[1];
125
+ return commands
126
+ .map((command) => {
127
+ const name = typeof command === 'string' ? command : command.name;
128
+ const summary = typeof command === 'string' ? '' : command.summary || '';
129
+ const match = scoreCommand(fragment, name, summary);
130
+ return match ? { kind: 'command', value: name, label: name, summary, score: match.score, hits: match.hits, where: match.where } : null;
131
+ })
132
+ .filter(Boolean)
133
+ .sort((a, b) => b.score - a.score)
134
+ .slice(0, 9);
135
+ }
136
+ const at = upto.lastIndexOf('@');
137
+ if (at >= 0 && !/\s/.test(upto.slice(at + 1))) {
138
+ const fragment = upto.slice(at + 1);
139
+ const files = fileProvider?.(fragment) || [];
140
+ return files
141
+ .map((file) => {
142
+ const match = fuzzy(fragment.split('/').pop() || '', String(file).split('/').pop() || String(file));
143
+ return { kind: 'file', value: file, label: String(file), summary: '', score: match ? match.score : 0, hits: [] };
144
+ })
145
+ .sort((a, b) => b.score - a.score)
146
+ .slice(0, 9);
147
+ }
148
+ return [];
149
+ }
150
+
151
+ // ─── Chrome ─────────────────────────────────────────────────────────────────────
152
+
153
+ function Header({ state, width }) {
154
+ const p = palette();
155
+ const m = state.meta;
156
+ const project = String(m.cwd || '.').split(/[\\/]/).filter(Boolean).pop() || '.';
157
+ const mode = MODE_LABEL[m.mode] || 'ask';
158
+ const modeColor = m.mode === 'bypassPermissions' ? p.red : m.mode === 'plan' ? p.yellow : m.mode === 'acceptEdits' ? p.teal : p.muted;
159
+ const narrow = width < 78;
160
+
161
+ // ── no mark in the header, and this is why ──
162
+ //
163
+ // The Jaitus J is a SQUARE mark. A half-block cell holds two square pixels, so a
164
+ // faithful square mark needs its height in pixel rows and half that in terminal rows:
165
+ // the real glyph, sampled from logo.png, degrades below about 6×6 and is properly
166
+ // itself at 8×8 — which is 8 cells by 4 rows, or 6 by 3 at a push. The header has one
167
+ // row for its title.
168
+ //
169
+ // Three marks were tried at one and two rows. Each was an abstraction of the logo
170
+ // rather than the logo, and each read as some other product's badge sitting next to
171
+ // this one's — correctly rejected. Half a square mark is not a small version of it.
172
+ //
173
+ // So the header carries the BRAND TOKEN instead: the coral chip with the J reversed
174
+ // out of it, which is the same mark the web header and the classic renderer use, and
175
+ // which is the one form of the logo that is honest at a single row — it is a colour
176
+ // field with a letter in it, not a squashed drawing of a square mark. Beside it the
177
+ // name in caps. Plain mixed-case `Jaitus` in the corner read as a window title; a
178
+ // coral field does not read as anything but a logo.
179
+ //
180
+ // The chip is also the only piece that survives every degradation — it is a space, a
181
+ // letter and a space, so it is not tofu on a raster console and not a box on a
182
+ // monochrome one. The real mark still lives where it has room: the welcome, and now
183
+ // the banner entry at the top of the transcript.
184
+ const ruleWidth = Math.max(4, width - 2);
185
+ // Below this the name is dropped and the chip carries it alone: the header's right
186
+ // side is controls, and squeezing six more characters in ahead of them is what makes
187
+ // Ink wrap the row and turn a two-row header into three.
188
+ const showName = width >= 62;
189
+
190
+ const left = h(Box, { flexDirection: 'row' },
191
+ h(Text, { color: '#ffffff', backgroundColor: p.brand, bold: true }, ' J '),
192
+ showName ? h(GradientText, { from: p.brandSoft, to: p.teal, bold: true }, ' JAITUS') : null,
193
+ h(Text, { color: p.faint }, ` ${g('▸', '/')} `),
194
+ h(Text, { color: p.muted }, project),
195
+ m.branch && width >= 78 ? h(Text, { color: p.faint }, ` ${g('⎇', 'b:')} ${m.branch}`) : null,
196
+ );
197
+
198
+ const right = h(Box, { flexDirection: 'row' },
199
+ h(Text, { color: modeColor, bold: true }, mode.toUpperCase()),
200
+ h(Text, { color: p.faint }, ' · '),
201
+ h(Text, { color: p.muted }, narrow ? shortModel(m.model) : String(m.model || 'auto')),
202
+ narrow ? null : h(Text, { color: p.faint }, ' · '),
203
+ narrow ? null : h(Meter, { value: m.ctxUsed || 0, max: m.ctxMax || 1, width: 8, danger: true }),
204
+ narrow ? null : h(Text, { color: p.dim }, ` ${m.ctx || '0 tok'}`),
205
+ );
206
+
207
+ return h(Box, { flexDirection: 'column', width },
208
+ h(Box, { flexDirection: 'row', paddingX: 1 }, left, h(Spacer), right),
209
+ // Idle, the hairline is not flat grey any more: it starts brand-tinted under the
210
+ // chip and fades into the line colour across the window. It is the cheapest way to
211
+ // make the header band belong to this product rather than to any terminal app —
212
+ // one row that was already being drawn, now carrying the identity.
213
+ h(Box, { paddingX: 1 },
214
+ h(FlowingRule, {
215
+ width: ruleWidth,
216
+ active: state.busy,
217
+ from: state.busy ? p.brand : mix(p.line, p.brand, 0.5),
218
+ to: state.busy ? p.teal : p.line,
219
+ })),
220
+ );
221
+ }
222
+
223
+ const shortModel = (id) => String(id || 'auto').split(/[/:]/).pop().slice(0, 18);
224
+
225
+ /**
226
+ * The fresh-session moment.
227
+ *
228
+ * Deliberately not a splash screen with a feature list: the wordmark, one line saying
229
+ * what this is, and three things you can type. Everything else the user needs is one
230
+ * `/` away and the menu will show it to them.
231
+ */
232
+ function Hero({ state, width, height }) {
233
+ const p = palette();
234
+
235
+ // ── the intro ──
236
+ //
237
+ // One pass, under a second, then it settles and stays settled. The pieces arrive in
238
+ // the order you should read them — the lockup, then what this is, then the three
239
+ // things you can type. Under reduced motion everything is simply already there.
240
+ const motion = motionOn();
241
+ const { time } = useAnimation({ interval: 40, isActive: motion && !state.introDone });
242
+ if (motion && time >= 900 && !state.introDone) state.introDone = true;
243
+ const elapsed = state.introDone ? 900 : time;
244
+ const phase = (from, to) => (motion && !state.introDone
245
+ ? Math.max(0, Math.min(1, (elapsed - from) / (to - from)))
246
+ : 1);
247
+
248
+ const markIn = phase(0, 420);
249
+ const lineIn = phase(280, 640);
250
+ const tipsIn = phase(420, 900);
251
+
252
+ // ── the lockup ──
253
+ //
254
+ // Mark and logotype on one baseline, left-aligned at exactly the indent transcript
255
+ // entries use, so the first answer appears directly beneath the branding with no
256
+ // jump. Both are four terminal rows: the mark is the DESIGNED mascot rather than the
257
+ // photographic badge downsampled, and the letterforms are drawn on a pixel grid and
258
+ // rendered with half-blocks — one cell per pixel is what made the old wordmark read
259
+ // as ASCII art, because a terminal cell is twice as tall as it is wide.
260
+ const mascot = MASCOT_ROWS();
261
+ const markInk = ramp(mix(p.bg, p.brandDeep, markIn), mix(p.bg, p.brand, markIn), mascot.length);
262
+ const mark = mascot.map((row, index) => [{ t: row, color: markInk[index] }]);
263
+ const markWidth = Math.max(...mascot.map((row) => row.length));
264
+
265
+ const word = wordRows('JAITUS', (column, total) =>
266
+ mix(p.bg, mix(p.brand, p.teal, total > 1 ? column / (total - 1) : 0), markIn));
267
+ const wordWidth = wordSize('JAITUS').width;
268
+
269
+ // Everything below is measured against what will fit, and dropped from the bottom up
270
+ // — the tips first, then the tagline — so a short window shows less rather than
271
+ // overflowing into the composer.
272
+ const showMark = width >= wordWidth + markWidth + 8;
273
+ const showWord = width >= wordWidth + 6;
274
+ const lockupRows = showWord ? word.length : 1;
275
+ const room = (rows) => height >= lockupRows + rows;
276
+
277
+ const tips = [
278
+ [`${g('›', '>')} `, 'describe a change', 'it reads the code before touching it'],
279
+ ['/ ', 'browse commands', 'research, review, commit, rewind'],
280
+ ['@ ', 'pull in a file', 'fuzzy-matched from this directory'],
281
+ ];
282
+
283
+ return h(Box, { flexDirection: 'column', width, height, paddingX: 1, justifyContent: 'flex-end' },
284
+ h(Box, { flexDirection: 'row' },
285
+ showMark ? h(Box, { marginRight: 2, flexDirection: 'column' }, h(Rows, { rows: mark })) : null,
286
+ showWord
287
+ ? h(Box, { flexDirection: 'column' }, h(Rows, { rows: word }))
288
+ : h(GradientText, { from: p.brand, to: p.teal, bold: true }, 'JAITUS'),
289
+ ),
290
+ room(3) ? h(Box, { marginTop: 1 },
291
+ h(Text, { color: mix(p.bg, p.dim, lineIn) },
292
+ `a coding agent in your terminal ${g('·', '-')} v${state.meta.version || ''}`)) : null,
293
+ room(8) ? h(Box, { marginTop: 1, flexDirection: 'column' },
294
+ tips.map(([marker, title, note], index) => {
295
+ const on = Math.max(0, Math.min(1, tipsIn * 3 - index));
296
+ return h(Box, { key: index, flexDirection: 'row' },
297
+ h(Text, { color: mix(p.bg, p.brand, on), bold: true }, marker),
298
+ h(Text, { color: mix(p.bg, p.text, on) }, title.padEnd(20)),
299
+ width >= 70 ? h(Text, { color: mix(p.bg, p.faint, on) }, note) : null,
300
+ );
301
+ })) : null,
302
+ room(10) ? h(Box, { marginTop: 1 },
303
+ h(Text, { color: mix(p.bg, p.faint, tipsIn), italic: true }, `tip: ${startupTip()}`)) : null,
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Lay the transcript out.
309
+ *
310
+ * Separate from the component that draws it, and called by `App` BEFORE any of its
311
+ * children render. Everything derived from the layout — the scroll ceiling, the screen
312
+ * map the drag handler reads, the click regions, the sticky prompt — has to be known
313
+ * before the chrome that displays it is rendered. Computing them inside the transcript
314
+ * component meant the header above it was drawn from the PREVIOUS frame's numbers, so
315
+ * the pinned prompt was always one scroll behind.
316
+ */
317
+ function layoutTranscript(state, width, height, top) {
318
+ const inner = Math.max(24, width - 2);
319
+ const rows = [];
320
+ // Where each prompt starts, so the sticky header can name the one whose answer is
321
+ // currently on screen rather than always the newest.
322
+ const prompts = [];
323
+ for (const entry of state.transcript) {
324
+ const built = rowsFor(entry, inner);
325
+ if (!built.length) continue;
326
+ if (rows.length) rows.push([]);
327
+ if (entry.kind === 'user') prompts.push({ at: rows.length, text: String(entry.data.text || '') });
328
+ rows.push(...built);
329
+ }
330
+ if (state.live && state.live.text) {
331
+ const built = rowsFor(state.live.entry, inner);
332
+ if (built.length) {
333
+ if (rows.length) rows.push([]);
334
+ rows.push(...built);
335
+ }
336
+ }
337
+
338
+ // The scroll ceiling. It was previously applied only where the rows were drawn, while
339
+ // the controller let the offset grow without bound — so scrolling up past the top kept
340
+ // incrementing a number nothing could see, and scrolling back down did nothing at all
341
+ // until it had counted its way home. That is the "scroll gets stuck" report: it was
342
+ // not stuck, it was somewhere off the top of a list that had already ended.
343
+ const maxOffset = Math.max(0, rows.length - height);
344
+ state.maxScroll = maxOffset;
345
+ const offset = state.viewingTranscript ? clamp(state.transcriptOffset, 0, maxOffset) : 0;
346
+ // Snap the stored offset to what will actually be drawn: this is the only place that
347
+ // knows how many rows exist at this width, so it is the authority, and writing the
348
+ // clamped value back is what keeps the controller's bound self-correcting.
349
+ state.transcriptOffset = offset;
350
+ if (!offset) state.viewingTranscript = false;
351
+
352
+ const end = rows.length - offset;
353
+ const first = Math.max(0, end - height);
354
+ const visible = rows.slice(first, end);
355
+ // Bottom-anchored: a short conversation sits at the composer, not floating at the top
356
+ // of an empty screen with the input a dozen rows below it.
357
+ const pad = Math.max(0, height - visible.length);
358
+ const screenTop = top + pad + 1;
359
+
360
+ // The drag handler needs the text under the pointer, and it must be the text actually
361
+ // on screen rather than the transcript's idea of it.
362
+ state.screen = { top: screenTop, left: 2, rows: visible };
363
+
364
+ // The sticky header names the prompt whose answer you are looking at — the last one
365
+ // that has scrolled off the top. Scroll far enough that the prompt itself is back on
366
+ // screen and there is nothing to pin; scroll past it and it becomes the one before.
367
+ const owning = prompts.filter((entry) => entry.at < first).at(-1);
368
+ state.stickyPrompt = owning ? owning.text : '';
369
+
370
+ return { visible, pad, screenTop };
371
+ }
372
+
373
+ /**
374
+ * How to select text, in words that are true for THIS terminal.
375
+ *
376
+ * Dragging selects inside the app everywhere and copies on release. What differs is the
377
+ * way back to the terminal's own selection while the app has the mouse: Shift in most
378
+ * terminals, Option in iTerm2 — and nothing at all in macOS Terminal, which has no
379
+ * bypass key. "shift+drag selects" was shown to everyone, and on a Mac it is simply
380
+ * wrong.
381
+ */
382
+ export function selectHint(env = process.env, platform = process.platform) {
383
+ const term = env.TERM_PROGRAM || '';
384
+ if (term === 'iTerm.app') return 'drag copies · ⌥ drag: native';
385
+ if (term === 'Apple_Terminal') return 'drag or double-click copies';
386
+ if (platform === 'darwin' && !term) return 'drag copies · ⌥ drag: native';
387
+ return 'drag copies · shift drag: native';
388
+ }
389
+
390
+ /** Register every URL on screen as a click target. */
391
+ function registerLinkHits(visible, screenTop, hits) {
392
+ if (!hits) return;
393
+ visible.forEach((row, index) => {
394
+ const text = rowText(row);
395
+ for (const match of text.matchAll(URL_IN_TEXT)) {
396
+ const url = match[0].replace(/[.,;:)\]]+$/, '');
397
+ hits.add({
398
+ x1: 2 + match.index,
399
+ x2: 2 + match.index + url.length - 1,
400
+ y1: screenTop + index,
401
+ y2: screenTop + index,
402
+ action: { type: 'link', url },
403
+ });
404
+ }
405
+ });
406
+ }
407
+
408
+ function Transcript({ state, width, height, layout }) {
409
+ const painted = paintSelection(layout.visible, state.selection, layout.screenTop, 2);
410
+ // Anchored to the bottom: if this box ever gets less room than it was laid out for,
411
+ // the OLDEST line is clipped off the top rather than the newest off the bottom.
412
+ return h(Box, { flexDirection: 'column', width, height, paddingX: 1, overflow: 'hidden', justifyContent: 'flex-end' },
413
+ layout.pad ? h(Box, { height: layout.pad, flexShrink: 0 }) : null,
414
+ h(Rows, { rows: painted }),
415
+ );
416
+ }
417
+
418
+ // Trailing punctuation is stripped by the caller: "see https://x.com/a." ends a sentence
419
+ // far more often than it names a path called `.`.
420
+ const URL_IN_TEXT = /https?:\/\/[^\s<>"'`]+/g;
421
+
422
+ /**
423
+ * Draw the drag selection.
424
+ *
425
+ * Mouse reporting takes the terminal's own highlight away, so if the app does not draw
426
+ * one there is no feedback at all while dragging — which is indistinguishable from
427
+ * selection being broken, because from the user's side it is.
428
+ */
429
+ function paintSelection(rows, selection, screenTop, left) {
430
+ if (!selection || !selection.anchor || !selection.focus) return rows;
431
+ const a = selection.anchor;
432
+ const b = selection.focus;
433
+ const [from, to] = (a.y < b.y || (a.y === b.y && a.x <= b.x)) ? [a, b] : [b, a];
434
+ if (from.y === to.y && from.x === to.x) return rows;
435
+
436
+ return rows.map((row, index) => {
437
+ const y = screenTop + index;
438
+ if (y < from.y || y > to.y) return row;
439
+ const startCol = y === from.y ? Math.max(0, from.x - left) : 0;
440
+ const endCol = y === to.y ? Math.max(0, to.x - left + 1) : rowText(row).length;
441
+ return inverseRange(row, startCol, endCol);
442
+ });
443
+ }
444
+
445
+ /**
446
+ * The line that proves the thing is alive.
447
+ *
448
+ * It carries four facts the user actually asks for while waiting: that work is
449
+ * happening, what it is, how long it has been, and how to stop it. The previous
450
+ * version carried only the first.
451
+ */
452
+ function WorkBar({ state, width }) {
453
+ const p = palette();
454
+ if (!state.busy && !state.status) return null;
455
+ const label = plain(state.status) || 'Working';
456
+ const narrow = width < 70;
457
+ return h(Box, { flexDirection: 'row', paddingX: 1 },
458
+ h(Spinner, { kind: ascii() ? 'ascii' : 'braille' }),
459
+ h(Text, null, ' '),
460
+ h(Shimmer, { from: p.brand, to: p.brandSoft }, truncatePlain(label, Math.max(12, width - (narrow ? 22 : 46)))),
461
+ h(Spacer),
462
+ state.turnStartedAt ? h(Elapsed, { since: state.turnStartedAt }) : null,
463
+ state.turnTokens ? h(Text, { color: p.faint }, ` ${state.turnTokens.toLocaleString()} tok`) : null,
464
+ narrow ? null : h(Text, { color: p.faint }, ` esc ${g('↩', '')}stop`),
465
+ );
466
+ }
467
+
468
+ const truncatePlain = (text, width) => (stringWidth(text) <= width ? text : `${String(text).slice(0, Math.max(1, width - 1))}…`);
469
+
470
+ function Menu({ state, width }) {
471
+ const p = palette();
472
+ if (!state.menu.length) return null;
473
+ const labelWidth = Math.min(28, Math.max(...state.menu.map((item) => stringWidth(item.label))) + 2);
474
+ return h(Box, { flexDirection: 'column', marginBottom: 1 },
475
+ state.menu.map((item, index) => {
476
+ const selected = index === state.menuIndex;
477
+ const spans = [
478
+ { t: selected ? `${g('▸', '>')} ` : ' ', color: p.brand, bold: true },
479
+ ...highlightMatch(item.label, item.hits || [], selected ? p.text : p.muted, p.brand),
480
+ ];
481
+ const used = stringWidth(item.label) + 2;
482
+ if (item.summary && width > 60) {
483
+ spans.push({ t: ' '.repeat(Math.max(1, labelWidth - used + 2)) });
484
+ spans.push({ t: item.summary, color: p.faint });
485
+ }
486
+ return h(Box, {
487
+ key: `${item.value}-${index}`,
488
+ backgroundColor: selected ? p.raised : undefined,
489
+ }, h(Row, { spans: truncateRow(spans, width - 2) }));
490
+ }),
491
+ );
492
+ }
493
+
494
+ function Composer({ state, width }) {
495
+ const p = palette();
496
+ const inner = Math.max(16, width - 4);
497
+ const bang = state.input.startsWith('!');
498
+ const promptMark = bang ? '! ' : `${g('›', '>')} `;
499
+ const promptColor = bang ? p.yellow : p.brand;
500
+
501
+ // A paste placeholder is a chip, not prose: it stands for content that is really
502
+ // there and will be sent, so it reads as an object rather than as something you typed
503
+ // and might have to delete.
504
+ const composed = [];
505
+ let at = 0;
506
+ for (const match of state.input.matchAll(/\[#\d+ pasted [^\]]*\]/g)) {
507
+ if (match.index > at) composed.push({ t: state.input.slice(at, match.index), color: p.text });
508
+ composed.push({ t: match[0], color: p.purple, backgroundColor: p.raised });
509
+ at = match.index + match[0].length;
510
+ }
511
+ if (at < state.input.length) composed.push({ t: state.input.slice(at), color: p.text });
512
+
513
+ // The same lines the App measured the composer's height from (composerLayout): the
514
+ // frame height is fixed, so a composer one row taller than it was counted as pushes
515
+ // the whole screen up — that was the header vanishing after a multi-line paste.
516
+ const { lines, caretLine } = composerLayout(state, inner - 2, composed);
517
+ const placeholder = state.placeholder || 'Describe a change, ask a question, or / for commands';
518
+
519
+ const borderColor = state.busy ? p.brand : state.menu.length ? p.teal : p.line;
520
+
521
+ return h(Box, {
522
+ flexDirection: 'column',
523
+ borderStyle: ascii() ? 'classic' : 'round',
524
+ borderColor,
525
+ paddingX: 1,
526
+ width,
527
+ },
528
+ h(Menu, { state, width: inner }),
529
+ h(Box, { flexDirection: 'row' },
530
+ h(Text, { color: promptColor, bold: true }, promptMark),
531
+ h(Box, { flexDirection: 'column', width: inner - 2 },
532
+ lines.map((row, index) => h(Box, { key: index, flexDirection: 'row' },
533
+ h(Row, { spans: row, wrap: 'wrap' }),
534
+ // The caret is always present, including at an empty prompt. Hiding it there
535
+ // was meant to keep it off the placeholder, but the result is a prompt with
536
+ // no cursor at all — there is nothing on screen saying the app is waiting for
537
+ // typing, which is the one thing a prompt has to say. The placeholder sits
538
+ // AFTER the caret instead, so the caret is at the insertion point either way.
539
+ index === caretLine ? h(Caret, { color: p.brand, char: g('▏', '_') }) : null,
540
+ !state.input && index === 0
541
+ ? h(Text, { color: p.faint, italic: true, wrap: 'truncate-end' }, placeholder)
542
+ : null,
543
+ )),
544
+ ),
545
+ ),
546
+ state.queued.length
547
+ ? h(Box, { marginTop: 1 }, h(Text, { color: p.purple }, `${g('⏎', '>')} ${state.queued.length} message${state.queued.length === 1 ? '' : 's'} queued — they send when this turn finishes`))
548
+ : null,
549
+ state.attachments.length
550
+ ? h(Box, { marginTop: 1 }, h(Text, { color: p.purple }, state.attachments.map((a) => `${g('⧉', '[img]')} ${a.name || 'image'}`).join(' ')))
551
+ : null,
552
+ );
553
+ }
554
+
555
+ const COMPOSER_MAX_LINES = 8;
556
+
557
+ /** Spans split at every newline: one array of spans per line the user typed. */
558
+ function splitSpansAtNewlines(spans) {
559
+ const out = [[]];
560
+ for (const span of spans) {
561
+ const parts = String(span.t).split('\n');
562
+ parts.forEach((part, i) => {
563
+ if (i > 0) out.push([]);
564
+ if (part) out[out.length - 1].push({ ...span, t: part });
565
+ });
566
+ }
567
+ return out;
568
+ }
569
+
570
+ /**
571
+ * The composer's lines as drawn: newlines are real line breaks (a pasted three-line
572
+ * snippet is three lines), each line wraps at `width`, and at most COMPOSER_MAX_LINES
573
+ * are shown — a window that follows the cursor. Returns the visible lines, the caret's
574
+ * line within them, and how many there are in total.
575
+ *
576
+ * Both the App (to size the frame) and the Composer (to draw) call this. They used to
577
+ * measure separately: the App's count treated a newline as a space and capped at 8, the
578
+ * Composer drew every line — so any newline or long input made the frame taller than
579
+ * the terminal, and the terminal scrolled.
580
+ */
581
+ function composerLayout(state, width, spans = null) {
582
+ const input = state.input || '';
583
+ if (!input) return { lines: [[]], caretLine: 0, total: 1 };
584
+ const paragraphs = splitSpansAtNewlines(spans || [{ t: input }]);
585
+ const all = [];
586
+ let caret = 0;
587
+ let start = 0; // offset of this paragraph in `input`
588
+ const cursor = Math.max(0, Math.min(input.length, state.cursor ?? input.length));
589
+ for (const para of paragraphs) {
590
+ const text = para.map((s) => s.t).join('');
591
+ const rows = para.length ? wrapRow(para, width) : [[]];
592
+ if (cursor >= start && cursor <= start + text.length) {
593
+ let seen = start;
594
+ let line = rows.length - 1;
595
+ for (let i = 0; i < rows.length; i++) {
596
+ seen += rows[i].map((s) => s.t).join('').length;
597
+ if (cursor <= seen) { line = i; break; }
598
+ seen += 1; // the space the wrap consumed
599
+ }
600
+ caret = all.length + line;
601
+ }
602
+ all.push(...rows);
603
+ start += text.length + 1; // + the newline
604
+ }
605
+ if (all.length <= COMPOSER_MAX_LINES) return { lines: all, caretLine: caret, total: all.length };
606
+ const first = Math.max(0, Math.min(caret - COMPOSER_MAX_LINES + 1, all.length - COMPOSER_MAX_LINES));
607
+ return { lines: all.slice(first, first + COMPOSER_MAX_LINES), caretLine: caret - first, total: all.length };
608
+ }
609
+
610
+ function StatusBar({ state, width }) {
611
+ const p = palette();
612
+ if (state.flash) {
613
+ const tone = { ok: p.teal, warn: p.yellow, info: p.muted }[state.flash.tone] || p.muted;
614
+ const mark = { ok: g('✓', '+'), warn: '!', info: g('›', '>') }[state.flash.tone] || g('›', '>');
615
+ return h(Box, { paddingX: 1 },
616
+ h(Text, { color: tone, bold: true }, `${mark} `),
617
+ h(Text, { color: tone, wrap: 'truncate-end' }, plain(state.flash.text)));
618
+ }
619
+ if (state.statusLine) {
620
+ return h(Box, { paddingX: 1 }, h(Text, { color: p.muted, wrap: 'truncate-end' }, plain(state.statusLine)));
621
+ }
622
+ const bits = state.viewingTranscript
623
+ ? [
624
+ [`${g('▲', '^')} history`, p.yellow],
625
+ [state.newMessages ? `${state.newMessages} new below` : 'scrolled back', p.muted],
626
+ ['ctrl+end to live', p.faint],
627
+ ]
628
+ : width < 70
629
+ ? [['ctrl+p palette', p.faint], ['shift+tab mode', p.faint]]
630
+ : [
631
+ ['ctrl+p palette', p.faint],
632
+ ['shift+tab mode', p.faint],
633
+ ['ctrl+l clear', p.faint],
634
+ ['esc esc rewind', p.faint],
635
+ // Mouse reporting takes the terminal's own text selection away, so the way to
636
+ // get it back has to be on screen — otherwise the first thing a new user
637
+ // notices is that they can no longer copy anything.
638
+ state.mouse ? [selectHint(), p.faint] : ['ctrl+d debug', p.faint],
639
+ ];
640
+ const spans = [];
641
+ bits.forEach(([text, color], index) => {
642
+ if (index) spans.push({ t: ` ${g('·', '|')} `, color: p.faint });
643
+ spans.push({ t: text, color });
644
+ });
645
+ const right = [];
646
+ if (state.spent?.turns) {
647
+ right.push({ t: `${state.spent.turns} turn${state.spent.turns === 1 ? '' : 's'}`, color: p.faint });
648
+ right.push({ t: ` ${g('·', '|')} `, color: p.faint });
649
+ right.push({ t: `${((state.spent.input || 0) + (state.spent.cached || 0) + (state.spent.output || 0)).toLocaleString()} tok`, color: p.faint });
650
+ }
651
+ // The counter on the right keeps its width; the hints get what is left, with a gap.
652
+ // Both used to shrink together, so the hints were cut off flush against the counter
653
+ // ("drag copi1 turn").
654
+ const rightWidth = right.reduce((n, s) => n + stringWidth(s.t), 0);
655
+ const leftRoom = Math.max(0, width - 2 - rightWidth - (rightWidth ? 3 : 0));
656
+ return h(Box, { flexDirection: 'row', paddingX: 1, width },
657
+ h(Row, { spans: truncateRow(spans, leftRoom) }),
658
+ h(Spacer),
659
+ rightWidth ? h(Box, { flexShrink: 0 }, h(Row, { spans: right })) : null,
660
+ );
661
+ }
662
+
663
+ // ─── Overlays ───────────────────────────────────────────────────────────────────
664
+
665
+ /**
666
+ * Exact height, so the box can be exactly centred.
667
+ *
668
+ * Ink will not tell you how tall a Box is until after it has laid it out, and an
669
+ * absolutely-positioned element has to be given its `top` before that happens. So the
670
+ * height is computed the same way Yoga will: borders, padding, and every row and margin
671
+ * in order. Guessing it — which is what a constant like `6 + options` was — puts the
672
+ * overlay a few rows off centre and the error grows with the content.
673
+ */
674
+ function dialogHeight(questionRows, previewRows, optionCount) {
675
+ return 2 // border top + bottom
676
+ + 2 // paddingY
677
+ + 1 // title
678
+ + 1 + questionRows
679
+ + (previewRows ? 1 + previewRows : 0)
680
+ + 1 + optionCount
681
+ + 1 + 1; // gap + hint
682
+ }
683
+
684
+ function Dialog({ state, width, height, hits }) {
685
+ const p = palette();
686
+ const dialog = state.dialog;
687
+ if (!dialog) return null;
688
+ const boxWidth = Math.min(Math.max(48, Math.floor(width * 0.78)), width - 4);
689
+ const previewRows = (dialog.preview || []).slice(0, 12);
690
+ const questionRows = wrapRow([{ t: plain(dialog.question) }], boxWidth - 6).length;
691
+ const rows = dialogHeight(questionRows, previewRows.length, dialog.options.length);
692
+ const accent = dialog.tone === 'danger' ? p.red : p.brand;
693
+
694
+ const top = Math.max(0, Math.round((height - rows) / 2));
695
+ const left = Math.max(0, Math.round((width - boxWidth) / 2));
696
+
697
+ // The whole box swallows clicks first, so nothing lands on the header behind it; the
698
+ // option rows are registered after and therefore win inside their own row. The
699
+ // arithmetic mirrors dialogHeight() exactly — terminal rows are 1-based, hence the +1.
700
+ hits?.add({ x1: left + 1, x2: left + boxWidth, y1: top + 1, y2: top + rows, action: { type: 'swallow' } });
701
+ const optionsTop = top + 2 + 1 + 1 + questionRows + (previewRows.length ? 1 + previewRows.length : 0) + 1;
702
+ dialog.options.forEach((option, index) => {
703
+ hits?.add({
704
+ x1: left + 1, x2: left + boxWidth, y1: optionsTop + index + 1, y2: optionsTop + index + 1,
705
+ action: { type: 'dialogOption', index },
706
+ });
707
+ });
708
+
709
+ return h(Box, { position: 'absolute', top, left, width: boxWidth },
710
+ h(Reveal, { rows },
711
+ h(Box, {
712
+ flexDirection: 'column',
713
+ width: boxWidth,
714
+ borderStyle: ascii() ? 'classic' : 'round',
715
+ borderColor: accent,
716
+ backgroundColor: p.surface,
717
+ paddingX: 2,
718
+ paddingY: 1,
719
+ },
720
+ h(Box, { flexDirection: 'row' },
721
+ h(Text, { color: accent, bold: true }, `${g('◆', '#')} ${dialog.title || 'Permission required'}`),
722
+ h(Spacer),
723
+ h(Text, { color: p.faint }, `${dialog.options.length} option${dialog.options.length === 1 ? '' : 's'}`),
724
+ ),
725
+ h(Box, { marginTop: 1, flexDirection: 'column' },
726
+ wrapRow([{ t: plain(dialog.question), color: p.text }], boxWidth - 6)
727
+ .map((row, index) => h(Row, { key: index, spans: row }))),
728
+ previewRows.length
729
+ ? h(Box, { marginTop: 1, flexDirection: 'column' },
730
+ previewRows.map((line, index) => h(Row, {
731
+ key: index,
732
+ spans: truncateRow(previewSpans(line, p), boxWidth - 6),
733
+ })))
734
+ : null,
735
+ h(Box, { marginTop: 1, flexDirection: 'column' },
736
+ dialog.options.map((option, index) => {
737
+ const selected = index === dialog.index;
738
+ return h(Box, { key: `${option.value}-${index}`, backgroundColor: selected ? p.raised : undefined },
739
+ h(Text, { color: selected ? accent : p.muted, bold: selected },
740
+ `${selected ? g(' ▸ ', ' > ') : ' '}${index + 1}. ${plain(option.label)}`));
741
+ })),
742
+ h(Box, { marginTop: 1 },
743
+ h(Text, { color: p.faint }, `${g('↑↓', 'up/dn')} choose · 1-9 jump · enter confirm · esc ${dialog.hideComment ? 'cancel' : 'deny'}`)),
744
+ ),
745
+ ),
746
+ );
747
+ }
748
+
749
+ function previewSpans(line, p) {
750
+ const text = plain(line);
751
+ if (text.startsWith('+')) return [{ t: text, color: p.addFg, backgroundColor: p.addBg }];
752
+ if (text.startsWith('-')) return [{ t: text, color: p.delFg, backgroundColor: p.delBg }];
753
+ if (text.startsWith('@@')) return [{ t: text, color: p.hunkFg }];
754
+ return [{ t: text, color: p.muted }];
755
+ }
756
+
757
+ function Panel({ state, width, height, hits }) {
758
+ const p = palette();
759
+ const panel = state.panel;
760
+ if (!panel) return null;
761
+ const boxWidth = Math.min(Math.max(52, Math.floor(width * 0.84)), width - 4);
762
+ // Leave room for the box's own chrome (8 rows) plus a margin, and never grow past
763
+ // what the content needs — a palette with four matches should be four rows tall, not
764
+ // a half-screen box with an empty lower half.
765
+ const maxRows = Math.max(4, Math.min(height - 10, 18));
766
+
767
+ let body;
768
+ let bodyRows;
769
+ let firstVisible = 0;
770
+ if (panel.palette) {
771
+ // Keep the selection in view without letting the window run past either end.
772
+ const visible = Math.min(maxRows, panel.options.length);
773
+ firstVisible = Math.max(0, Math.min(panel.index - Math.floor(visible / 2), panel.options.length - visible));
774
+ const view = panel.options.slice(firstVisible, firstVisible + visible);
775
+ bodyRows = Math.max(1, view.length);
776
+ body = view.map((option, index) => {
777
+ const globalIndex = firstVisible + index;
778
+ const selected = globalIndex === panel.index;
779
+ const name = typeof option === 'string' ? option : option.name;
780
+ const summary = typeof option === 'string' ? '' : option.summary || '';
781
+ return h(Box, { key: `${name}-${index}`, backgroundColor: selected ? p.raised : undefined, flexDirection: 'row' },
782
+ h(Text, { color: selected ? p.brand : p.muted, bold: selected }, `${selected ? g(' ▸ ', ' > ') : ' '}${name.padEnd(Math.min(26, boxWidth - 10))}`),
783
+ summary ? h(Text, { color: p.faint, wrap: 'truncate-end' }, summary) : null,
784
+ );
785
+ });
786
+ if (!view.length) {
787
+ body = [h(Text, { key: 'none', color: p.faint, italic: true }, `nothing matches “${panel.filter || ''}”`)];
788
+ bodyRows = 1;
789
+ }
790
+ } else {
791
+ const rows = (panel.rows || []).slice(-maxRows);
792
+ const lines = (panel.lines || []).slice(-maxRows);
793
+ bodyRows = Math.max(1, rows.length || lines.length);
794
+ body = rows.length
795
+ ? [h(Rows, { key: 'rows', rows })]
796
+ : lines.map((line, index) => h(Text, { key: index, color: p.text, wrap: 'truncate-end' }, plain(line)));
797
+ }
798
+
799
+ // border 2 + padY 2 + title 1 + gap 1 + body + gap 1 + hint 1
800
+ const rows = 8 + bodyRows;
801
+ const top = Math.max(0, Math.round((height - rows) / 2));
802
+ const left = Math.max(0, Math.round((width - boxWidth) / 2));
803
+
804
+ hits?.add({ x1: left + 1, x2: left + boxWidth, y1: top + 1, y2: top + rows, action: { type: 'swallow' } });
805
+ if (panel.palette) {
806
+ const bodyTop = top + 2 + 1 + 1;
807
+ for (let i = 0; i < bodyRows; i++) {
808
+ hits?.add({
809
+ x1: left + 1, x2: left + boxWidth, y1: bodyTop + i + 1, y2: bodyTop + i + 1,
810
+ action: { type: 'paletteRow', index: firstVisible + i },
811
+ });
812
+ }
813
+ }
814
+
815
+ return h(Box, { position: 'absolute', top, left, width: boxWidth },
816
+ h(Reveal, { rows },
817
+ h(Box, {
818
+ flexDirection: 'column',
819
+ width: boxWidth,
820
+ borderStyle: ascii() ? 'classic' : 'round',
821
+ borderColor: p.teal,
822
+ backgroundColor: p.surface,
823
+ paddingX: 2,
824
+ paddingY: 1,
825
+ },
826
+ h(Box, { flexDirection: 'row' },
827
+ h(Text, { color: p.teal, bold: true }, plain(panel.title)),
828
+ h(Spacer),
829
+ panel.palette ? h(Text, { color: p.faint }, `${panel.options.length}`) : null,
830
+ ),
831
+ h(Box, { marginTop: 1, flexDirection: 'column' }, body),
832
+ h(Box, { marginTop: 1 }, h(Text, { color: p.faint }, panel.palette
833
+ ? `type to filter · ${g('↑↓', 'up/dn')} choose · enter run · esc close`
834
+ : `${g('↑↓', 'up/dn')} scroll · esc close`)),
835
+ ),
836
+ ),
837
+ );
838
+ }
839
+
840
+ // ─── The app ────────────────────────────────────────────────────────────────────
841
+
842
+ function App({ controller }) {
843
+ // Ink's hook answers in `{columns, rows}`, not `{width, height}`. Destructuring the
844
+ // wrong names yields two `undefined`s that propagate into every wrap and clamp as
845
+ // NaN — comparisons against NaN are all false, so the layout does not throw, it just
846
+ // silently breaks one word per line. Worth naming, because nothing in the output
847
+ // points at the hook.
848
+ const size = useWindowSize();
849
+ const width = Math.max(30, size?.columns || process.stdout.columns || 80);
850
+ // One row short of the terminal, on purpose. A frame that fills the terminal exactly
851
+ // puts Ink on its Windows path of clearing the whole screen before EVERY frame (ink.js
852
+ // shouldClearTerminalForFrame), and ConPTY — which Windows Terminal also sits on —
853
+ // commits that clear as its own synchronized frame: measured in a real ConPTY, the
854
+ // screen went blank and was redrawn ~9 times a second while a reply streamed, and on
855
+ // every keystroke. One row shorter, Ink redraws in place and only what changed.
856
+ const height = Math.max(10, (size?.rows || process.stdout.rows || 24) - 1);
857
+ const { exit } = useApp();
858
+ const [, setTick] = React.useState(0);
859
+ const p = palette();
860
+
861
+ React.useEffect(() => {
862
+ controller.attach(setTick);
863
+ return () => controller.attach(() => {});
864
+ }, [controller]);
865
+
866
+ useCursor({ visible: false });
867
+ useInput((input, key) => controller.handleKey(input, key, exit));
868
+ // Bracketed paste. Without it a pasted block arrives as ordinary input, and the first
869
+ // newline in it SUBMITS — so pasting a stack trace sent its first line and left the
870
+ // rest in the box. Ink keeps paste off the useInput channel entirely while this is
871
+ // mounted, so the two cannot race.
872
+ usePaste((text) => controller.handlePaste(text));
873
+
874
+ // A resize invalidates anything expressed in screen coordinates. The transcript
875
+ // re-wraps at the new width, so a selection made at the old one now highlights
876
+ // unrelated text, and the hit map is rebuilt from scratch on the next frame anyway.
877
+ React.useEffect(() => { controller.onResize(width, height); }, [controller, width, height]);
878
+
879
+ const state = controller.state;
880
+ // One empty frame after a resize — see onResize.
881
+ if (state.blankFrame) return h(Box, { height: 1 });
882
+ const composerRows = CHROME.composerFrame
883
+ + (state.menu.length ? state.menu.length + 1 : 0)
884
+ + Math.max(0, composerTextRows(state, width) - 1)
885
+ + (state.queued.length ? 2 : 0)
886
+ + (state.attachments.length ? 2 : 0);
887
+ const workRows = state.busy || state.status ? 1 : 0;
888
+ // A single pinned line showing what was last asked, while the transcript is scrolled
889
+ // away from it. Scrolling back through a long answer and losing track of the question
890
+ // it was answering is the specific thing this fixes.
891
+ const stickyRows = state.viewingTranscript ? 1 : 0;
892
+ const transcriptHeight = Math.max(3, height - CHROME.header - CHROME.status - composerRows - workRows - CHROME.gapAboveComposer - stickyRows);
893
+
894
+ // The welcome stands until there is real conversation. Anything chrome-ish — a
895
+ // notice, a stray pre-formatted print from an older code path — must not suppress it:
896
+ // one such line was enough to make the whole welcome screen, logo included, invisible,
897
+ // and nothing about the result suggested why.
898
+ const CONVERSATION = new Set(['user', 'assistant', 'tool', 'diff', 'todo', 'error']);
899
+ const showHero = state.home && !state.live && !state.transcript.some((e) => CONVERSATION.has(e.kind));
900
+
901
+ // Immediate-mode hit testing: the regions are rebuilt from the same numbers that
902
+ // produce the layout, every frame. Anything measured after the fact would be a second
903
+ // source of truth for where things are, and the two would drift.
904
+ const hits = controller.hits;
905
+ hits.reset();
906
+ const transcriptTop = CHROME.header + stickyRows; // 0-based row above the transcript
907
+ // Laid out here, before anything renders, so the header above the transcript is drawn
908
+ // from THIS frame's numbers rather than the previous one's.
909
+ const layout = showHero
910
+ ? { visible: [], pad: 0, screenTop: transcriptTop + 1 }
911
+ : layoutTranscript(state, width, transcriptHeight, transcriptTop);
912
+ hits.add({ x1: 1, x2: width, y1: transcriptTop + 1, y2: transcriptTop + transcriptHeight, action: { type: 'transcript' } });
913
+ registerLinkHits(layout.visible, layout.screenTop, hits);
914
+ registerHeaderHits(state, width, hits);
915
+ hits.add({ x1: 1, x2: width, y1: height, y2: height, action: { type: 'status' } });
916
+ if (state.menu.length) {
917
+ const menuTop = height - CHROME.status - composerRows + 1; // inside the composer border
918
+ state.menu.forEach((_item, index) => {
919
+ hits.add({ x1: 2, x2: width - 1, y1: menuTop + index + 1, y2: menuTop + index + 1, action: { type: 'menuRow', index } });
920
+ });
921
+ }
922
+
923
+ // Everything but the transcript keeps its size (flexShrink 0); the transcript is the
924
+ // one part allowed to give way if the chrome ever measures taller than counted.
925
+ const fixed = (child) => h(Box, { flexShrink: 0, flexDirection: 'column' }, child);
926
+ return h(Box, { flexDirection: 'column', width: '100%', height },
927
+ fixed(h(Header, { state, width })),
928
+ stickyRows ? fixed(h(StickyPrompt, { state, width })) : null,
929
+ showHero
930
+ ? h(Hero, { state, width, height: transcriptHeight })
931
+ : h(Transcript, { state, width, height: transcriptHeight, layout }),
932
+ h(Box, { height: CHROME.gapAboveComposer, flexShrink: 0 }),
933
+ fixed(h(WorkBar, { state, width })),
934
+ h(Box, { paddingX: 1, flexShrink: 0 }, h(Composer, { state, width: width - 2 })),
935
+ fixed(h(StatusBar, { state, width })),
936
+ h(Panel, { state, width, height, hits }),
937
+ h(Dialog, { state, width, height, hits }),
938
+ );
939
+ }
940
+
941
+ function composerTextRows(state, width) {
942
+ // The Composer is given width - 2 and draws text in (that - 4) - 2: width - 8.
943
+ return composerLayout(state, Math.max(16, width - 6) - 2).lines.length;
944
+ }
945
+
946
+ /**
947
+ * The prompt currently being answered on screen.
948
+ *
949
+ * Published by the transcript each frame rather than read from the end of the list: the
950
+ * useful line while scrolling back through a long session is the question THIS part of
951
+ * the conversation was answering, not whatever was asked most recently. Scroll far
952
+ * enough up and it becomes the previous exchange's question; scroll to where the prompt
953
+ * itself is visible and there is nothing to pin.
954
+ */
955
+ function StickyPrompt({ state, width }) {
956
+ const p = palette();
957
+ const text = String(state.stickyPrompt || '').replace(/\s+/g, ' ').trim();
958
+ return h(Box, { flexDirection: 'row', paddingX: 1, width, backgroundColor: p.raised },
959
+ h(Text, { color: p.brand, bold: true }, `${g('▌', '|')} `),
960
+ h(Text, { color: text ? p.muted : p.faint, wrap: 'truncate-end' },
961
+ text || 'the start of this conversation'),
962
+ h(Spacer),
963
+ h(Text, { color: p.faint }, ` ${g('↑', '^')} scrolled`),
964
+ );
965
+ }
966
+
967
+ /**
968
+ * The header's right-hand side is a row of controls, not a readout — the mode, the
969
+ * model and the context meter all do something when clicked.
970
+ *
971
+ * The columns are computed from the same strings the header renders, walking right to
972
+ * left from the edge, so a click lands on the thing it looks like it is on. Anything
973
+ * here that stops matching Header() shows up immediately as a click that misses — which
974
+ * is why tests/tui-render.js slices the rendered row at these exact columns.
975
+ */
976
+ function registerHeaderHits(state, width, hits) {
977
+ const m = state.meta;
978
+ const narrow = width < 78;
979
+ const mode = MODE_LABEL[m.mode] || 'ask';
980
+ const model = narrow ? shortModel(m.model) : String(m.model || 'auto');
981
+
982
+ let right = width - 1; // 1-based column of the last cell (paddingX 1)
983
+ if (!narrow) {
984
+ const meter = 8 + 1 + stringWidth(m.ctx || '0 tok');
985
+ hits.add({ x1: right - meter + 1, x2: right, y1: 1, y2: 1, action: { type: 'context' } });
986
+ right -= meter + 5; // the ' · ' separator
987
+ }
988
+ hits.add({ x1: right - stringWidth(model) + 1, x2: right, y1: 1, y2: 1, action: { type: 'model' } });
989
+ right -= stringWidth(model) + 5;
990
+ hits.add({ x1: right - mode.length + 1, x2: right, y1: 1, y2: 1, action: { type: 'mode' } });
991
+ }
992
+
993
+ // ─── Controller ─────────────────────────────────────────────────────────────────
994
+
995
+ export function createTui({ slashCommands = [], fileProvider = null, meta = {}, onModeCycle = null, settings = null } = {}) {
996
+ publishSettings(settings);
997
+
998
+ const commands = slashCommands.map((command) => (typeof command === 'string' ? { name: command, summary: '' } : command));
999
+
1000
+ const state = {
1001
+ active: false, busy: false, home: true,
1002
+ input: '', cursor: 0, placeholder: '',
1003
+ menu: [], menuIndex: -1,
1004
+ history: [], historyIndex: -1, draft: null,
1005
+ queued: [], attachments: [],
1006
+ status: '', statusLine: '',
1007
+ // A short notice — "Copied 40 characters", "Opened <link>" — shown on the status
1008
+ // line for a moment. Deliberately NOT `status`: that is the live working status the
1009
+ // work bar draws with a spinner, so a notice put there looked like a task running,
1010
+ // and clearing it could wipe the real status of a turn in flight.
1011
+ flash: null,
1012
+ transcript: [], live: null, activity: [],
1013
+ transcriptOffset: 0, newMessages: 0, viewingTranscript: false,
1014
+ dialog: null, panel: null, mouse: false, selection: null, screen: null, maxScroll: 0, pastes: [],
1015
+ onLine: null, onEscape: null, onRewind: null, onCommand: null, onCopy: null, onModeCycle,
1016
+ debug: !!process.env.JAITUS_DEBUG, introDone: false,
1017
+ turnStartedAt: null, turnTokens: 0,
1018
+ spent: { turns: 0, input: 0, cached: 0, output: 0 },
1019
+ meta: { model: 'loading…', mode: 'default', ctx: '0 tok', ctxUsed: 0, ctxMax: 1, intent: 'build', ...meta },
1020
+ };
1021
+
1022
+ let inkInstance = null;
1023
+ let update = () => {};
1024
+ let pending = null;
1025
+ let exited = false;
1026
+ const hits = createHitMap();
1027
+ const scrollStep = () => {
1028
+ const n = Number(settings?.scrollSpeed);
1029
+ return Math.max(1, Math.round((Number.isFinite(n) && n > 0 ? n : 1) * 3));
1030
+ };
1031
+
1032
+ /**
1033
+ * Coalesce notifications to one per animation frame.
1034
+ *
1035
+ * A streaming response calls print thousands of times a second. Re-rendering per
1036
+ * chunk pins a core and makes typing lag behind the keyboard; batching at 16ms costs
1037
+ * nothing visually and keeps input responsive under a full-speed stream.
1038
+ */
1039
+ function notify() {
1040
+ if (pending) return;
1041
+ pending = setTimeout(() => { pending = null; update((value) => value + 1); }, 16);
1042
+ if (pending.unref) pending.unref();
1043
+ }
1044
+ function notifyNow() {
1045
+ if (pending) { clearTimeout(pending); pending = null; }
1046
+ update((value) => value + 1);
1047
+ }
1048
+
1049
+ /**
1050
+ * The brand lockup, as transcript row zero.
1051
+ *
1052
+ * Deliberately an ENTRY rather than chrome. It scrolls like anything else, so it is
1053
+ * never sticky and never costs a row of the working area — and because it is the
1054
+ * first thing in the scrollback it is still there at the top of a thousand-turn
1055
+ * session. The welcome screen owns the same lockup for the fresh-session moment; this
1056
+ * is what keeps it on screen once real conversation replaces that.
1057
+ */
1058
+ function seedBanner() {
1059
+ if (process.env.JAITUS_NO_BANNER) return;
1060
+ if (state.transcript[0]?.kind === 'banner') return;
1061
+ state.transcript.unshift(makeEntry('banner', { version: state.meta.version }));
1062
+ }
1063
+
1064
+ let flashTimer = null;
1065
+ /** Show a notice on the status line for `ms`, then take it down — with a redraw. */
1066
+ function flash(text, tone = 'ok', ms = 1600) {
1067
+ if (flashTimer) clearTimeout(flashTimer);
1068
+ state.flash = { text: String(text), tone };
1069
+ notifyNow();
1070
+ flashTimer = setTimeout(() => {
1071
+ flashTimer = null;
1072
+ state.flash = null;
1073
+ // notifyNow, not a bare assignment: the old clear changed state without asking
1074
+ // for a redraw, so the notice stayed on screen until something else repainted.
1075
+ notifyNow();
1076
+ }, ms);
1077
+ if (flashTimer.unref) flashTimer.unref();
1078
+ }
1079
+
1080
+ function push(kind, data) {
1081
+ const entry = makeEntry(kind, data);
1082
+ state.transcript.push(entry);
1083
+ // A transcript is not a log: 600 entries is far more than anyone scrolls back
1084
+ // through, and every one held is rows cached against the current width. The banner
1085
+ // is exempt — trimming from the front would quietly delete the brand off the top of
1086
+ // any long session, which is the exact failure this entry exists to fix.
1087
+ if (state.transcript.length > 600) {
1088
+ const keep = state.transcript[0]?.kind === 'banner' ? 1 : 0;
1089
+ state.transcript.splice(keep, state.transcript.length - 600);
1090
+ }
1091
+ if (state.viewingTranscript) state.newMessages += 1;
1092
+ notify();
1093
+ return entry;
1094
+ }
1095
+
1096
+ function refreshMenu() {
1097
+ state.menu = state.dialog || state.panel ? [] : menuItemsFor(state.input, commands, fileProvider, state.cursor);
1098
+ state.menuIndex = state.menu.length ? clamp(state.menuIndex < 0 ? 0 : state.menuIndex, 0, state.menu.length - 1) : -1;
1099
+ }
1100
+
1101
+ function insert(text) {
1102
+ state.input = state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor);
1103
+ state.cursor += text.length;
1104
+ refreshMenu();
1105
+ notifyNow();
1106
+ }
1107
+
1108
+ function clearInput() {
1109
+ state.input = '';
1110
+ state.cursor = 0;
1111
+ state.menu = [];
1112
+ state.menuIndex = -1;
1113
+ notifyNow();
1114
+ }
1115
+
1116
+ /**
1117
+ * Take the highlighted suggestion.
1118
+ *
1119
+ * `run` submits it instead of completing it, which is what Enter does on a command:
1120
+ * typing `/re`, pressing Enter and having the composer fill in `/review ` so you can
1121
+ * press Enter a second time is a completion, not a shortcut. Tab still completes, for
1122
+ * when the command needs an argument. A file suggestion is never run — `@` is always
1123
+ * building up a message.
1124
+ */
1125
+ function chooseMenu({ run = false } = {}) {
1126
+ const item = state.menu[state.menuIndex];
1127
+ if (!item) return false;
1128
+ const trigger = item.kind === 'command' ? '/' : '@';
1129
+ const at = state.input.lastIndexOf(trigger, Math.max(0, state.cursor - 1));
1130
+ const prefix = at >= 0 ? state.input.slice(0, at) : '';
1131
+ const suffix = state.input.slice(state.cursor);
1132
+
1133
+ if (run && item.kind === 'command' && !prefix.trim() && !suffix.trim()) {
1134
+ state.input = String(item.value);
1135
+ state.cursor = state.input.length;
1136
+ state.menu = [];
1137
+ state.menuIndex = -1;
1138
+ submit();
1139
+ return true;
1140
+ }
1141
+
1142
+ state.input = `${prefix}${item.value} ${suffix}`;
1143
+ state.cursor = prefix.length + String(item.value).length + 1;
1144
+ refreshMenu();
1145
+ notifyNow();
1146
+ return true;
1147
+ }
1148
+
1149
+ /**
1150
+ * A paste.
1151
+ *
1152
+ * Short pastes go in as text — that is what you meant, and a chip for eleven
1153
+ * characters is a chip in the way. Anything long becomes a placeholder holding its
1154
+ * place in the sentence, with the real content kept aside and substituted back when
1155
+ * the message is sent. The alternative is a composer that has grown to forty rows and
1156
+ * hidden the conversation you were pasting INTO.
1157
+ */
1158
+ function handlePaste(text) {
1159
+ const value = String(text ?? '').replace(/\r\n?/g, '\n');
1160
+ if (!value) return;
1161
+ const lines = value.split('\n').length;
1162
+
1163
+ if (lines <= 3 && value.length <= 240) {
1164
+ insert(value);
1165
+ return;
1166
+ }
1167
+
1168
+ const id = state.pastes.length + 1;
1169
+ state.pastes.push({ id, text: value });
1170
+ // The token has to survive being typed around, so it is bracketed and carries its
1171
+ // own index; expansion matches on that rather than on position.
1172
+ insert(`[#${id} pasted ${lines} line${lines === 1 ? '' : 's'}, ${value.length} chars]`);
1173
+ }
1174
+
1175
+ /**
1176
+ * Paste from the system clipboard — right-click and Ctrl+V. The app owns the mouse, so
1177
+ * the terminal's own right-click paste never runs; without this, right-click did
1178
+ * nothing at all. Text goes through handlePaste (so a long paste becomes a chip); with
1179
+ * no text, an image on the clipboard becomes an attachment.
1180
+ */
1181
+ let pasting = false;
1182
+ async function pasteFromClipboard({ imageOnly = false } = {}) {
1183
+ if (pasting) return;
1184
+ pasting = true;
1185
+ try {
1186
+ const text = imageOnly ? null : await (api._readClipboardText || readClipboardText)();
1187
+ if (text) { handlePaste(text); notifyNow(); return; }
1188
+ const png = await (api._readClipboardImage || readClipboardImage)();
1189
+ if (png?.length) {
1190
+ const fs = await import('node:fs');
1191
+ const os = await import('node:os');
1192
+ const path = await import('node:path');
1193
+ const file = path.join(os.tmpdir(), `jaitus-clip-${Date.now()}.png`);
1194
+ fs.writeFileSync(file, png);
1195
+ const { imageBlock } = await import('./agent/tools.js');
1196
+ const block = await imageBlock(file).catch(() => null);
1197
+ if (block) {
1198
+ state.attachments.push({ name: `image ${state.attachments.length + 1}`, block });
1199
+ flash('Image attached — it is sent with your next message', 'info', 2000);
1200
+ } else {
1201
+ flash('That image could not be read (png, jpg, gif or webp, up to about 3 MB)', 'warn', 2500);
1202
+ }
1203
+ notifyNow();
1204
+ return;
1205
+ }
1206
+ flash(imageOnly ? 'No image on the clipboard' : 'The clipboard is empty', 'info', 1600);
1207
+ } catch {
1208
+ flash('Could not read the clipboard', 'warn', 2000);
1209
+ } finally {
1210
+ pasting = false;
1211
+ }
1212
+ }
1213
+
1214
+ // ── a paste chip is one thing ──
1215
+ //
1216
+ // It looks like a single object, so it has to behave like one. Backspacing a character
1217
+ // off the end leaves `[#1 pasted 120 lines, 969 char` — a token that no longer matches,
1218
+ // so the content it stands for is silently orphaned and the literal text is what gets
1219
+ // sent. Deleting it whole is both what you meant and the only way the placeholder and
1220
+ // the thing it holds cannot come apart.
1221
+ const CHIP = /\[#(\d+) pasted [^\]]*\]/;
1222
+ const chipBefore = (text, cursor) => new RegExp(`${CHIP.source}$`).exec(text.slice(0, cursor));
1223
+ const chipAfter = (text, cursor) => new RegExp(`^${CHIP.source}`).exec(text.slice(cursor));
1224
+
1225
+ /** Forget the content a chip stood for, once the chip is gone. */
1226
+ function dropPaste(id) {
1227
+ state.pastes = state.pastes.filter((entry) => entry.id !== Number(id));
1228
+ }
1229
+
1230
+ /** Put the real content back in place of every placeholder. */
1231
+ function expandPastes(line) {
1232
+ if (!state.pastes.length) return line;
1233
+ return line.replace(/\[#(\d+) pasted [^\]]*\]/g, (whole, id) => {
1234
+ const blob = state.pastes.find((entry) => entry.id === Number(id));
1235
+ return blob ? blob.text : whole;
1236
+ });
1237
+ }
1238
+
1239
+ function submit() {
1240
+ const typed = state.input;
1241
+ const line = expandPastes(typed);
1242
+ state.pastes = [];
1243
+ clearInput();
1244
+ if (!line.trim()) return;
1245
+ // History keeps the TOKEN form. Pressing Up after sending a thousand-line paste
1246
+ // should bring back the sentence you wrote, not the thousand lines.
1247
+ if (state.history.at(-1) !== typed) state.history.push(typed);
1248
+ state.historyIndex = state.history.length;
1249
+ state.viewingTranscript = false;
1250
+ state.transcriptOffset = 0;
1251
+ state.newMessages = 0;
1252
+ if (state.busy) state.queued.push(line);
1253
+ else state.onLine?.(line);
1254
+ notifyNow();
1255
+ }
1256
+
1257
+ function historyMove(direction) {
1258
+ if (!state.history.length) return;
1259
+ if (state.historyIndex === state.history.length) state.draft = state.input;
1260
+ state.historyIndex = clamp(state.historyIndex + direction, 0, state.history.length);
1261
+ state.input = state.historyIndex === state.history.length ? (state.draft || '') : state.history[state.historyIndex];
1262
+ state.cursor = state.input.length;
1263
+ refreshMenu();
1264
+ notifyNow();
1265
+ }
1266
+
1267
+ function wordLeft() {
1268
+ const upto = state.input.slice(0, state.cursor);
1269
+ const match = /\S+\s*$/.exec(upto);
1270
+ return match ? state.cursor - match[0].length : 0;
1271
+ }
1272
+ function wordRight() {
1273
+ const rest = state.input.slice(state.cursor);
1274
+ const match = /^\s*\S+/.exec(rest);
1275
+ return match ? state.cursor + match[0].length : state.input.length;
1276
+ }
1277
+
1278
+ function resolveDialog(value) {
1279
+ const dialog = state.dialog;
1280
+ if (!dialog) return;
1281
+ state.dialog = null;
1282
+ refreshMenu();
1283
+ dialog.resolve(value);
1284
+ notifyNow();
1285
+ }
1286
+
1287
+ function resolvePanel() {
1288
+ const panel = state.panel;
1289
+ if (!panel) return;
1290
+ state.panel = null;
1291
+ refreshMenu();
1292
+ panel.resolve?.();
1293
+ notifyNow();
1294
+ }
1295
+
1296
+ let lastEscapeAt = 0;
1297
+ // Drag state. A press is ambiguous until release: it may be a click on a control or
1298
+ // the start of a text selection, and treating it as either one too early breaks the
1299
+ // other.
1300
+ let pressedAt = null;
1301
+ let dragged = false;
1302
+ // Counted so /debug can distinguish "mouse reporting was requested" from "the
1303
+ // terminal is actually sending reports". Those are different facts and only the
1304
+ // second one means the wheel works.
1305
+ let mouseReports = 0;
1306
+ let lastMouseAt = 0;
1307
+ // Half a mouse report, waiting for the rest (see isMouseFragment).
1308
+ let mouseFragment = null;
1309
+ // Double- and triple-click: select a word, then a line — what every terminal does,
1310
+ // and the first thing people try when they want one word out of the output.
1311
+ let lastClick = null;
1312
+ let clickCount = 0;
1313
+
1314
+ /**
1315
+ * Scroll the transcript. Positive is back into history.
1316
+ *
1317
+ * Clamped at BOTH ends against the real row count, which the transcript publishes
1318
+ * each frame. Previously only the renderer clamped it: the offset itself grew without
1319
+ * limit, so scrolling up past the top kept counting into a void and scrolling back
1320
+ * down did nothing visible until it had counted all the way back. It read as the
1321
+ * wheel sticking, at random, for a random length of time.
1322
+ */
1323
+ function scroll(rows) {
1324
+ // The ceiling comes from the last frame. On the very first scroll after new content
1325
+ // there has not been one yet, so a stale 0 would swallow the gesture — allow the
1326
+ // move and let the renderer snap it back. It is authoritative; this is a hint.
1327
+ const known = Math.max(0, state.maxScroll || 0);
1328
+ const ceiling = known || (state.transcript.length ? Number.MAX_SAFE_INTEGER : 0);
1329
+ const next = clamp(state.transcriptOffset + rows, 0, ceiling);
1330
+ if (next === state.transcriptOffset) {
1331
+ if (!next && state.viewingTranscript) { state.viewingTranscript = false; state.newMessages = 0; notifyNow(); }
1332
+ return;
1333
+ }
1334
+ state.transcriptOffset = next;
1335
+ state.viewingTranscript = next > 0;
1336
+ if (!next) state.newMessages = 0;
1337
+ notifyNow();
1338
+ }
1339
+
1340
+ /** Every screen cell between two points, as text. */
1341
+ function selectedText() {
1342
+ const sel = state.selection;
1343
+ const screen = state.screen;
1344
+ if (!sel?.anchor || !sel?.focus || !screen) return '';
1345
+ const a = sel.anchor;
1346
+ const b = sel.focus;
1347
+ const [from, to] = (a.y < b.y || (a.y === b.y && a.x <= b.x)) ? [a, b] : [b, a];
1348
+ const out = [];
1349
+ screen.rows.forEach((row, index) => {
1350
+ const y = screen.top + index;
1351
+ if (y < from.y || y > to.y) return;
1352
+ const text = rowText(row);
1353
+ const start = y === from.y ? Math.max(0, from.x - screen.left) : 0;
1354
+ const end = y === to.y ? Math.max(0, to.x - screen.left + 1) : text.length;
1355
+ out.push(text.slice(start, end).replace(/\s+$/, ''));
1356
+ });
1357
+ return out.join('\n').replace(/\n+$/, '');
1358
+ }
1359
+
1360
+ /**
1361
+ * Select the word or the whole line under a point, the way a double- or triple-click
1362
+ * does in a terminal. Returns false when there is no text there.
1363
+ */
1364
+ function selectAt(point, unit) {
1365
+ const screen = state.screen;
1366
+ if (!screen) return false;
1367
+ const row = screen.rows[point.y - screen.top];
1368
+ if (!row) return false;
1369
+ const text = rowText(row);
1370
+ const col = point.x - screen.left;
1371
+ let start, end;
1372
+ if (unit === 'line') {
1373
+ // Past the gutter: the message bullet and quote bars are decoration, not text.
1374
+ start = text.length - text.replace(/^[\s●○◆▎│┃⎿]+/, '').length;
1375
+ end = text.replace(/\s+$/, '').length - 1;
1376
+ } else {
1377
+ if (col < 0 || col >= text.length || /\s/.test(text[col])) return false;
1378
+ // A "word" is what you would want to paste: a path, a URL, an identifier.
1379
+ const inWord = (ch) => ch && !/[\s"'`()[\]{}<>,;]/.test(ch);
1380
+ start = col; end = col;
1381
+ while (inWord(text[start - 1])) start -= 1;
1382
+ while (inWord(text[end + 1])) end += 1;
1383
+ }
1384
+ if (end < start) return false;
1385
+ state.selection = { anchor: { x: screen.left + start, y: point.y }, focus: { x: screen.left + end, y: point.y } };
1386
+ notifyNow();
1387
+ void finishSelection();
1388
+ return true;
1389
+ }
1390
+
1391
+ function clearSelection() {
1392
+ if (!state.selection) return;
1393
+ state.selection = null;
1394
+ notifyNow();
1395
+ }
1396
+
1397
+ /**
1398
+ * Finish a drag: copy what was highlighted.
1399
+ *
1400
+ * Copy-on-select is the terminal behaviour people expect, and taking the mouse away
1401
+ * from the terminal took it with it. Doing it on release rather than on a menu keeps
1402
+ * the muscle memory intact; `copyOnSelect off` turns it back into a plain highlight.
1403
+ */
1404
+ async function finishSelection() {
1405
+ const text = selectedText();
1406
+ if (!text.trim()) { clearSelection(); return; }
1407
+ if (settings?.copyOnSelect === false) {
1408
+ // Not "Ctrl+Shift+C": that is the TERMINAL's copy shortcut and it acts on the
1409
+ // terminal's own selection, which does not exist while the app owns the mouse.
1410
+ // Naming a shortcut that cannot work is worse than naming none.
1411
+ flash(`${text.length} characters selected · jaitus config copyOnSelect on — to copy these automatically`, 'info', 4000);
1412
+ return;
1413
+ }
1414
+ const ok = await (state.onCopy ? state.onCopy(text) : Promise.resolve(false));
1415
+ if (ok) flash(`Copied ${text.length} character${text.length === 1 ? '' : 's'}`, 'ok', 1400);
1416
+ else flash('Nothing could copy it — no clipboard tool found', 'warn', 3000);
1417
+ // The highlight stays a moment so it is clear WHAT was copied, then goes.
1418
+ const copied = state.selection;
1419
+ setTimeout(() => { if (state.selection === copied) clearSelection(); }, 700);
1420
+ }
1421
+
1422
+ /**
1423
+ * A click landed on something.
1424
+ *
1425
+ * Everything reachable on screen does what it looks like it does: the mode pill opens
1426
+ * the mode picker, the model name opens the model picker, the context meter offers to
1427
+ * compact. That is the whole point of drawing them as distinct objects — an indicator
1428
+ * you cannot act on is a thing you have to go and find a command for.
1429
+ */
1430
+ function dispatchClick(region, event) {
1431
+ if (!region) return;
1432
+ switch (region.action.type) {
1433
+ case 'swallow':
1434
+ return;
1435
+ case 'dialogOption': {
1436
+ const d = state.dialog;
1437
+ if (!d) return;
1438
+ d.index = clamp(region.action.index, 0, d.options.length - 1);
1439
+ return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
1440
+ }
1441
+ case 'paletteRow': {
1442
+ const panel = state.panel;
1443
+ if (!panel?.palette) return;
1444
+ panel.index = clamp(region.action.index, 0, Math.max(0, panel.options.length - 1));
1445
+ const chosen = panel.options[panel.index];
1446
+ const name = typeof chosen === 'string' ? chosen : chosen?.name;
1447
+ state.panel = null;
1448
+ refreshMenu();
1449
+ if (!name) return notifyNow();
1450
+ state.input = name;
1451
+ state.cursor = name.length;
1452
+ return submit();
1453
+ }
1454
+ case 'menuRow': {
1455
+ state.menuIndex = clamp(region.action.index, 0, state.menu.length - 1);
1456
+ return chooseMenu({ run: true });
1457
+ }
1458
+ case 'mode':
1459
+ return void state.onModeCycle?.();
1460
+ case 'model':
1461
+ return void state.onCommand?.('/model');
1462
+ case 'context':
1463
+ return void state.onCommand?.('/context');
1464
+ case 'link':
1465
+ // Only http(s) reaches here — see openUrl. A terminal will happily render
1466
+ // `file:///` or `javascript:` in text that came from a model or a fetched page,
1467
+ // and handing either to the shell runs something nobody agreed to.
1468
+ if (openUrl(region.action.url)) {
1469
+ flash(`Opened ${region.action.url.slice(0, 60)}`, 'info', 1600);
1470
+ }
1471
+ return;
1472
+ case 'status':
1473
+ return openPalette();
1474
+ case 'transcript':
1475
+ // A click in the body is how you get back to the live end after scrolling.
1476
+ if (state.viewingTranscript && event.button === 'left') {
1477
+ state.viewingTranscript = false;
1478
+ state.transcriptOffset = 0;
1479
+ state.newMessages = 0;
1480
+ notifyNow();
1481
+ }
1482
+ return;
1483
+ default:
1484
+ }
1485
+ }
1486
+
1487
+ function openPalette() {
1488
+ state.panel = {
1489
+ title: 'COMMAND PALETTE', palette: true, filter: '',
1490
+ all: commands, options: commands, index: 0, lines: [], resolve: () => {},
1491
+ };
1492
+ notifyNow();
1493
+ }
1494
+
1495
+ function handleKey(input, key, exit) {
1496
+ if (!state.active) return;
1497
+
1498
+ // ── the mouse arrives here too ──
1499
+ //
1500
+ // Ink does not recognise the reports, so it hands them through as ordinary input
1501
+ // with the escape stripped. Without this guard, scrolling the wheel types
1502
+ // `[<64;10;5M` into the composer.
1503
+ if (mouseFragment) {
1504
+ const fresh = Date.now() - mouseFragment.at < 300;
1505
+ const joined = mouseFragment.text + input;
1506
+ mouseFragment = null;
1507
+ if (fresh && (isMouse(joined) || isMouseFragment(joined))) input = joined;
1508
+ }
1509
+ if (isMouseFragment(input)) {
1510
+ mouseFragment = { text: input, at: Date.now() };
1511
+ return;
1512
+ }
1513
+ if (isMouse(input)) {
1514
+ mouseReports += 1;
1515
+ lastMouseAt = Date.now();
1516
+ const event = parseMouse(input);
1517
+ if (!event) return;
1518
+ if (event.kind === 'wheelUp') return scroll(scrollStep());
1519
+ if (event.kind === 'wheelDown') return scroll(-scrollStep());
1520
+ // Right-click pastes, as it does in a Windows console — the terminal cannot do it
1521
+ // itself while the app has the mouse.
1522
+ if (event.kind === 'down' && event.button === 'right') {
1523
+ if (!state.dialog && !state.panel) void pasteFromClipboard();
1524
+ return;
1525
+ }
1526
+
1527
+ if (event.kind === 'down' && event.button === 'left') {
1528
+ // A press starts a possible drag AND a possible click. Which it was is only
1529
+ // known on release, so nothing is dispatched yet — dispatching here made every
1530
+ // attempt to select text also press whatever was under the cursor.
1531
+ pressedAt = { x: event.x, y: event.y };
1532
+ dragged = false;
1533
+ clearSelection();
1534
+ return;
1535
+ }
1536
+ if (event.kind === 'drag' && pressedAt) {
1537
+ if (event.x !== pressedAt.x || event.y !== pressedAt.y) dragged = true;
1538
+ state.selection = { anchor: pressedAt, focus: { x: event.x, y: event.y } };
1539
+ return notifyNow();
1540
+ }
1541
+ if (event.kind === 'up') {
1542
+ const start = pressedAt;
1543
+ pressedAt = null;
1544
+ if (!start) return;
1545
+ if (dragged) { dragged = false; void finishSelection(); return; }
1546
+ const now = Date.now();
1547
+ const again = lastClick && now - lastClick.at < 450 && lastClick.y === start.y && Math.abs(lastClick.x - start.x) <= 1;
1548
+ clickCount = again ? clickCount + 1 : 1;
1549
+ lastClick = { x: start.x, y: start.y, at: now };
1550
+ const region = hits.at(start.x, start.y);
1551
+ const onText = !region || region.action.type === 'transcript' || region.action.type === 'swallow';
1552
+ if (clickCount >= 2 && onText && selectAt(start, clickCount >= 3 ? 'line' : 'word')) return;
1553
+ return dispatchClick(region, event);
1554
+ }
1555
+ return;
1556
+ }
1557
+
1558
+ // ── dialog has the keyboard ──
1559
+ if (state.dialog) {
1560
+ const d = state.dialog;
1561
+ if (key.escape) return resolveDialog(undefined);
1562
+ if (key.upArrow || key.up) { d.index = clamp(d.index - 1, 0, d.options.length - 1); return notifyNow(); }
1563
+ if (key.downArrow || key.down) { d.index = clamp(d.index + 1, 0, d.options.length - 1); return notifyNow(); }
1564
+ if (key.return || key.enter) return resolveDialog({ ...d.options[d.index], comment: d.comment || '' });
1565
+ if (/^[1-9]$/.test(input)) {
1566
+ const index = clamp(Number(input) - 1, 0, d.options.length - 1);
1567
+ d.index = index;
1568
+ // A number is a decision, not a cursor move: waiting for Enter after it is the
1569
+ // most common reason a permission prompt feels unresponsive.
1570
+ return resolveDialog({ ...d.options[index], comment: d.comment || '' });
1571
+ }
1572
+ return;
1573
+ }
1574
+
1575
+ // ── palette / panel has the keyboard ──
1576
+ if (state.panel) {
1577
+ const panel = state.panel;
1578
+ if (panel.palette) {
1579
+ if (key.upArrow || key.up) { panel.index = clamp(panel.index - 1, 0, panel.options.length - 1); return notifyNow(); }
1580
+ if (key.downArrow || key.down) { panel.index = clamp(panel.index + 1, 0, panel.options.length - 1); return notifyNow(); }
1581
+ if (key.return || key.enter) {
1582
+ const chosen = panel.options[panel.index];
1583
+ const name = typeof chosen === 'string' ? chosen : chosen?.name;
1584
+ state.panel = null;
1585
+ refreshMenu();
1586
+ if (!name) return notifyNow();
1587
+ // Run it. Picking a command out of a palette and then being handed it to
1588
+ // press Enter on again is a step that exists for no reason. Shift+Enter is
1589
+ // the escape hatch for a command you want to add an argument to.
1590
+ state.input = name;
1591
+ state.cursor = name.length;
1592
+ if (key.shift) { state.input = `${name} `; state.cursor = state.input.length; return notifyNow(); }
1593
+ submit();
1594
+ return notifyNow();
1595
+ }
1596
+ if (key.escape) { state.panel = null; panel.resolve?.(); return notifyNow(); }
1597
+ if (key.backspace || key.delete) {
1598
+ panel.filter = String(panel.filter || '').slice(0, -1);
1599
+ applyPaletteFilter(panel);
1600
+ return notifyNow();
1601
+ }
1602
+ if (input && !key.ctrl && !key.meta) {
1603
+ panel.filter = `${panel.filter || ''}${input}`;
1604
+ applyPaletteFilter(panel);
1605
+ return notifyNow();
1606
+ }
1607
+ return;
1608
+ }
1609
+ if (key.escape || key.return || key.enter) resolvePanel();
1610
+ return;
1611
+ }
1612
+
1613
+ // Fast PTYs on Windows deliver a typed line and its carriage return in one chunk.
1614
+ // Split here or the return becomes a literal character in the composer.
1615
+ const newlineAt = String(input || '').search(/[\r\n]/);
1616
+ if (newlineAt >= 0) {
1617
+ const typed = String(input).slice(0, newlineAt);
1618
+ const rest = String(input).slice(newlineAt + 1);
1619
+ if (typed) insert(typed);
1620
+ if (key.shift) insert('\n');
1621
+ // This path went straight to submit(), which is why `/re` + Enter sometimes ran
1622
+ // `/re` instead of the highlighted `/review`. Whether Enter arrives on its own or
1623
+ // glued to the typed text is a matter of how fast the keystrokes were batched by
1624
+ // the PTY — so the two paths have to make the same decision, or the shortcut
1625
+ // works or does not work depending on typing speed.
1626
+ else if (state.menu.length) chooseMenu({ run: true });
1627
+ else submit();
1628
+ if (rest) insert(rest);
1629
+ return;
1630
+ }
1631
+
1632
+ // Ink reports Ctrl+<letter> as `key.ctrl` with the LETTER itself in `input`; its
1633
+ // Key type has no `name` field at all. Every `key.name === 'l'` style check in the
1634
+ // previous version of this file was therefore dead code — Ctrl+L, Ctrl+P, Ctrl+U,
1635
+ // Ctrl+K, Ctrl+A and Ctrl+E all did nothing, silently, because a condition that is
1636
+ // never true is indistinguishable from a shortcut nobody presses.
1637
+ //
1638
+ // `key.name` is still accepted so the controller tests (and the classic renderer's
1639
+ // readline keypress objects) can drive the same handler.
1640
+ const ctrlKey = key.ctrl ? String(key.name || input || '').toLowerCase() : '';
1641
+
1642
+ if (ctrlKey === 'c') {
1643
+ if (state.busy) state.onEscape?.();
1644
+ else if (state.input) clearInput();
1645
+ else flash('Type /exit to leave', 'info', 1500);
1646
+ return;
1647
+ }
1648
+ if (key.shift && key.tab) { void state.onModeCycle?.(); return; }
1649
+ if (ctrlKey === 'l') { api.clearTranscript(); return; }
1650
+ if (ctrlKey === 'd') { api.toggleDebug(); return; }
1651
+ if (ctrlKey === 'p') return openPalette();
1652
+ // Ctrl+V reaches the app only in terminals that do not paste on it themselves (those
1653
+ // send the text as a bracketed paste instead). Alt+V is for an image — a screenshot
1654
+ // cannot be typed in, so no terminal pastes one.
1655
+ if (ctrlKey === 'v') { void pasteFromClipboard(); return; }
1656
+ if (key.meta && String(input).toLowerCase() === 'v') { void pasteFromClipboard({ imageOnly: true }); return; }
1657
+ if (key.escape) {
1658
+ if (state.busy) return state.onEscape?.();
1659
+ if (state.viewingTranscript) { state.viewingTranscript = false; state.transcriptOffset = 0; state.newMessages = 0; return notifyNow(); }
1660
+ const now = Date.now();
1661
+ if (lastEscapeAt && now - lastEscapeAt < 1200) {
1662
+ lastEscapeAt = 0;
1663
+ if (flashTimer) { clearTimeout(flashTimer); flashTimer = null; }
1664
+ state.flash = null;
1665
+ state.onRewind?.();
1666
+ } else {
1667
+ lastEscapeAt = now;
1668
+ flash('Press Esc again to rewind', 'info', 1200);
1669
+ }
1670
+ return;
1671
+ }
1672
+
1673
+ // ── scrollback ──
1674
+ if (key.pageUp || key.pageup) return scroll(10);
1675
+ if (key.pageDown || key.pagedown) return scroll(-10);
1676
+ if ((key.ctrl && (key.end || ctrlKey === 'g'))) {
1677
+ state.viewingTranscript = false; state.transcriptOffset = 0; state.newMessages = 0;
1678
+ return notifyNow();
1679
+ }
1680
+ if ((key.ctrl && key.home)) return scroll(100000);
1681
+
1682
+ // ── menu ──
1683
+ if ((key.upArrow || key.up) && state.menu.length) { state.menuIndex = clamp(state.menuIndex - 1, 0, state.menu.length - 1); return notifyNow(); }
1684
+ if ((key.downArrow || key.down) && state.menu.length) { state.menuIndex = clamp(state.menuIndex + 1, 0, state.menu.length - 1); return notifyNow(); }
1685
+ if (key.tab && state.menu.length) return chooseMenu();
1686
+ if (key.return || key.enter) {
1687
+ if (!key.shift && state.menu.length) return chooseMenu({ run: true });
1688
+ if (key.shift) return insert('\n');
1689
+ return submit();
1690
+ }
1691
+
1692
+ // ── line editing ──
1693
+ if (key.leftArrow || key.left) {
1694
+ if (key.ctrl || key.meta) state.cursor = wordLeft();
1695
+ else {
1696
+ // Step over a chip, not into it. A cursor sitting inside `[#1 pasted 120 …]`
1697
+ // can only do damage: anything typed there breaks the token.
1698
+ const chip = chipBefore(state.input, state.cursor);
1699
+ state.cursor = Math.max(0, state.cursor - (chip ? chip[0].length : 1));
1700
+ }
1701
+ return notifyNow();
1702
+ }
1703
+ if (key.rightArrow || key.right) {
1704
+ if (key.ctrl || key.meta) state.cursor = wordRight();
1705
+ else {
1706
+ const chip = chipAfter(state.input, state.cursor);
1707
+ state.cursor = Math.min(state.input.length, state.cursor + (chip ? chip[0].length : 1));
1708
+ }
1709
+ return notifyNow();
1710
+ }
1711
+ if (key.home || (ctrlKey === 'a')) { state.cursor = 0; return notifyNow(); }
1712
+ if (key.end || (ctrlKey === 'e')) { state.cursor = state.input.length; return notifyNow(); }
1713
+ if (ctrlKey === 'w') {
1714
+ // A chip is one word. Deleting "the last word" of `…, 969 chars]` would otherwise
1715
+ // leave a broken token behind.
1716
+ const chip = chipBefore(state.input, state.cursor);
1717
+ const at = chip ? state.cursor - chip[0].length : wordLeft();
1718
+ if (chip) dropPaste(chip[1]);
1719
+ state.input = state.input.slice(0, at) + state.input.slice(state.cursor);
1720
+ state.cursor = at;
1721
+ refreshMenu();
1722
+ return notifyNow();
1723
+ }
1724
+ if (key.backspace) {
1725
+ if (state.cursor === 0) return;
1726
+ const chip = chipBefore(state.input, state.cursor);
1727
+ const eat = chip ? chip[0].length : 1;
1728
+ if (chip) dropPaste(chip[1]);
1729
+ state.input = state.input.slice(0, state.cursor - eat) + state.input.slice(state.cursor);
1730
+ state.cursor -= eat;
1731
+ refreshMenu();
1732
+ return notifyNow();
1733
+ }
1734
+ if (key.delete) {
1735
+ const chip = chipAfter(state.input, state.cursor);
1736
+ const eat = chip ? chip[0].length : 1;
1737
+ if (chip) dropPaste(chip[1]);
1738
+ state.input = state.input.slice(0, state.cursor) + state.input.slice(state.cursor + eat);
1739
+ refreshMenu();
1740
+ return notifyNow();
1741
+ }
1742
+ if (ctrlKey === 'u') { state.input = state.input.slice(state.cursor); state.cursor = 0; refreshMenu(); return notifyNow(); }
1743
+ if (ctrlKey === 'k') { state.input = state.input.slice(0, state.cursor); refreshMenu(); return notifyNow(); }
1744
+ if (key.upArrow || key.up) return historyMove(-1);
1745
+ if (key.downArrow || key.down) return historyMove(1);
1746
+ if (input && !key.ctrl && !key.meta) insert(input);
1747
+ }
1748
+
1749
+ function applyPaletteFilter(panel) {
1750
+ const filter = String(panel.filter || '');
1751
+ if (!filter) { panel.options = panel.all; panel.index = 0; return; }
1752
+ panel.options = panel.all
1753
+ .map((command) => {
1754
+ const name = typeof command === 'string' ? command : command.name;
1755
+ const match = fuzzy(filter, name.replace(/^\//, ''));
1756
+ return match ? { command, score: match.score } : null;
1757
+ })
1758
+ .filter(Boolean)
1759
+ .sort((a, b) => b.score - a.score)
1760
+ .map((entry) => entry.command);
1761
+ panel.index = 0;
1762
+ }
1763
+
1764
+ const api = {
1765
+ state,
1766
+
1767
+ attach(fn) { update = fn; },
1768
+ onEscape(fn) { state.onEscape = fn; },
1769
+ onRewind(fn) { state.onRewind = fn; },
1770
+ setMeta(next) { Object.assign(state.meta, next); notify(); },
1771
+ setSpent(next) { Object.assign(state.spent, next || {}); notify(); },
1772
+
1773
+ hits,
1774
+ onCommand(fn) { state.onCommand = fn; },
1775
+ onCopy(fn) { state.onCopy = fn; },
1776
+ handlePaste,
1777
+
1778
+ /**
1779
+ * A resize is the one moment the screen has to be thrown away and redrawn.
1780
+ *
1781
+ * Ink clears only when the terminal gets NARROWER, on the reasoning that a wider
1782
+ * one cannot leave anything behind. That is true of output that flows, and false of
1783
+ * an app that draws a fixed number of rows: make the window shorter and the taller
1784
+ * frame's lower rows stay on screen underneath the new one. That is the duplicated
1785
+ * composer and the layout "breaking" on resize.
1786
+ *
1787
+ * Clearing on every size change costs one repaint during a drag-resize and removes
1788
+ * the whole class of problem.
1789
+ */
1790
+ onResize(width, height) {
1791
+ const last = state.lastSize;
1792
+ state.lastSize = { width, height };
1793
+ if (last && last.width === width && last.height === height) return;
1794
+ // Anything expressed in screen coordinates is now about the wrong cells.
1795
+ state.selection = null;
1796
+ // The explicit erase wipes cells Ink is not tracking: after the window SHRINKS, the
1797
+ // rows of the taller frame below the new one are not in Ink's set and would stay.
1798
+ //
1799
+ // Then one EMPTY frame, then the real one. Ink redraws line by line
1800
+ // (incrementalRendering) against what it believes is on screen, and its clear()
1801
+ // re-records the old frame as present — so after the erase above, the next frame
1802
+ // rewrote only the lines that had changed, and the screen stayed almost blank
1803
+ // (measured). An empty frame makes Ink forget every line; the frame after it is
1804
+ // then written in full.
1805
+ try {
1806
+ if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
1807
+ } catch { /* mid-teardown */ }
1808
+ state.blankFrame = true;
1809
+ notifyNow();
1810
+ setTimeout(() => { state.blankFrame = false; notifyNow(); }, 120);
1811
+ },
1812
+
1813
+ /**
1814
+ * What the mouse is actually doing, for `/debug`.
1815
+ *
1816
+ * Enabling reporting and receiving reports are different facts, and only the second
1817
+ * one means the mouse works. A terminal that ignores the request looks identical
1818
+ * from this side to one that is simply not being used — so the count is kept, and
1819
+ * "requested but nothing received" is a thing the diagnostics can say out loud
1820
+ * instead of leaving someone to guess.
1821
+ */
1822
+ mouseReport() {
1823
+ return {
1824
+ requested: !!state.mouse,
1825
+ reportsSeen: mouseReports,
1826
+ lastAt: lastMouseAt ? new Date(lastMouseAt).toISOString() : null,
1827
+ };
1828
+ },
1829
+
1830
+ start({ onLine, history = [] }) {
1831
+ state.onLine = onLine;
1832
+ state.history = [...history];
1833
+ state.historyIndex = state.history.length;
1834
+ state.active = true;
1835
+ seedBanner();
1836
+ // `alternateScreen` is not a cosmetic preference — three separate defects came
1837
+ // from not passing it.
1838
+ //
1839
+ // Rendering inline, Ink reflows its output in place against a window whose height
1840
+ // it does not own. Resize the terminal and the previous frame is still on screen
1841
+ // under the new one: that is the duplicated composer, the doubled prompt, and the
1842
+ // "sometimes fullscreen, sometimes not" all at once.
1843
+ //
1844
+ // It also breaks the mouse. Every coordinate this file computes assumes row 1 is
1845
+ // the top of the app, which is only true on a screen the app owns. Inline, the
1846
+ // app sits below whatever scrollback happens to be there, so clicks and drag
1847
+ // selection land on the wrong rows — by an amount that changes as the session
1848
+ // grows, which is why it looked intermittent rather than broken.
1849
+ inkInstance = render(h(App, { controller: api }), {
1850
+ alternateScreen: true,
1851
+ exitOnCtrlC: false,
1852
+ patchConsole: false,
1853
+ // Rewrite only the lines that changed. A streamed word changes one line; without
1854
+ // this every token rewrote all ~30 lines (~45KB a second measured while
1855
+ // streaming), which is its own source of tearing on slower terminals.
1856
+ incrementalRendering: true,
1857
+ });
1858
+ // Mouse reporting takes over the terminal's own selection, so it is opt-out and
1859
+ // the status bar says how to get selection back. Enabled after render so Ink's
1860
+ // own setup cannot overwrite the mode.
1861
+ if (settings?.mouse !== false && !process.env.JAITUS_DISABLE_MOUSE) {
1862
+ state.mouse = enableMouse(process.stdout);
1863
+ }
1864
+ notifyNow();
1865
+ },
1866
+
1867
+ stop() {
1868
+ state.active = false;
1869
+ exited = true;
1870
+ if (pending) { clearTimeout(pending); pending = null; }
1871
+ // Before unmount: the terminal keeps reporting after the process is gone
1872
+ // otherwise, and every click in the shell afterwards prints escape codes.
1873
+ disableMouse(process.stdout);
1874
+ inkInstance?.unmount();
1875
+ inkInstance = null;
1876
+ },
1877
+
1878
+ setMouse(on) {
1879
+ if (on) state.mouse = enableMouse(process.stdout);
1880
+ else { disableMouse(process.stdout); state.mouse = false; }
1881
+ notifyNow();
1882
+ return state.mouse;
1883
+ },
1884
+
1885
+ // ── structured entries ──────────────────────────────────────────────────────
1886
+ user(text, attachments) { return push('user', { text, attachments }); },
1887
+ assistant(text) { return push('assistant', { text }); },
1888
+ notice(text, tone = 'dim') { return push('notice', { text, tone }); },
1889
+ todo(items) { return push('todo', { items }); },
1890
+ diff(file, patch, max) { return push('diff', { file, patch, max }); },
1891
+
1892
+ /**
1893
+ * A tool call. Returns a handle whose `update` mutates the same entry in place, so
1894
+ * a row goes running → ok without a second line appearing under the first.
1895
+ */
1896
+ tool(data) {
1897
+ const entry = push('tool', { status: 'running', ...data });
1898
+ return {
1899
+ id: entry.id,
1900
+ update(next) {
1901
+ Object.assign(entry.data, next);
1902
+ entry._w = -1;
1903
+ entry._rows = null;
1904
+ notify();
1905
+ },
1906
+ };
1907
+ },
1908
+
1909
+ /**
1910
+ * A failure, with everything needed to act on it.
1911
+ *
1912
+ * Errors were previously one dim line that scrolled past, so a session where every
1913
+ * request was being rejected looked like a session where the model had nothing to
1914
+ * say. They now get a red-barred block, a hint where one applies, and a folded
1915
+ * stack that `/debug` or Ctrl+D unfolds.
1916
+ */
1917
+ error(error, context = {}) {
1918
+ const value = error instanceof Error ? error : new Error(String(error?.message ?? error ?? 'Unknown error'));
1919
+ const message = messageForError(value);
1920
+ let hint = context.hint || hintForError(value);
1921
+ // Our server messages often already say what to do ("run `jaitus login`"); the
1922
+ // hint would then say it a second time, one line lower.
1923
+ const flat = (t) => String(t || '').replace(/[`.]/g, '').toLowerCase().trim();
1924
+ if (hint && flat(message).includes(flat(hint))) hint = '';
1925
+ return push('error', {
1926
+ title: context.title || titleForError(value),
1927
+ message,
1928
+ hint,
1929
+ detail: context.detail || (value.requestId ? `request id ${value.requestId}` : ''),
1930
+ // A service error's stack is this CLI's own call path — nothing in it explains
1931
+ // what happened. Only a fault in the CLI keeps one, for /debug.
1932
+ stack: value.name === 'JaitusError' ? '' : (value.stack || ''),
1933
+ expanded: state.debug,
1934
+ });
1935
+ },
1936
+
1937
+ toggleDebug() {
1938
+ state.debug = !state.debug;
1939
+ for (const entry of state.transcript) {
1940
+ if (entry.kind === 'error') { entry.data.expanded = state.debug; entry._w = -1; entry._rows = null; }
1941
+ }
1942
+ flash(state.debug ? 'Debug on — stack traces expanded' : 'Debug off', 'info', 1600);
1943
+ },
1944
+
1945
+ // ── the legacy string surface ───────────────────────────────────────────────
1946
+ //
1947
+ // Most of the CLI still prints pre-formatted ANSI strings. Those keep working and
1948
+ // keep their colour: `raw` parses the escapes back into spans rather than dropping
1949
+ // them, which is what the previous renderer did.
1950
+ print(value, kind = 'assistant') {
1951
+ const text = String(value ?? '');
1952
+ if (!plain(text).trim() && !/\n/.test(text)) return;
1953
+ state.live = null;
1954
+ if (kind === 'user') return push('raw', { ansi: text });
1955
+ if (kind === 'error') {
1956
+ return push('error', { title: 'Error', message: plain(text).replace(/^[✗x]\s*/, ''), stack: '', expanded: false });
1957
+ }
1958
+ return push('raw', { ansi: text });
1959
+ },
1960
+
1961
+ printStream(value) {
1962
+ const text = String(value ?? '');
1963
+ if (!text) return;
1964
+ if (!state.live) {
1965
+ state.live = { text: '', entry: makeEntry('assistant', { text: '', compact: true }) };
1966
+ }
1967
+ state.live.text += plain(text);
1968
+ state.live.entry.data.text = state.live.text;
1969
+ state.live.entry._w = -1;
1970
+ state.live.entry._rows = null;
1971
+ if (!state.turnStartedAt) state.turnStartedAt = Date.now();
1972
+ notify();
1973
+ },
1974
+
1975
+ flushStream() {
1976
+ if (state.live?.text?.trim()) {
1977
+ const entry = state.live.entry;
1978
+ entry.data.compact = false;
1979
+ entry._w = -1;
1980
+ entry._rows = null;
1981
+ state.transcript.push(entry);
1982
+ if (state.viewingTranscript) state.newMessages += 1;
1983
+ }
1984
+ state.live = null;
1985
+ notify();
1986
+ },
1987
+
1988
+ clearTranscript() {
1989
+ state.transcript = [];
1990
+ seedBanner();
1991
+ state.live = null;
1992
+ state.transcriptOffset = 0;
1993
+ state.viewingTranscript = false;
1994
+ state.newMessages = 0;
1995
+ state.home = true;
1996
+ notifyNow();
1997
+ },
1998
+
1999
+ setStatusLine(value) { state.statusLine = plain(value || ''); notify(); },
2000
+ setBusy(value) {
2001
+ state.busy = !!value;
2002
+ if (value) { state.turnStartedAt = Date.now(); state.turnTokens = 0; }
2003
+ else { state.status = ''; state.turnStartedAt = null; }
2004
+ notifyNow();
2005
+ },
2006
+ setStatus(value) { state.status = plain(value); notify(); },
2007
+ flash(text, tone, ms) { flash(plain(text), tone, ms); },
2008
+ clearStatus() { state.status = ''; notify(); },
2009
+ setTurnTokens(n) { state.turnTokens = n | 0; notify(); },
2010
+ setPlaceholder(text) { state.placeholder = String(text || ''); notify(); },
2011
+ setHome(value) { state.home = !!value; notify(); },
2012
+
2013
+ queue(line) { state.queued.push(line); notify(); },
2014
+ takeQueued() { const queued = state.queued; state.queued = []; notify(); return queued; },
2015
+ hasQueued() { return state.queued.length > 0; },
2016
+
2017
+ addAttachment(attachment) { state.attachments.push(attachment); notify(); },
2018
+ drainAttachments() { const list = state.attachments; state.attachments = []; notify(); return list.map((a) => a.block).filter(Boolean); },
2019
+
2020
+ askDialog(question, options, preview = [], opts = {}) {
2021
+ return new Promise((resolve) => {
2022
+ state.dialog = {
2023
+ question: plain(question),
2024
+ options: options.map((option) => (option && typeof option === 'object'
2025
+ ? { label: plain(option.label ?? option.value), value: option.value }
2026
+ : { label: plain(option), value: option })),
2027
+ preview: preview.map(plain),
2028
+ index: 0,
2029
+ resolve,
2030
+ title: opts.title,
2031
+ tone: opts.tone,
2032
+ hideComment: opts.hideComment,
2033
+ comment: '',
2034
+ };
2035
+ state.menu = [];
2036
+ notifyNow();
2037
+ });
2038
+ },
2039
+
2040
+ showPanel(title, lines) {
2041
+ return new Promise((resolve) => {
2042
+ state.panel = {
2043
+ title: plain(title),
2044
+ lines: (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line)),
2045
+ rows: null,
2046
+ resolve,
2047
+ };
2048
+ notifyNow();
2049
+ });
2050
+ },
2051
+
2052
+ updatePanel(lines) {
2053
+ if (!state.panel) return;
2054
+ state.panel.lines = (Array.isArray(lines) ? lines : String(lines).split('\n')).map((line) => plain(line));
2055
+ notify();
2056
+ },
2057
+
2058
+ /** A theme change recolours every cached row, so they all have to be rebuilt. */
2059
+ reskin() {
2060
+ resetPalette();
2061
+ invalidate(state.transcript);
2062
+ if (state.live?.entry) { state.live.entry._w = -1; state.live.entry._rows = null; }
2063
+ notifyNow();
2064
+ },
2065
+
2066
+ recordActivity(event) {
2067
+ const existing = event.id ? state.activity.find((item) => item.id === event.id) : null;
2068
+ if (existing) Object.assign(existing, event, { updatedAt: Date.now() });
2069
+ else {
2070
+ state.activity.push({
2071
+ id: event.id || `${Date.now()}-${state.activity.length}`,
2072
+ name: event.name,
2073
+ label: String(event.label || event.name || ''),
2074
+ status: event.status || 'running',
2075
+ detail: event.detail || '',
2076
+ updatedAt: Date.now(),
2077
+ });
2078
+ }
2079
+ if (state.activity.length > 200) state.activity.shift();
2080
+ notify();
2081
+ return existing?.id || state.activity.at(-1)?.id;
2082
+ },
2083
+ activitySnapshot() { return state.activity.map((item) => ({ ...item })); },
2084
+
2085
+ bellIfAway() {
2086
+ // A terminal bell is the only cross-platform "I need you" a CLI has. Suppressed
2087
+ // under reduced motion, which is also where people who do not want surprises are.
2088
+ if (!motionOn()) return;
2089
+ try { process.stdout.write(''); } catch { /* a bell is never worth an error */ }
2090
+ },
2091
+ animationsOn() { return state.active && motionOn(); },
2092
+ redraw() { notifyNow(); },
2093
+
2094
+ get exited() { return exited; },
2095
+ handleKey,
2096
+ };
2097
+
2098
+ return api;
2099
+ }
2100
+
2101
+ // A JaitusError brings its own title and hint, written in errors.js. Anything else is a
2102
+ // fault inside this CLI: it gets a plain title rather than its class name ("TypeError"
2103
+ // means nothing to a user), and its real message and stack wait behind /debug.
2104
+ function titleForError(error) {
2105
+ if (error.name === 'JaitusError' && error.title) return error.title;
2106
+ if (error.name === 'AbortError') return 'Interrupted';
2107
+ return 'Something went wrong in Jaitus';
2108
+ }
2109
+
2110
+ function hintForError(error) {
2111
+ if (error.name === 'JaitusError') return error.hint || '';
2112
+ if (error.name === 'AbortError') return '';
2113
+ return 'Try again. /debug shows the details if it keeps happening.';
2114
+ }
2115
+
2116
+ function messageForError(error) {
2117
+ if (error.name === 'JaitusError' || error.name === 'AbortError') return error.message || '';
2118
+ return 'This is a fault in the Jaitus CLI, not in your project.';
2119
+ }