jaitus 0.3.0 → 0.3.2
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/package.json +2 -2
- package/src/agent/loop.js +155 -17
- package/src/commands/login.js +50 -32
- package/src/commands/update.js +167 -9
- package/src/errors.js +2 -2
- package/src/index.js +40 -10
- package/src/tui/mouse.js +150 -125
- package/src/tui-ink.js +1998 -1927
package/src/tui-ink.js
CHANGED
|
@@ -1,1927 +1,1998 @@
|
|
|
1
|
-
// ─── The Jaitus app view ────────────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// One conversation, full bleed, with exactly four pieces of permanent chrome: a header
|
|
4
|
-
// line, the transcript, the composer, and a status line. There is no dashboard, no
|
|
5
|
-
// sidebar and no second column, because the thing being read is prose and code and
|
|
6
|
-
// both of those want the full width of the window.
|
|
7
|
-
//
|
|
8
|
-
// Three decisions are worth knowing before changing anything here:
|
|
9
|
-
//
|
|
10
|
-
// • **Entries keep their source, not their pixels.** `entries.js` renders rows on
|
|
11
|
-
// demand for the current width and caches them against it. Resizing re-lays every
|
|
12
|
-
// message correctly, and scrolling is exact because the row count is already known
|
|
13
|
-
// — the previous renderer estimated it and drifted.
|
|
14
|
-
// • **Colour survives.** Everything the rest of the CLI prints already carries ANSI.
|
|
15
|
-
// It is parsed back into styled spans rather than stripped, so a red ✗ printed by
|
|
16
|
-
// a command written two years ago is still red here.
|
|
17
|
-
// • **Motion is one timer.** Ink 7 consolidates every `useAnimation` into a single
|
|
18
|
-
// render cycle, which is the only reason a spinner, a shimmering label, a drifting
|
|
19
|
-
// hairline and a blinking caret can all run without the frame rate suffering.
|
|
20
|
-
//
|
|
21
|
-
// Reduced motion (`/animations off`, `JAITUS_NO_ANIMATION=1`) is a first-class mode:
|
|
22
|
-
// every animated widget has a still frame that reads correctly on its own.
|
|
23
|
-
|
|
24
|
-
import React from 'react';
|
|
25
|
-
import { Box, Spacer, Text, render, useAnimation, useApp, useCursor, useInput, usePaste, useWindowSize } from 'ink';
|
|
26
|
-
import { palette, resetPalette, mix, ramp } from './tui/theme.js';
|
|
27
|
-
import { makeEntry, rowsFor, invalidate } from './tui/entries.js';
|
|
28
|
-
import { plain, stringWidth, truncateRow, wrapRow, rowText, inverseRange } from './tui/ansi.js';
|
|
29
|
-
import { Spinner, Shimmer, GradientText, FlowingRule, Meter, Elapsed, Caret, Rows, Row, Reveal, motionOn, publishSettings } from './tui/widgets.js';
|
|
30
|
-
import { wordRows, wordSize } from './tui/wordmark.js';
|
|
31
|
-
import { enableMouse, disableMouse, parseMouse, isMouse, 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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
return h(Box, { flexDirection: '
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
),
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
:
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
h(Box, { marginTop: 1 },
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
if (
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
return
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
if (
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
if (
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
state.
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
function
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
state.
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
// ──
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
if (
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
state.
|
|
1539
|
-
|
|
1540
|
-
return
|
|
1541
|
-
}
|
|
1542
|
-
if (key.
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
state.
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
if (key.
|
|
1564
|
-
if (
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
if (
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
.
|
|
1577
|
-
.
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
return {
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
state.
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
},
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
},
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
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
|
+
}
|