jaitus 0.3.3 → 0.3.5

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