@chantier/tui 0.3.0 → 0.5.0
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/dist/index.d.mts +488 -22
- package/dist/index.mjs +1291 -125
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,711 @@
|
|
|
1
|
-
import { Box, Static, Text, render, useApp, useInput, useIsScreenReaderEnabled } from "ink";
|
|
2
|
-
import { createElement, useEffect, useSyncExternalStore } from "react";
|
|
1
|
+
import { Box, Static, Text, render, useAnimation, useApp, useInput, useIsScreenReaderEnabled, usePaste, useWindowSize } from "ink";
|
|
2
|
+
import { createElement, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
//#region src/keys.ts
|
|
7
|
+
/**
|
|
8
|
+
* The chord table. "app.queue.edit" and "app.history.prev" share the
|
|
9
|
+
* up-arrow chord on purpose: callers disambiguate by state (queued rows
|
|
10
|
+
* exist vs history recall), which is how OMP treats Alt+Up vs plain Up.
|
|
11
|
+
*/
|
|
12
|
+
const ACTION_CHORDS = {
|
|
13
|
+
"app.interrupt": [{ escape: true }],
|
|
14
|
+
"app.quit": [{
|
|
15
|
+
input: "c",
|
|
16
|
+
ctrl: true
|
|
17
|
+
}],
|
|
18
|
+
"app.decide.allow": [{ input: "y" }],
|
|
19
|
+
"app.decide.always": [{ input: "a" }],
|
|
20
|
+
"app.decide.deny": [{ input: "n" }],
|
|
21
|
+
"app.decide.abort": [{ escape: true }],
|
|
22
|
+
"app.submit": [{ return: true }],
|
|
23
|
+
"app.history.prev": [{ upArrow: true }],
|
|
24
|
+
"app.history.next": [{ downArrow: true }],
|
|
25
|
+
"app.queue.edit": [{ upArrow: true }],
|
|
26
|
+
"app.editor.home": [{
|
|
27
|
+
input: "a",
|
|
28
|
+
ctrl: true
|
|
29
|
+
}],
|
|
30
|
+
"app.editor.end": [{
|
|
31
|
+
input: "e",
|
|
32
|
+
ctrl: true
|
|
33
|
+
}],
|
|
34
|
+
"app.editor.char.back": [{
|
|
35
|
+
input: "b",
|
|
36
|
+
ctrl: true
|
|
37
|
+
}],
|
|
38
|
+
"app.editor.char.forward": [{
|
|
39
|
+
input: "f",
|
|
40
|
+
ctrl: true
|
|
41
|
+
}],
|
|
42
|
+
"app.editor.kill.to-end": [{
|
|
43
|
+
input: "k",
|
|
44
|
+
ctrl: true
|
|
45
|
+
}],
|
|
46
|
+
"app.editor.kill.line": [{
|
|
47
|
+
input: "u",
|
|
48
|
+
ctrl: true
|
|
49
|
+
}],
|
|
50
|
+
"app.editor.kill.word": [{
|
|
51
|
+
input: "w",
|
|
52
|
+
ctrl: true
|
|
53
|
+
}],
|
|
54
|
+
"app.editor.backspace": [{ backspace: true }, { delete: true }],
|
|
55
|
+
"app.redraw": [{
|
|
56
|
+
input: "l",
|
|
57
|
+
ctrl: true
|
|
58
|
+
}]
|
|
59
|
+
};
|
|
60
|
+
function chordMatches(chord, event) {
|
|
61
|
+
for (const field of Object.keys(chord)) if (event[field] !== chord[field]) return false;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether the keypress resolves to the action id. Input chunks are
|
|
66
|
+
* normalized by stripping CR/LF first: a PTY can deliver "y\r" as one chunk
|
|
67
|
+
* while ink sets key.return only for a lone CR.
|
|
68
|
+
*/
|
|
69
|
+
function matches(event, id) {
|
|
70
|
+
const chords = ACTION_CHORDS[id];
|
|
71
|
+
if (chords === void 0) return false;
|
|
72
|
+
const normalized = event.input === void 0 ? event : {
|
|
73
|
+
...event,
|
|
74
|
+
input: event.input.replace(/[\r\n]/g, "")
|
|
75
|
+
};
|
|
76
|
+
return chords.some((chord) => chordMatches(chord, normalized));
|
|
77
|
+
}
|
|
78
|
+
/** Hint line for the approval card, verbatim spec §7 mockup. */
|
|
79
|
+
const APPROVAL_HINT_PARTS = [
|
|
80
|
+
"y allow",
|
|
81
|
+
"a always",
|
|
82
|
+
"n deny",
|
|
83
|
+
"esc abort"
|
|
84
|
+
];
|
|
85
|
+
/**
|
|
86
|
+
* Maps a prompt keypress to a decision; null = key not handled by the
|
|
87
|
+
* prompt. Moved here unchanged from app.ts (spec §7): this stays the pure
|
|
88
|
+
* dispatch surface, unit-tested without mounting ink.
|
|
89
|
+
*/
|
|
90
|
+
function keypressToDecision(key) {
|
|
91
|
+
const clean = key.replace(/[\r\n]/g, "");
|
|
92
|
+
if (clean === "y") return { approved: true };
|
|
93
|
+
if (clean === "a") return {
|
|
94
|
+
approved: true,
|
|
95
|
+
remember: true
|
|
96
|
+
};
|
|
97
|
+
if (clean === "n") return {
|
|
98
|
+
approved: false,
|
|
99
|
+
reason: "user denied"
|
|
100
|
+
};
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/input.ts
|
|
105
|
+
function emptyEditor() {
|
|
106
|
+
return {
|
|
107
|
+
text: "",
|
|
108
|
+
cursor: 0
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function editorInsert(state, insert) {
|
|
112
|
+
return {
|
|
113
|
+
text: state.text.slice(0, state.cursor) + insert + state.text.slice(state.cursor),
|
|
114
|
+
cursor: state.cursor + insert.length
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function editorBackspace(state) {
|
|
118
|
+
if (state.cursor === 0) return state;
|
|
119
|
+
return {
|
|
120
|
+
text: state.text.slice(0, state.cursor - 1) + state.text.slice(state.cursor),
|
|
121
|
+
cursor: state.cursor - 1
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Emacs set (§6f) as pure (text, cursor) transforms: ctrl+a/e home/end,
|
|
126
|
+
* ctrl+b/f char moves, ctrl+k kill to end, ctrl+u clear, ctrl+w kill word
|
|
127
|
+
* back. Single-line editor only; multiline ctrl+j is v0.6.
|
|
128
|
+
*/
|
|
129
|
+
function applyEditorAction(state, action) {
|
|
130
|
+
const len = state.text.length;
|
|
131
|
+
switch (action) {
|
|
132
|
+
case "home": return {
|
|
133
|
+
text: state.text,
|
|
134
|
+
cursor: 0
|
|
135
|
+
};
|
|
136
|
+
case "end": return {
|
|
137
|
+
text: state.text,
|
|
138
|
+
cursor: len
|
|
139
|
+
};
|
|
140
|
+
case "char.back": return {
|
|
141
|
+
text: state.text,
|
|
142
|
+
cursor: Math.max(0, cursorAt(state) - 1)
|
|
143
|
+
};
|
|
144
|
+
case "char.forward": return {
|
|
145
|
+
text: state.text,
|
|
146
|
+
cursor: Math.min(len, cursorAt(state) + 1)
|
|
147
|
+
};
|
|
148
|
+
case "kill.to-end": return {
|
|
149
|
+
text: state.text.slice(0, cursorAt(state)),
|
|
150
|
+
cursor: cursorAt(state)
|
|
151
|
+
};
|
|
152
|
+
case "kill.line": return emptyEditor();
|
|
153
|
+
case "kill.word": {
|
|
154
|
+
const cursor = cursorAt(state);
|
|
155
|
+
let index = cursor;
|
|
156
|
+
while (index > 0 && state.text[index - 1] === " ") index -= 1;
|
|
157
|
+
while (index > 0 && state.text[index - 1] !== " ") index -= 1;
|
|
158
|
+
return {
|
|
159
|
+
text: state.text.slice(0, index) + state.text.slice(cursor),
|
|
160
|
+
cursor: index
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function cursorAt(state) {
|
|
166
|
+
return Math.min(state.cursor, state.text.length);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Chip thresholds (§6e): >3 lines or >800 chars chips; under 12 terminal
|
|
170
|
+
* rows the CC narrow rule applies (1 line / 200 chars).
|
|
171
|
+
*/
|
|
172
|
+
function pasteChip(text, rows) {
|
|
173
|
+
const lines = text.split("\n").length;
|
|
174
|
+
const narrow = rows !== void 0 && rows < 12;
|
|
175
|
+
const lineLimit = narrow ? 1 : 3;
|
|
176
|
+
const charLimit = narrow ? 200 : 800;
|
|
177
|
+
return {
|
|
178
|
+
chip: lines > lineLimit || text.length > charLimit ? `[pasted +${lines} lines]` : null,
|
|
179
|
+
lines
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Restores full paste text from its chip markers before submit. */
|
|
183
|
+
function expandPasteChips(text, chunks) {
|
|
184
|
+
let expanded = text;
|
|
185
|
+
for (const [chip, full] of chunks) if (expanded.includes(chip)) expanded = expanded.split(chip).join(full);
|
|
186
|
+
return expanded;
|
|
187
|
+
}
|
|
188
|
+
/** File-backed history; the path is injectable so tests never touch $HOME. */
|
|
189
|
+
function historyPath(home = homedir()) {
|
|
190
|
+
return join(home, ".chantier", "history.jsonl");
|
|
191
|
+
}
|
|
192
|
+
function createHistoryStore(opts = {}) {
|
|
193
|
+
const entries = [...opts.entries ?? []];
|
|
194
|
+
let index = null;
|
|
195
|
+
return {
|
|
196
|
+
get entries() {
|
|
197
|
+
return entries;
|
|
198
|
+
},
|
|
199
|
+
async record(text) {
|
|
200
|
+
if (text.trim().length === 0) return;
|
|
201
|
+
if (entries[entries.length - 1] === text) return;
|
|
202
|
+
entries.push(text);
|
|
203
|
+
if (opts.file === void 0) return;
|
|
204
|
+
try {
|
|
205
|
+
await mkdir(join(opts.file, ".."), { recursive: true });
|
|
206
|
+
await appendFile(opts.file, `${JSON.stringify({ text })}\n`, "utf8");
|
|
207
|
+
} catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
prev() {
|
|
212
|
+
if (entries.length === 0) return null;
|
|
213
|
+
index = index === null ? entries.length - 1 : Math.max(0, index - 1);
|
|
214
|
+
return entries[index] ?? null;
|
|
215
|
+
},
|
|
216
|
+
next() {
|
|
217
|
+
if (index === null) return null;
|
|
218
|
+
index += 1;
|
|
219
|
+
if (index >= entries.length) {
|
|
220
|
+
index = null;
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
return entries[index] ?? null;
|
|
224
|
+
},
|
|
225
|
+
reset() {
|
|
226
|
+
index = null;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** Reads the JSONL history; silent failure yields an empty list. */
|
|
231
|
+
async function loadHistory(file) {
|
|
232
|
+
try {
|
|
233
|
+
const raw = await readFile(file, "utf8");
|
|
234
|
+
const texts = [];
|
|
235
|
+
for (const line of raw.split("\n")) {
|
|
236
|
+
if (line.trim().length === 0) continue;
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(line);
|
|
239
|
+
if (typeof parsed === "object" && parsed !== null && "text" in parsed && typeof parsed.text === "string") texts.push(parsed.text);
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
return texts;
|
|
243
|
+
} catch {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const QUIT_HINT = "press ctrl-c again to quit";
|
|
248
|
+
const INPUT_HINT_PARTS = [
|
|
249
|
+
"type a task",
|
|
250
|
+
"enter run",
|
|
251
|
+
"q quit"
|
|
252
|
+
];
|
|
253
|
+
/** Default two-stage quit window (§6c). */
|
|
254
|
+
/** Two-stage quit window (§6c); exported so the App's armed-hint display matches. */
|
|
255
|
+
const QUIT_WINDOW_MS = 2e3;
|
|
256
|
+
function TaskInput({ editor, onEditorChange, onSubmit, running, queuedCount, onQueueEdit, locked = false, rows, history, onQuit, onQuitArm, quitWindowMs = QUIT_WINDOW_MS, symbols, screenReader = false }) {
|
|
257
|
+
const pasteChunks = useRef(/* @__PURE__ */ new Map());
|
|
258
|
+
const quitTimer = useRef(void 0);
|
|
259
|
+
const quitArmed = useRef(false);
|
|
260
|
+
usePaste((text) => {
|
|
261
|
+
if (locked) return;
|
|
262
|
+
const { chip } = pasteChip(text, rows);
|
|
263
|
+
if (chip === null) {
|
|
264
|
+
onEditorChange(editorInsert(editor, text));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
let chipText = chip;
|
|
268
|
+
let suffix = 2;
|
|
269
|
+
while (pasteChunks.current.has(chipText)) {
|
|
270
|
+
chipText = `${chip} #${suffix}`;
|
|
271
|
+
suffix += 1;
|
|
272
|
+
}
|
|
273
|
+
pasteChunks.current.set(chipText, text);
|
|
274
|
+
onEditorChange(editorInsert(editor, chipText));
|
|
275
|
+
}, { isActive: !locked });
|
|
276
|
+
useInput((input, key) => {
|
|
277
|
+
if (locked) return;
|
|
278
|
+
const event = {
|
|
279
|
+
input,
|
|
280
|
+
ctrl: key.ctrl,
|
|
281
|
+
escape: key.escape,
|
|
282
|
+
upArrow: key.upArrow,
|
|
283
|
+
downArrow: key.downArrow,
|
|
284
|
+
return: key.return,
|
|
285
|
+
backspace: key.backspace,
|
|
286
|
+
delete: key.delete
|
|
287
|
+
};
|
|
288
|
+
if (matches(event, "app.quit")) {
|
|
289
|
+
if (!running || quitArmed.current) {
|
|
290
|
+
quitArmed.current = false;
|
|
291
|
+
clearTimeout(quitTimer.current);
|
|
292
|
+
onQuit();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
quitArmed.current = true;
|
|
296
|
+
onEditorChange(emptyEditor());
|
|
297
|
+
onQuitArm();
|
|
298
|
+
clearTimeout(quitTimer.current);
|
|
299
|
+
quitTimer.current = setTimeout(() => {
|
|
300
|
+
quitArmed.current = false;
|
|
301
|
+
quitTimer.current = void 0;
|
|
302
|
+
}, quitWindowMs);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (matches(event, "app.redraw")) return;
|
|
306
|
+
if (key.escape) {
|
|
307
|
+
if (!running && editor.text.length > 0 && history !== void 0) {
|
|
308
|
+
history.record(editor.text);
|
|
309
|
+
onEditorChange(emptyEditor());
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (matches(event, "app.editor.home")) return onEditorChange(applyEditorAction(editor, "home"));
|
|
314
|
+
if (matches(event, "app.editor.end")) return onEditorChange(applyEditorAction(editor, "end"));
|
|
315
|
+
if (matches(event, "app.editor.char.back")) return onEditorChange(applyEditorAction(editor, "char.back"));
|
|
316
|
+
if (matches(event, "app.editor.char.forward")) return onEditorChange(applyEditorAction(editor, "char.forward"));
|
|
317
|
+
if (matches(event, "app.editor.kill.to-end")) return onEditorChange(applyEditorAction(editor, "kill.to-end"));
|
|
318
|
+
if (matches(event, "app.editor.kill.line")) return onEditorChange(applyEditorAction(editor, "kill.line"));
|
|
319
|
+
if (matches(event, "app.editor.kill.word")) return onEditorChange(applyEditorAction(editor, "kill.word"));
|
|
320
|
+
if (matches(event, "app.editor.backspace")) return onEditorChange(editorBackspace(editor));
|
|
321
|
+
if (key.upArrow) {
|
|
322
|
+
if (running && queuedCount > 0) return onQueueEdit();
|
|
323
|
+
if (!running && history !== void 0) {
|
|
324
|
+
const recalled = history.prev();
|
|
325
|
+
if (recalled !== null) return onEditorChange({
|
|
326
|
+
text: recalled,
|
|
327
|
+
cursor: recalled.length
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (key.downArrow) {
|
|
334
|
+
if (history !== void 0 && !running) {
|
|
335
|
+
const recalled = history.next();
|
|
336
|
+
if (recalled === null) return onEditorChange(emptyEditor());
|
|
337
|
+
return onEditorChange({
|
|
338
|
+
text: recalled,
|
|
339
|
+
cursor: recalled.length
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const bundledReturn = /[\r\n]/.test(input ?? "");
|
|
345
|
+
let current = editor;
|
|
346
|
+
if (input !== void 0 && input.length > 0) {
|
|
347
|
+
const printable = [...input].filter((char) => char !== "\r" && char !== "\n");
|
|
348
|
+
if (printable.length > 0) {
|
|
349
|
+
current = editorInsert(current, printable.join(""));
|
|
350
|
+
onEditorChange(current);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (key.return || bundledReturn) {
|
|
354
|
+
const expanded = expandPasteChips(current.text, pasteChunks.current);
|
|
355
|
+
pasteChunks.current.clear();
|
|
356
|
+
history?.record(expanded);
|
|
357
|
+
history?.reset();
|
|
358
|
+
onSubmit(expanded);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
if (screenReader) return createElement(Box, { flexDirection: "column" }, createElement(Text, { "aria-label": "task input" }, editor.text), createElement(Text, { dimColor: true }, ` ${INPUT_HINT_PARTS.join(` ${symbols.hintSeparator} `)}`));
|
|
362
|
+
const cursor = Math.min(editor.cursor, editor.text.length);
|
|
363
|
+
return createElement(Box, {
|
|
364
|
+
borderStyle: symbols.border,
|
|
365
|
+
borderColor: "green",
|
|
366
|
+
paddingX: 1
|
|
367
|
+
}, createElement(Text, { color: "green" }, "> "), createElement(Text, null, editor.text.slice(0, cursor)), createElement(Text, { inverse: true }, editor.text.slice(cursor, cursor + 1) || " "), createElement(Text, null, editor.text.slice(cursor + 1)), createElement(Text, { dimColor: true }, ` ${INPUT_HINT_PARTS.join(` ${symbols.hintSeparator} `)}`));
|
|
368
|
+
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/markdown.ts
|
|
371
|
+
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
372
|
+
function fenceMarker(line) {
|
|
373
|
+
const match = FENCE_OPEN_RE.exec(line);
|
|
374
|
+
return match === null ? null : match[1] ?? null;
|
|
375
|
+
}
|
|
376
|
+
/** A closing fence is the same marker run plus optional whitespace, nothing else. */
|
|
377
|
+
function closesFence(line, marker) {
|
|
378
|
+
const rest = line.replace(/^ {0,3}/, "");
|
|
379
|
+
const first = marker[0];
|
|
380
|
+
if (first === void 0 || !rest.startsWith(first)) return false;
|
|
381
|
+
let run = 0;
|
|
382
|
+
while (run < rest.length && rest[run] === first) run += 1;
|
|
383
|
+
return run >= marker.length && rest.slice(run).trim() === "";
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Splits a stream buffer at its last safe split point: the final `\n\n`
|
|
387
|
+
* outside an open fence. When the buffer ends inside an open fence and no
|
|
388
|
+
* outside boundary exists, it splits BEFORE the fence start instead, so a
|
|
389
|
+
* half-open code block is never emitted into the finalized transcript. With
|
|
390
|
+
* neither a boundary nor an open fence, nothing flushes.
|
|
391
|
+
*/
|
|
392
|
+
function takeSafeFlush(buffer) {
|
|
393
|
+
if (buffer.length === 0) return {
|
|
394
|
+
flushed: "",
|
|
395
|
+
rest: buffer
|
|
396
|
+
};
|
|
397
|
+
const lines = buffer.split("\n");
|
|
398
|
+
const offsets = [];
|
|
399
|
+
let cursor = 0;
|
|
400
|
+
for (const line of lines) {
|
|
401
|
+
offsets.push(cursor);
|
|
402
|
+
cursor += line.length + 1;
|
|
403
|
+
}
|
|
404
|
+
let inFence = false;
|
|
405
|
+
let openMarker = "";
|
|
406
|
+
let fenceStart = -1;
|
|
407
|
+
let lastBoundaryLine = -1;
|
|
408
|
+
for (let k = 0; k < lines.length; k += 1) {
|
|
409
|
+
const line = lines[k];
|
|
410
|
+
if (line === void 0) break;
|
|
411
|
+
if (inFence) {
|
|
412
|
+
if (closesFence(line, openMarker)) {
|
|
413
|
+
inFence = false;
|
|
414
|
+
openMarker = "";
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const marker = fenceMarker(line);
|
|
419
|
+
if (marker !== null) {
|
|
420
|
+
inFence = true;
|
|
421
|
+
openMarker = marker;
|
|
422
|
+
fenceStart = k;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (line === "" && k > 0 && k < lines.length - 1) lastBoundaryLine = k;
|
|
426
|
+
}
|
|
427
|
+
if (lastBoundaryLine >= 0) {
|
|
428
|
+
const boundary = (offsets[lastBoundaryLine] ?? 0) + 1;
|
|
429
|
+
return {
|
|
430
|
+
flushed: buffer.slice(0, boundary),
|
|
431
|
+
rest: buffer.slice(boundary)
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (inFence && fenceStart > 0) {
|
|
435
|
+
const boundary = offsets[fenceStart] ?? 0;
|
|
436
|
+
return {
|
|
437
|
+
flushed: buffer.slice(0, boundary),
|
|
438
|
+
rest: buffer.slice(boundary)
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
flushed: "",
|
|
443
|
+
rest: buffer
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Cheap streaming fast path (CC ch13): scans only the first 500 chars so a
|
|
448
|
+
* plain-prose stream skips the block parser entirely. Triggers mirror the
|
|
449
|
+
* parser's styled block set — headings, fences, lists, rules, and pipe-led
|
|
450
|
+
* table rows — so a table or rule is never swallowed by the plain path.
|
|
451
|
+
*/
|
|
452
|
+
const FAST_PATH_WINDOW = 500;
|
|
453
|
+
const MARKDOWN_TRIGGER_RE = /(^ {0,3}(#{1,6}\s|```|~~~|[-*+]\s|\d{1,9}[.)]\s))|(^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$)|(^\s*\|)/m;
|
|
454
|
+
function hasMarkdownSyntax(text) {
|
|
455
|
+
return MARKDOWN_TRIGGER_RE.test(text.slice(0, FAST_PATH_WINDOW));
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Tool output and model text can embed ANSI escapes (color codes survive an
|
|
459
|
+
* adapter round-trip); ink renders the raw escape bytes as garbage, so fenced
|
|
460
|
+
* content is stripped before display. No ANSI passthrough, ever.
|
|
461
|
+
*/
|
|
462
|
+
const ANSI_RE = /\u001B\[[0-9;:?]*[A-Za-z]|\u001B/g;
|
|
463
|
+
function stripAnsi(text) {
|
|
464
|
+
return text.replace(ANSI_RE, "");
|
|
465
|
+
}
|
|
466
|
+
const HEADING_RE = /^ {0,3}#{1,6}\s+(.+)$/;
|
|
467
|
+
const HR_RE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
468
|
+
const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d{1,9}[.)])\s+/;
|
|
469
|
+
const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
|
|
470
|
+
/** Splits one table row into trimmed cells; edge pipes are dropped. */
|
|
471
|
+
function tableCells(line) {
|
|
472
|
+
let cells = line.split("|");
|
|
473
|
+
if (line.trimStart().startsWith("|")) cells = cells.slice(1);
|
|
474
|
+
if (line.trimEnd().endsWith("|")) cells = cells.slice(0, -1);
|
|
475
|
+
return cells.map((cell) => cell.trim());
|
|
476
|
+
}
|
|
477
|
+
/** Line-based block parser (spec §2b). Blank lines are paragraph separators. */
|
|
478
|
+
function parseBlocks(text) {
|
|
479
|
+
const lines = text.split("\n");
|
|
480
|
+
const lineAt = (index) => lines[index] ?? "";
|
|
481
|
+
const blocks = [];
|
|
482
|
+
let k = 0;
|
|
483
|
+
while (k < lines.length) {
|
|
484
|
+
const line = lineAt(k);
|
|
485
|
+
if (line.trim() === "") {
|
|
486
|
+
k += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const heading = HEADING_RE.exec(line);
|
|
490
|
+
if (heading !== null) {
|
|
491
|
+
blocks.push({
|
|
492
|
+
kind: "heading",
|
|
493
|
+
text: heading[1]?.trim() ?? ""
|
|
494
|
+
});
|
|
495
|
+
k += 1;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const marker = fenceMarker(line);
|
|
499
|
+
if (marker !== null) {
|
|
500
|
+
const body = [];
|
|
501
|
+
let closed = false;
|
|
502
|
+
k += 1;
|
|
503
|
+
while (k < lines.length) {
|
|
504
|
+
if (closesFence(lineAt(k), marker)) {
|
|
505
|
+
closed = true;
|
|
506
|
+
k += 1;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
body.push(lineAt(k));
|
|
510
|
+
k += 1;
|
|
511
|
+
}
|
|
512
|
+
blocks.push({
|
|
513
|
+
kind: "fence",
|
|
514
|
+
lines: body,
|
|
515
|
+
closed
|
|
516
|
+
});
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (HR_RE.test(line)) {
|
|
520
|
+
blocks.push({ kind: "rule" });
|
|
521
|
+
k += 1;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (LIST_ITEM_RE.test(line)) {
|
|
525
|
+
const block = [];
|
|
526
|
+
while (k < lines.length && lineAt(k).trim() !== "" && LIST_ITEM_RE.test(lineAt(k))) {
|
|
527
|
+
block.push(lineAt(k));
|
|
528
|
+
k += 1;
|
|
529
|
+
}
|
|
530
|
+
blocks.push({
|
|
531
|
+
kind: "list",
|
|
532
|
+
lines: block
|
|
533
|
+
});
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (line.includes("|") && k + 1 < lines.length && TABLE_SEPARATOR_RE.test(lineAt(k + 1))) {
|
|
537
|
+
const rows = [tableCells(line)];
|
|
538
|
+
k += 2;
|
|
539
|
+
while (k < lines.length && lineAt(k).includes("|") && lineAt(k).trim() !== "") {
|
|
540
|
+
rows.push(tableCells(lineAt(k)));
|
|
541
|
+
k += 1;
|
|
542
|
+
}
|
|
543
|
+
blocks.push({
|
|
544
|
+
kind: "table",
|
|
545
|
+
rows
|
|
546
|
+
});
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const paragraph = [];
|
|
550
|
+
while (k < lines.length && lineAt(k).trim() !== "" && HEADING_RE.test(lineAt(k)) === false && fenceMarker(lineAt(k)) === null && HR_RE.test(lineAt(k)) === false && LIST_ITEM_RE.test(lineAt(k)) === false) {
|
|
551
|
+
paragraph.push(lineAt(k));
|
|
552
|
+
k += 1;
|
|
553
|
+
}
|
|
554
|
+
blocks.push({
|
|
555
|
+
kind: "paragraph",
|
|
556
|
+
lines: paragraph
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return blocks;
|
|
560
|
+
}
|
|
561
|
+
const DIVIDER_UNICODE = "─";
|
|
562
|
+
const DIVIDER_ASCII = "-";
|
|
563
|
+
const DIVIDER_WIDTH = 40;
|
|
564
|
+
/**
|
|
565
|
+
* Divider/hr rule character. symbols.ts belongs to the parallel activity
|
|
566
|
+
* worker; its `border` field already encodes the ASCII-mode decision, so the
|
|
567
|
+
* rule glyph derives from it until the integration owner migrates both call
|
|
568
|
+
* sites onto that worker's dedicated symbols extension.
|
|
569
|
+
*/
|
|
570
|
+
function dividerGlyph(symbols) {
|
|
571
|
+
return symbols.border === "single" ? DIVIDER_ASCII : DIVIDER_UNICODE;
|
|
572
|
+
}
|
|
573
|
+
/** Longest cell per column; rows are padded to these for space alignment. */
|
|
574
|
+
function columnWidths(rows) {
|
|
575
|
+
const widths = [];
|
|
576
|
+
for (const row of rows) row.forEach((cell, column) => {
|
|
577
|
+
widths[column] = Math.max(widths[column] ?? 0, cell.length);
|
|
578
|
+
});
|
|
579
|
+
return widths;
|
|
580
|
+
}
|
|
581
|
+
/** A dim rule wrapped around `text`; screen readers get the plain text only. */
|
|
582
|
+
function markdownDivider(text, symbols, screenReader) {
|
|
583
|
+
if (screenReader) return createElement(Text, null, text);
|
|
584
|
+
const rule = dividerGlyph(symbols).repeat(DIVIDER_WIDTH);
|
|
585
|
+
return createElement(Text, { dimColor: true }, `${rule} ${text} ${rule}`);
|
|
586
|
+
}
|
|
587
|
+
/** Parses text into render rows; pure, so tests can assert without ink. */
|
|
588
|
+
function renderRows$1(text, symbols) {
|
|
589
|
+
if (!hasMarkdownSyntax(text)) return text.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim() !== "").map((line) => ({ text: line }));
|
|
590
|
+
const rows = [];
|
|
591
|
+
for (const block of parseBlocks(text)) switch (block.kind) {
|
|
592
|
+
case "heading":
|
|
593
|
+
rows.push({
|
|
594
|
+
text: block.text,
|
|
595
|
+
bold: true
|
|
596
|
+
});
|
|
597
|
+
break;
|
|
598
|
+
case "fence":
|
|
599
|
+
if (block.closed) for (const line of block.lines) rows.push({
|
|
600
|
+
text: ` ${stripAnsi(line)}`,
|
|
601
|
+
dim: true
|
|
602
|
+
});
|
|
603
|
+
else for (const line of block.lines) rows.push({ text: stripAnsi(line) });
|
|
604
|
+
break;
|
|
605
|
+
case "list":
|
|
606
|
+
for (const line of block.lines) rows.push({ text: ` ${line.replace(/^ {1,3}/, "")}` });
|
|
607
|
+
break;
|
|
608
|
+
case "rule":
|
|
609
|
+
rows.push({
|
|
610
|
+
text: dividerGlyph(symbols).repeat(DIVIDER_WIDTH),
|
|
611
|
+
dim: true,
|
|
612
|
+
srLabel: "divider"
|
|
613
|
+
});
|
|
614
|
+
break;
|
|
615
|
+
case "table": {
|
|
616
|
+
const widths = columnWidths(block.rows);
|
|
617
|
+
for (const row of block.rows) rows.push({ text: row.map((cell, column) => cell.padEnd(widths[column] ?? cell.length)).join(" ") });
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
case "paragraph": for (const line of block.lines) rows.push({ text: line });
|
|
621
|
+
}
|
|
622
|
+
return rows;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Renders markdown text into ink elements (spec §2b): headings bold, lists
|
|
626
|
+
* indented, fences dim + 2-space indented, rules as divider rows, tables as
|
|
627
|
+
* space-aligned rows. Screen-reader mode renders the same rows unstyled, with
|
|
628
|
+
* decorative rows replaced by their label, and plain input takes the fast
|
|
629
|
+
* path and skips parsing entirely. Tolerant streaming: an unclosed fence
|
|
630
|
+
* renders as plain text until closed, so the strict styling only ever
|
|
631
|
+
* applies to balanced blocks.
|
|
632
|
+
*/
|
|
633
|
+
function markdownToElements(text, symbols, screenReader) {
|
|
634
|
+
const rows = renderRows$1(text, symbols);
|
|
635
|
+
if (screenReader) return rows.filter((row) => row.text.trim() !== "").map((row, index) => createElement(Text, { key: index }, row.srLabel ?? row.text));
|
|
636
|
+
return rows.map((row, index) => createElement(Text, {
|
|
637
|
+
key: index,
|
|
638
|
+
...row.dim === true ? { dimColor: true } : {},
|
|
639
|
+
...row.bold === true ? { bold: true } : {}
|
|
640
|
+
}, row.text));
|
|
641
|
+
}
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/screen-reader.ts
|
|
644
|
+
/**
|
|
645
|
+
* Screen-reader mode: ink serializes the tree to plain linear text (no
|
|
646
|
+
* borders, no colors), keeps `<Static>` append-only, and rewrites the dynamic
|
|
647
|
+
* region only when it changes. Enabled by the `--screen-reader` flag or the
|
|
648
|
+
* CHANTIER_SCREEN_READER=1 environment alias.
|
|
649
|
+
*/
|
|
650
|
+
function resolveScreenReader(flag, env) {
|
|
651
|
+
return flag === true || env === "1";
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/symbols.ts
|
|
655
|
+
const UNICODE_SYMBOLS = {
|
|
656
|
+
border: "round",
|
|
657
|
+
hintSeparator: "·",
|
|
658
|
+
ellipsis: "…",
|
|
659
|
+
spinnerFrames: [
|
|
660
|
+
"⠋",
|
|
661
|
+
"⠙",
|
|
662
|
+
"⠹",
|
|
663
|
+
"⠸",
|
|
664
|
+
"⠼",
|
|
665
|
+
"⠴",
|
|
666
|
+
"⠦",
|
|
667
|
+
"⠧",
|
|
668
|
+
"⠇",
|
|
669
|
+
"⠏"
|
|
670
|
+
],
|
|
671
|
+
runGlyph: "▸",
|
|
672
|
+
subGlyph: "└",
|
|
673
|
+
errorGlyph: "✗",
|
|
674
|
+
rule: "─",
|
|
675
|
+
barFilled: "▮",
|
|
676
|
+
barEmpty: "▯",
|
|
677
|
+
arrow: "→",
|
|
678
|
+
arrowUp: "↑",
|
|
679
|
+
minus: "−"
|
|
680
|
+
};
|
|
681
|
+
const ASCII_SYMBOLS = {
|
|
682
|
+
border: "single",
|
|
683
|
+
hintSeparator: "|",
|
|
684
|
+
ellipsis: "...",
|
|
685
|
+
spinnerFrames: [
|
|
686
|
+
"|",
|
|
687
|
+
"/",
|
|
688
|
+
"-",
|
|
689
|
+
"\\"
|
|
690
|
+
],
|
|
691
|
+
runGlyph: ">",
|
|
692
|
+
subGlyph: "+",
|
|
693
|
+
errorGlyph: "x",
|
|
694
|
+
rule: "-",
|
|
695
|
+
barFilled: "#",
|
|
696
|
+
barEmpty: "-",
|
|
697
|
+
arrow: "->",
|
|
698
|
+
arrowUp: "^",
|
|
699
|
+
minus: "-"
|
|
700
|
+
};
|
|
701
|
+
function resolveSymbols(ascii) {
|
|
702
|
+
return ascii ? ASCII_SYMBOLS : UNICODE_SYMBOLS;
|
|
703
|
+
}
|
|
704
|
+
/** CHANTIER_ASCII=1 requests the ASCII-safe rendering. */
|
|
705
|
+
function isAsciiEnv(env) {
|
|
706
|
+
return env === "1";
|
|
707
|
+
}
|
|
708
|
+
//#endregion
|
|
3
709
|
//#region src/diff.ts
|
|
4
710
|
const META_PREFIXES = [
|
|
5
711
|
"diff --git ",
|
|
@@ -34,79 +740,433 @@ function summarizeUnifiedDiff(diff, maxLines = 10) {
|
|
|
34
740
|
};
|
|
35
741
|
}
|
|
36
742
|
//#endregion
|
|
37
|
-
//#region src/
|
|
743
|
+
//#region src/widgets.ts
|
|
744
|
+
/** Ladder set (§2c): these tools render a second detail line when present. */
|
|
745
|
+
const TWO_LINE_TOOLS = {
|
|
746
|
+
task: true,
|
|
747
|
+
read: true,
|
|
748
|
+
edit: true
|
|
749
|
+
};
|
|
750
|
+
/** First non-empty line of a tool result, capped for the row preview. */
|
|
751
|
+
function previewLine(content, ellipsis, cap = 120) {
|
|
752
|
+
for (const line of content.split("\n")) {
|
|
753
|
+
const trimmed = line.trim();
|
|
754
|
+
if (trimmed.length > 0) return trimmed.length > cap ? `${trimmed.slice(0, cap)}${ellipsis}` : trimmed;
|
|
755
|
+
}
|
|
756
|
+
return "";
|
|
757
|
+
}
|
|
758
|
+
function formatDuration(ms) {
|
|
759
|
+
if (ms === void 0) return "";
|
|
760
|
+
if (ms < 1e3) return `${Math.max(1, Math.round(ms))}ms`;
|
|
761
|
+
const seconds = ms / 1e3;
|
|
762
|
+
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
763
|
+
const minutes = Math.floor(seconds / 60);
|
|
764
|
+
const rest = Math.round(seconds - minutes * 60);
|
|
765
|
+
return rest > 0 ? `${minutes}m ${rest}s` : `${minutes}m`;
|
|
766
|
+
}
|
|
767
|
+
/** "24k"-style token counts for the compaction divider (§2d mockup). */
|
|
768
|
+
function formatTokens(tokens) {
|
|
769
|
+
if (tokens < 1e3) return String(tokens);
|
|
770
|
+
const k = tokens / 1e3;
|
|
771
|
+
const rounded = Math.round(k * 10) / 10;
|
|
772
|
+
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}k`;
|
|
773
|
+
}
|
|
774
|
+
function renderRows(rows) {
|
|
775
|
+
return createElement(Box, { flexDirection: "column" }, ...rows.map((row, index) => createElement(Text, {
|
|
776
|
+
key: index,
|
|
777
|
+
color: row.color,
|
|
778
|
+
bold: row.bold === true,
|
|
779
|
+
dimColor: row.dim === true
|
|
780
|
+
}, row.text)));
|
|
781
|
+
}
|
|
38
782
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* CHANTIER_SCREEN_READER=1 environment alias.
|
|
783
|
+
* Collapse ladder: task/read/edit render two lines (args + detail preview);
|
|
784
|
+
* everything else renders one line. Errors turn the glyph red (§2c). The
|
|
785
|
+
* full output stays in the transcript log; expand/collapse is v0.6.
|
|
43
786
|
*/
|
|
44
|
-
function
|
|
45
|
-
|
|
787
|
+
function toolRowLines(item, symbols) {
|
|
788
|
+
const duration = formatDuration(item.durationMs);
|
|
789
|
+
const durationTail = duration.length > 0 ? ` ${duration}` : "";
|
|
790
|
+
const failed = item.outcome === "error";
|
|
791
|
+
const head = {
|
|
792
|
+
text: `${failed ? symbols.errorGlyph : symbols.runGlyph} ${item.toolName}(${item.argsSummary})${durationTail}`,
|
|
793
|
+
...failed ? { color: "red" } : {}
|
|
794
|
+
};
|
|
795
|
+
const detail = item.detail ?? "";
|
|
796
|
+
if (TWO_LINE_TOOLS[item.toolName] !== true || detail.length === 0) return [head];
|
|
797
|
+
return [head, {
|
|
798
|
+
text: ` ${detail}`,
|
|
799
|
+
dim: true,
|
|
800
|
+
...failed ? { color: "red" } : {}
|
|
801
|
+
}];
|
|
46
802
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
border: "round",
|
|
51
|
-
hintSeparator: "·",
|
|
52
|
-
ellipsis: "…"
|
|
53
|
-
};
|
|
54
|
-
const ASCII_SYMBOLS = {
|
|
55
|
-
border: "single",
|
|
56
|
-
hintSeparator: "|",
|
|
57
|
-
ellipsis: "..."
|
|
58
|
-
};
|
|
59
|
-
function resolveSymbols(ascii) {
|
|
60
|
-
return ascii ? ASCII_SYMBOLS : UNICODE_SYMBOLS;
|
|
803
|
+
/** SR parity (§8): `tool: read(src/config.ts) done`. */
|
|
804
|
+
function toolRowSrText(item) {
|
|
805
|
+
return `tool: ${item.toolName}(${item.argsSummary}) ${item.outcome}`;
|
|
61
806
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
807
|
+
function ToolRow({ item, symbols, screenReader = false }) {
|
|
808
|
+
if (screenReader) return createElement(Text, { key: "sr" }, toolRowSrText(item));
|
|
809
|
+
const child = item.subagent;
|
|
810
|
+
if (child !== void 0) return createElement(SubagentCard, {
|
|
811
|
+
item: {
|
|
812
|
+
...item,
|
|
813
|
+
subagent: child
|
|
814
|
+
},
|
|
815
|
+
symbols
|
|
816
|
+
});
|
|
817
|
+
return renderRows(toolRowLines(item, symbols));
|
|
818
|
+
}
|
|
819
|
+
/** Summary block cap: 8 dim lines, then the tail pointer to the child session. */
|
|
820
|
+
const SUBAGENT_SUMMARY_MAX_LINES = 8;
|
|
821
|
+
function subagentLines(item, symbols) {
|
|
822
|
+
const sessionId = item.subagent.sessionId.slice(0, 8);
|
|
823
|
+
const duration = formatDuration(item.durationMs);
|
|
824
|
+
const durationTail = duration.length > 0 ? ` ${duration}` : "";
|
|
825
|
+
const head = { text: `${symbols.runGlyph} task ${symbols.arrow} subagent (session ${sessionId})${durationTail}` };
|
|
826
|
+
const shown = item.subagent.summary.split("\n").slice(0, 8).map((line) => ({
|
|
827
|
+
text: ` ${line}`,
|
|
828
|
+
dim: true
|
|
829
|
+
}));
|
|
830
|
+
const tail = {
|
|
831
|
+
text: ` ${symbols.ellipsis} full summary in child session`,
|
|
832
|
+
dim: true
|
|
833
|
+
};
|
|
834
|
+
return [
|
|
835
|
+
head,
|
|
836
|
+
...shown,
|
|
837
|
+
tail
|
|
838
|
+
];
|
|
839
|
+
}
|
|
840
|
+
function SubagentCard({ item, symbols }) {
|
|
841
|
+
return renderRows(subagentLines(item, symbols));
|
|
842
|
+
}
|
|
843
|
+
const STATUS_VERB_WIDTH = Math.max(...[
|
|
844
|
+
"working",
|
|
845
|
+
"delegating",
|
|
846
|
+
"thinking"
|
|
847
|
+
].map((verb) => verb.length));
|
|
848
|
+
/** Elapsed clock with a bounded tail: s → "1m 12s" → "1h 2m" → "99h+". */
|
|
849
|
+
function formatElapsed(ms) {
|
|
850
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
|
|
851
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
852
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
853
|
+
const seconds = totalSeconds - minutes * 60;
|
|
854
|
+
if (minutes < 60) return `${minutes}m ${seconds}s`;
|
|
855
|
+
const hours = Math.floor(minutes / 60);
|
|
856
|
+
if (hours > 99) return "99h+";
|
|
857
|
+
return `${hours}h ${minutes - hours * 60}m`;
|
|
858
|
+
}
|
|
859
|
+
function spinnerFrame(frame, symbols) {
|
|
860
|
+
const frames = symbols.spinnerFrames;
|
|
861
|
+
return frames[(frame % frames.length + frames.length) % frames.length] ?? "";
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* The one-line running status: spinner + anti-jitter-padded verb + elapsed +
|
|
865
|
+
* interrupt hint, with the detail line underneath. The verb padding is the
|
|
866
|
+
* Hermes trick: the tail never shifts while the spinner cycles because the
|
|
867
|
+
* verb column width is pinned to the widest verb the widget can show.
|
|
868
|
+
*/
|
|
869
|
+
function statusLines(frame, running, status, symbols, elapsedMs) {
|
|
870
|
+
const verb = running.detail?.startsWith("task") ? "delegating" : status.startsWith("thinking") ? "thinking" : "working";
|
|
871
|
+
const separator = ` ${symbols.hintSeparator} `;
|
|
872
|
+
return [{
|
|
873
|
+
text: `${spinnerFrame(frame, symbols)} ${verb.padEnd(STATUS_VERB_WIDTH)}${separator}${formatElapsed(elapsedMs)}${separator}esc to interrupt`,
|
|
874
|
+
dim: true
|
|
875
|
+
}, ...(running.detail ?? "").split("\n").slice(0, 3).filter((l) => l.length > 0).map((line) => ({
|
|
876
|
+
text: `${symbols.subGlyph} ${line}`,
|
|
877
|
+
dim: true
|
|
878
|
+
}))];
|
|
879
|
+
}
|
|
880
|
+
function StatusWidget({ running, status, symbols, now = Date.now, screenReader = false }) {
|
|
881
|
+
const { frame } = useAnimation({ interval: 120 });
|
|
882
|
+
if (running === null || screenReader) return null;
|
|
883
|
+
return renderRows(statusLines(frame, running, status, symbols, now() - running.sinceMs));
|
|
884
|
+
}
|
|
885
|
+
function formatTokenCount(tokens) {
|
|
886
|
+
if (tokens < 1e3) return String(tokens);
|
|
887
|
+
return `${(Math.round(tokens / 1e3 * 10) / 10).toFixed(1)}k`;
|
|
888
|
+
}
|
|
889
|
+
/** 8-cell context bar; filled cells round to nearest, clamped to [0, 8]. */
|
|
890
|
+
function ctxBar(fraction, symbols) {
|
|
891
|
+
const filled = Math.min(8, Math.max(0, Math.round(fraction * 8)));
|
|
892
|
+
return `${symbols.barFilled.repeat(filled)}${symbols.barEmpty.repeat(8 - filled)}`;
|
|
893
|
+
}
|
|
894
|
+
function ctxLevel(fraction) {
|
|
895
|
+
if (fraction < .5) return { color: "gray" };
|
|
896
|
+
if (fraction < .8) return { color: "yellow" };
|
|
897
|
+
return fraction >= .95 ? {
|
|
898
|
+
color: "red",
|
|
899
|
+
bold: true
|
|
900
|
+
} : { color: "red" };
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Segment list under the width breakpoints: <64 → model + ctx%; <80 → +
|
|
904
|
+
* session; ≥80 → all four. Whole segments only — never mid-truncate.
|
|
905
|
+
*/
|
|
906
|
+
function footerSegments(props) {
|
|
907
|
+
const { model, ctxFraction, compactSoon, usage, sessionId, columns, symbols } = props;
|
|
908
|
+
const showSession = columns >= 64;
|
|
909
|
+
const showTokens = columns >= 80;
|
|
910
|
+
const separator = ` ${symbols.hintSeparator} `;
|
|
911
|
+
const parts = [model];
|
|
912
|
+
if (ctxFraction !== void 0) {
|
|
913
|
+
const soon = compactSoon === true ? `${separator}compaction soon` : "";
|
|
914
|
+
parts.push(`${ctxBar(ctxFraction, symbols)} ${Math.round(ctxFraction * 100)}%${soon}`);
|
|
915
|
+
}
|
|
916
|
+
if (showTokens && usage !== void 0) parts.push(`${formatTokenCount(usage.inputTokens)} in / ${formatTokenCount(usage.outputTokens)} out`);
|
|
917
|
+
if (showSession) parts.push(sessionId.slice(0, 8));
|
|
918
|
+
const level = ctxLevel(ctxFraction ?? 0);
|
|
919
|
+
return [{
|
|
920
|
+
text: parts.join(separator),
|
|
921
|
+
...level
|
|
922
|
+
}];
|
|
923
|
+
}
|
|
924
|
+
function FooterBar({ model, ctxFraction, compactSoon, usage, sessionId, columns, symbols, hidden = false }) {
|
|
925
|
+
if (hidden) return null;
|
|
926
|
+
const [row] = footerSegments({
|
|
927
|
+
model,
|
|
928
|
+
ctxFraction,
|
|
929
|
+
compactSoon,
|
|
930
|
+
usage,
|
|
931
|
+
sessionId,
|
|
932
|
+
columns,
|
|
933
|
+
symbols
|
|
934
|
+
});
|
|
935
|
+
if (row === void 0) return null;
|
|
936
|
+
return createElement(Box, { "aria-hidden": true }, createElement(Text, {
|
|
937
|
+
color: row.color,
|
|
938
|
+
bold: row.bold === true,
|
|
939
|
+
dimColor: row.dim === true
|
|
940
|
+
}, row.text));
|
|
941
|
+
}
|
|
942
|
+
const QUEUE_PREVIEW_MAX_ROWS = 2;
|
|
943
|
+
/** ≤2 dimmed rows + "+N more"; collapses runs of whitespace per row. */
|
|
944
|
+
function queuePreviewLines(queued, symbols) {
|
|
945
|
+
if (queued.length === 0) return [];
|
|
946
|
+
const rows = queued.slice(0, 2).map((text) => ({
|
|
947
|
+
text: `queued: ${text.replace(/\s+/g, " ").trim()} (${symbols.arrowUp} to edit)`,
|
|
948
|
+
dim: true
|
|
949
|
+
}));
|
|
950
|
+
const more = queued.length - 2;
|
|
951
|
+
if (more > 0) rows.push({
|
|
952
|
+
text: `+${more} more`,
|
|
953
|
+
dim: true
|
|
954
|
+
});
|
|
955
|
+
return rows;
|
|
956
|
+
}
|
|
957
|
+
function QueuePreview({ queued, symbols }) {
|
|
958
|
+
const rows = queuePreviewLines(queued, symbols);
|
|
959
|
+
if (rows.length === 0) return null;
|
|
960
|
+
return createElement(Box, {
|
|
961
|
+
flexDirection: "column",
|
|
962
|
+
"aria-label": `${queued.length} task${queued.length === 1 ? "" : "s"} queued`
|
|
963
|
+
}, ...rows.map((row, index) => createElement(Text, {
|
|
964
|
+
key: index,
|
|
965
|
+
color: row.color,
|
|
966
|
+
bold: row.bold === true,
|
|
967
|
+
dimColor: row.dim === true
|
|
968
|
+
}, row.text)));
|
|
969
|
+
}
|
|
970
|
+
const COMMAND_CAP = 80;
|
|
971
|
+
function stringField(input, keys) {
|
|
972
|
+
if (typeof input !== "object" || input === null) return "";
|
|
973
|
+
const record = input;
|
|
974
|
+
for (const key of keys) {
|
|
975
|
+
const value = record[key];
|
|
976
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
977
|
+
}
|
|
978
|
+
return "";
|
|
979
|
+
}
|
|
980
|
+
/** CC-style shortening: a path under the session cwd renders as ~/rest. */
|
|
981
|
+
function shortenPath(path, cwd) {
|
|
982
|
+
if (cwd === void 0 || cwd.length === 0) return path;
|
|
983
|
+
const prefix = cwd.endsWith("/") ? cwd : `${cwd}/`;
|
|
984
|
+
return path.startsWith(prefix) ? `~/${path.slice(prefix.length)}` : path;
|
|
985
|
+
}
|
|
986
|
+
function elide(text, cap, ellipsis) {
|
|
987
|
+
return text.length > cap ? `${text.slice(0, cap)}${ellipsis}` : text;
|
|
988
|
+
}
|
|
989
|
+
/** The ~-shortened edit/write target path (no counts; the SR label reuses it). */
|
|
990
|
+
function editTargetPath(input, cwd) {
|
|
991
|
+
return shortenPath(stringField(input, [
|
|
992
|
+
"file_path",
|
|
993
|
+
"path",
|
|
994
|
+
"file"
|
|
995
|
+
]), cwd);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Humanized subject per tool (§7): edit/write → ~-shortened path + ±counts
|
|
999
|
+
* from the diff detail; bash → command (80 cap); read/grep → the pattern,
|
|
1000
|
+
* glob → its pattern; task → first line of the prompt.
|
|
1001
|
+
*/
|
|
1002
|
+
function humanizeApproval(tool, input, detail, symbols, cwd) {
|
|
1003
|
+
if (tool === "edit" || tool === "write") {
|
|
1004
|
+
const path = editTargetPath(input, cwd);
|
|
1005
|
+
if (detail?.diff !== void 0 && detail.diff.length > 0) {
|
|
1006
|
+
const preview = summarizeUnifiedDiff(detail.diff);
|
|
1007
|
+
return `${path} +${preview.additions} ${symbols.minus}${preview.deletions}`;
|
|
1008
|
+
}
|
|
1009
|
+
return path;
|
|
1010
|
+
}
|
|
1011
|
+
if (tool === "bash") return elide(stringField(input, ["command"]), COMMAND_CAP, symbols.ellipsis);
|
|
1012
|
+
if (tool === "task") return elide(stringField(input, ["prompt"]).split("\n")[0] ?? "", COMMAND_CAP, symbols.ellipsis);
|
|
1013
|
+
if (tool === "grep") return stringField(input, [
|
|
1014
|
+
"pattern",
|
|
1015
|
+
"query",
|
|
1016
|
+
"path"
|
|
1017
|
+
]);
|
|
1018
|
+
if (tool === "glob") return stringField(input, ["pattern", "path"]);
|
|
1019
|
+
return stringField(input, [
|
|
1020
|
+
"path",
|
|
1021
|
+
"file_path",
|
|
1022
|
+
"file",
|
|
1023
|
+
"pattern",
|
|
1024
|
+
"query",
|
|
1025
|
+
"url"
|
|
1026
|
+
]);
|
|
1027
|
+
}
|
|
1028
|
+
/** SR parity (§8): `edit src/x.ts: +3 -1`. */
|
|
1029
|
+
function approvalSrLabel(tool, input, detail, symbols, cwd) {
|
|
1030
|
+
if (tool === "edit" || tool === "write") {
|
|
1031
|
+
const preview = detail?.diff !== void 0 && detail.diff.length > 0 ? summarizeUnifiedDiff(detail.diff) : null;
|
|
1032
|
+
const counts = preview === null ? "" : `: +${preview.additions} -${preview.deletions}`;
|
|
1033
|
+
return `${tool} ${editTargetPath(input, cwd)}${counts}`;
|
|
1034
|
+
}
|
|
1035
|
+
return `approve ${tool} ${humanizeApproval(tool, input, detail, symbols, cwd)}`.trim();
|
|
1036
|
+
}
|
|
1037
|
+
/** Pure card spec so tests assert content without mounting ink. */
|
|
1038
|
+
function approvalCardSpec(props) {
|
|
1039
|
+
const { request, detail, symbols, cwd } = props;
|
|
1040
|
+
const subjectText = humanizeApproval(request.tool, request.input, detail, symbols, cwd);
|
|
1041
|
+
const subject = subjectText.length > 0 ? { text: subjectText } : null;
|
|
1042
|
+
let diffLines = [];
|
|
1043
|
+
let hiddenLines = 0;
|
|
1044
|
+
if (detail?.diff !== void 0 && detail.diff.length > 0) {
|
|
1045
|
+
const preview = summarizeUnifiedDiff(detail.diff);
|
|
1046
|
+
diffLines = preview.lines.map((line) => {
|
|
1047
|
+
const kind = classifyUnifiedDiffLine(line);
|
|
1048
|
+
if (kind === "add") return {
|
|
1049
|
+
text: line,
|
|
1050
|
+
color: "green"
|
|
1051
|
+
};
|
|
1052
|
+
if (kind === "del") return {
|
|
1053
|
+
text: line,
|
|
1054
|
+
color: "red"
|
|
1055
|
+
};
|
|
1056
|
+
if (kind === "meta") return {
|
|
1057
|
+
text: line,
|
|
1058
|
+
dim: true
|
|
1059
|
+
};
|
|
1060
|
+
return { text: line };
|
|
1061
|
+
});
|
|
1062
|
+
hiddenLines = preview.hiddenLines;
|
|
1063
|
+
}
|
|
1064
|
+
return {
|
|
1065
|
+
title: `approve ${request.tool}?`,
|
|
1066
|
+
subject,
|
|
1067
|
+
diffLines,
|
|
1068
|
+
hiddenLines,
|
|
1069
|
+
hints: APPROVAL_HINT_PARTS.join(` ${symbols.hintSeparator} `),
|
|
1070
|
+
srLabel: approvalSrLabel(request.tool, request.input, detail, symbols, cwd)
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
function ApprovalCardV2({ request, detail, symbols, cwd, screenReader = false }) {
|
|
1074
|
+
const spec = approvalCardSpec({
|
|
1075
|
+
request,
|
|
1076
|
+
detail,
|
|
1077
|
+
symbols,
|
|
1078
|
+
cwd
|
|
1079
|
+
});
|
|
1080
|
+
if (screenReader) return createElement(Text, null, spec.srLabel);
|
|
1081
|
+
return createElement(Box, {
|
|
1082
|
+
flexDirection: "column",
|
|
1083
|
+
borderStyle: symbols.border,
|
|
1084
|
+
borderColor: "yellow",
|
|
1085
|
+
paddingX: 1,
|
|
1086
|
+
"aria-role": "button",
|
|
1087
|
+
"aria-label": spec.srLabel
|
|
1088
|
+
}, createElement(Text, {
|
|
1089
|
+
bold: true,
|
|
1090
|
+
color: "yellow"
|
|
1091
|
+
}, spec.title), spec.subject !== null ? createElement(Text, {
|
|
1092
|
+
key: "subject",
|
|
1093
|
+
dimColor: true
|
|
1094
|
+
}, spec.subject.text) : null, ...spec.diffLines.map((row, index) => createElement(Text, {
|
|
1095
|
+
key: index,
|
|
1096
|
+
color: row.color,
|
|
1097
|
+
bold: row.bold === true,
|
|
1098
|
+
dimColor: row.dim === true
|
|
1099
|
+
}, row.text)), spec.hiddenLines > 0 ? createElement(Text, {
|
|
1100
|
+
key: "more",
|
|
1101
|
+
dimColor: true
|
|
1102
|
+
}, `${symbols.ellipsis} ${spec.hiddenLines} more lines`) : null, createElement(Text, { key: "hints" }, spec.hints));
|
|
1103
|
+
}
|
|
1104
|
+
/** `── {text} ──` with the ASCII `--` fallback. */
|
|
1105
|
+
function dividerLine(text, symbols) {
|
|
1106
|
+
const rule = symbols.rule.repeat(2);
|
|
1107
|
+
return `${rule} ${text} ${rule}`;
|
|
1108
|
+
}
|
|
1109
|
+
function Divider({ text, symbols, screenReader = false }) {
|
|
1110
|
+
return createElement(Text, { dimColor: !screenReader }, screenReader ? text : dividerLine(text, symbols));
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Hermes mechanism: when no tool item survives a visibility filter, the last
|
|
1114
|
+
* error item is forced back into view — quiet mode must never hide failures.
|
|
1115
|
+
*/
|
|
1116
|
+
function withErrorBackstop(items, isVisible) {
|
|
1117
|
+
const visible = items.filter(isVisible);
|
|
1118
|
+
if (visible.some((item) => item.kind === "tool")) return visible;
|
|
1119
|
+
let lastError;
|
|
1120
|
+
for (const item of items) if (item.kind === "error") lastError = item;
|
|
1121
|
+
if (lastError === void 0) return visible;
|
|
1122
|
+
const forced = new Set(visible);
|
|
1123
|
+
forced.add(lastError);
|
|
1124
|
+
return items.filter((item) => forced.has(item));
|
|
65
1125
|
}
|
|
66
1126
|
//#endregion
|
|
67
1127
|
//#region src/app.ts
|
|
68
|
-
|
|
69
|
-
"y allow",
|
|
70
|
-
"a always",
|
|
71
|
-
"n deny",
|
|
72
|
-
"esc abort"
|
|
73
|
-
];
|
|
74
|
-
const INPUT_HINT_PARTS = [
|
|
75
|
-
"type a task",
|
|
76
|
-
"enter run",
|
|
77
|
-
"q quit"
|
|
78
|
-
];
|
|
79
|
-
function TuiApp({ store }) {
|
|
1128
|
+
function TuiApp({ store, footer }) {
|
|
80
1129
|
const state = useSyncExternalStore(store.subscribe, () => store.state);
|
|
81
1130
|
const screenReader = useIsScreenReaderEnabled();
|
|
82
1131
|
const symbols = resolveSymbols(screenReader || isAsciiEnv(process.env.CHANTIER_ASCII));
|
|
83
1132
|
const { exit } = useApp();
|
|
1133
|
+
const { columns, rows } = useWindowSize();
|
|
1134
|
+
const [editor, setEditor] = useState(emptyEditor());
|
|
1135
|
+
const [quitArmed, setQuitArmed] = useState(false);
|
|
1136
|
+
const quitHintTimer = useRef(void 0);
|
|
1137
|
+
const armQuitHint = () => {
|
|
1138
|
+
setQuitArmed(true);
|
|
1139
|
+
clearTimeout(quitHintTimer.current);
|
|
1140
|
+
quitHintTimer.current = setTimeout(() => {
|
|
1141
|
+
setQuitArmed(false);
|
|
1142
|
+
quitHintTimer.current = void 0;
|
|
1143
|
+
}, QUIT_WINDOW_MS);
|
|
1144
|
+
};
|
|
1145
|
+
const [history, setHistory] = useState(void 0);
|
|
1146
|
+
useEffect(() => {
|
|
1147
|
+
loadHistory(historyPath()).then((entries) => {
|
|
1148
|
+
setHistory(createHistoryStore({
|
|
1149
|
+
entries,
|
|
1150
|
+
file: historyPath()
|
|
1151
|
+
}));
|
|
1152
|
+
});
|
|
1153
|
+
}, []);
|
|
84
1154
|
useInput((input, key) => {
|
|
85
|
-
const {
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (key.escape) {
|
|
91
|
-
store.abort("escape");
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (mode === "input" && prompt === null) {
|
|
95
|
-
if (key.backspace || key.delete) {
|
|
96
|
-
store.backspaceInput();
|
|
1155
|
+
const { prompt, running } = store.state;
|
|
1156
|
+
if (prompt !== null) {
|
|
1157
|
+
const decision = keypressToDecision((input ?? "").replace(/[\r\n]/g, ""));
|
|
1158
|
+
if (decision !== null) {
|
|
1159
|
+
store.decide(decision);
|
|
97
1160
|
return;
|
|
98
1161
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
1162
|
+
if (key.escape) {
|
|
1163
|
+
store.abort("escape");
|
|
1164
|
+
return;
|
|
102
1165
|
}
|
|
103
|
-
if (key.
|
|
1166
|
+
if (key.ctrl) store.abort("ctrl-c");
|
|
104
1167
|
return;
|
|
105
1168
|
}
|
|
106
|
-
if (
|
|
107
|
-
const decision = keypressToDecision(input.replace(/[\r\n]/g, ""));
|
|
108
|
-
if (decision !== null) store.decide(decision);
|
|
109
|
-
}
|
|
1169
|
+
if (key.escape && running !== null) store.abort("escape");
|
|
110
1170
|
});
|
|
111
1171
|
useEffect(() => {
|
|
112
1172
|
if (!store.state.finished) return;
|
|
@@ -114,60 +1174,103 @@ function TuiApp({ store }) {
|
|
|
114
1174
|
return () => clearTimeout(timer);
|
|
115
1175
|
});
|
|
116
1176
|
const children = [createElement(Static, {
|
|
117
|
-
items: [...state.
|
|
118
|
-
children: (item, index) =>
|
|
1177
|
+
items: [...state.items],
|
|
1178
|
+
children: (item, index) => itemNode(item, index, symbols, screenReader)
|
|
119
1179
|
})];
|
|
120
1180
|
if (state.streamText.length > 0) children.push(createElement(Text, {
|
|
121
1181
|
color: "cyan",
|
|
122
1182
|
"aria-hidden": screenReader
|
|
123
1183
|
}, state.streamText));
|
|
124
|
-
if (state.
|
|
1184
|
+
if (state.running !== null && state.prompt === null) children.push(createElement(StatusWidget, {
|
|
1185
|
+
running: state.running,
|
|
1186
|
+
status: state.statusFlash.length > 0 ? state.statusFlash : state.status,
|
|
1187
|
+
symbols,
|
|
1188
|
+
screenReader
|
|
1189
|
+
}));
|
|
125
1190
|
if (state.prompt !== null) {
|
|
126
|
-
const
|
|
127
|
-
children.push(createElement(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}, createElement(Text, {
|
|
134
|
-
bold: true,
|
|
135
|
-
color: "yellow",
|
|
136
|
-
"aria-label": approvalLabel(tool, input)
|
|
137
|
-
}, `approve ${tool}?`), createElement(Text, { dimColor: true }, summarizeInput(input, symbols.ellipsis)), diffCard(state.promptDetail, symbols), createElement(Text, null, PROMPT_HINT_PARTS.join(` ${symbols.hintSeparator} `))));
|
|
1191
|
+
const detail = state.promptDetail;
|
|
1192
|
+
children.push(createElement(ApprovalCardV2, {
|
|
1193
|
+
request: state.prompt,
|
|
1194
|
+
detail: detail === null ? null : { diff: detail.diff },
|
|
1195
|
+
symbols,
|
|
1196
|
+
screenReader
|
|
1197
|
+
}));
|
|
138
1198
|
}
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1199
|
+
if (!state.finished) {
|
|
1200
|
+
if (quitArmed) children.push(createElement(Text, { dimColor: true }, "press ctrl-c again to quit"));
|
|
1201
|
+
children.push(createElement(TaskInput, {
|
|
1202
|
+
editor,
|
|
1203
|
+
onEditorChange: (next) => setEditor(next),
|
|
1204
|
+
onSubmit: (text) => {
|
|
1205
|
+
setEditor(emptyEditor());
|
|
1206
|
+
if (store.state.running !== null) {
|
|
1207
|
+
store.pushQueued(text);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
store.submitTask(text);
|
|
1211
|
+
},
|
|
1212
|
+
running: state.running !== null,
|
|
1213
|
+
queuedCount: state.queued.length,
|
|
1214
|
+
onQueueEdit: () => {
|
|
1215
|
+
const last = store.state.queued.at(-1);
|
|
1216
|
+
if (last === void 0) return;
|
|
1217
|
+
store.dropQueued();
|
|
1218
|
+
setEditor({
|
|
1219
|
+
text: last,
|
|
1220
|
+
cursor: last.length
|
|
1221
|
+
});
|
|
1222
|
+
},
|
|
1223
|
+
locked: state.prompt !== null,
|
|
1224
|
+
rows,
|
|
1225
|
+
history,
|
|
1226
|
+
onQuit: () => store.abort("ctrl-c"),
|
|
1227
|
+
onQuitArm: armQuitHint,
|
|
1228
|
+
symbols,
|
|
1229
|
+
screenReader
|
|
1230
|
+
}));
|
|
1231
|
+
}
|
|
1232
|
+
children.push(createElement(QueuePreview, {
|
|
1233
|
+
queued: state.queued,
|
|
1234
|
+
symbols
|
|
1235
|
+
}));
|
|
1236
|
+
const footerData = footer?.data?.();
|
|
1237
|
+
children.push(createElement(FooterBar, {
|
|
1238
|
+
model: footer?.model ?? "chantier",
|
|
1239
|
+
ctxFraction: footerData?.ctxFraction,
|
|
1240
|
+
compactSoon: footerData?.compactSoon === true ? true : void 0,
|
|
1241
|
+
usage: state.usage,
|
|
1242
|
+
sessionId: footer?.sessionId ?? "",
|
|
1243
|
+
columns,
|
|
1244
|
+
symbols,
|
|
1245
|
+
hidden: state.prompt !== null
|
|
1246
|
+
}));
|
|
144
1247
|
return createElement(Box, { flexDirection: "column" }, ...children);
|
|
145
1248
|
}
|
|
146
|
-
/**
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
1249
|
+
/** Renders one finalized transcript item (spec §1). */
|
|
1250
|
+
function itemNode(item, index, symbols, screenReader) {
|
|
1251
|
+
switch (item.kind) {
|
|
1252
|
+
case "markdown": return createElement(Box, {
|
|
1253
|
+
key: index,
|
|
1254
|
+
flexDirection: "column"
|
|
1255
|
+
}, ...markdownToElements(item.text, symbols, screenReader));
|
|
1256
|
+
case "divider": return createElement(Divider, {
|
|
1257
|
+
key: index,
|
|
1258
|
+
text: item.text,
|
|
1259
|
+
symbols,
|
|
1260
|
+
screenReader
|
|
1261
|
+
});
|
|
1262
|
+
case "tool": return createElement(ToolRow, {
|
|
1263
|
+
key: index,
|
|
1264
|
+
item,
|
|
1265
|
+
symbols,
|
|
1266
|
+
screenReader
|
|
1267
|
+
});
|
|
1268
|
+
case "info": return createElement(Text, { key: index }, item.text);
|
|
1269
|
+
case "error": return createElement(Text, {
|
|
1270
|
+
key: index,
|
|
1271
|
+
color: "red"
|
|
1272
|
+
}, item.text);
|
|
1273
|
+
}
|
|
171
1274
|
}
|
|
172
1275
|
/** Screen-reader label for the approval card (ink serializes it as the button name). */
|
|
173
1276
|
function approvalLabel(tool, input) {
|
|
@@ -187,24 +1290,12 @@ function inputSubject(input) {
|
|
|
187
1290
|
}
|
|
188
1291
|
return "";
|
|
189
1292
|
}
|
|
190
|
-
/** Maps a prompt keypress to a decision; null = key not handled by the prompt. */
|
|
191
|
-
function keypressToDecision(key) {
|
|
192
|
-
const clean = key.replace(/[\r\n]/g, "");
|
|
193
|
-
if (clean === "y") return { approved: true };
|
|
194
|
-
if (clean === "a") return {
|
|
195
|
-
approved: true,
|
|
196
|
-
remember: true
|
|
197
|
-
};
|
|
198
|
-
if (clean === "n") return {
|
|
199
|
-
approved: false,
|
|
200
|
-
reason: "user denied"
|
|
201
|
-
};
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
/** Mounts the TUI. The store drives everything; the caller drives the agent. */
|
|
205
1293
|
function startTui(store, options = {}) {
|
|
206
1294
|
const screenReader = resolveScreenReader(options.screenReader, process.env.CHANTIER_SCREEN_READER);
|
|
207
|
-
const instance = render(createElement(TuiApp, {
|
|
1295
|
+
const instance = render(createElement(TuiApp, {
|
|
1296
|
+
store,
|
|
1297
|
+
...options.footer === void 0 ? {} : { footer: options.footer }
|
|
1298
|
+
}), {
|
|
208
1299
|
exitOnCtrlC: false,
|
|
209
1300
|
...screenReader ? { isScreenReaderEnabled: true } : {}
|
|
210
1301
|
});
|
|
@@ -215,9 +1306,13 @@ function startTui(store, options = {}) {
|
|
|
215
1306
|
function createTuiStore(handlers, isQuitCommand = (text) => /^(q|exit|quit)$/i.test(text.trim())) {
|
|
216
1307
|
let state = {
|
|
217
1308
|
mode: "input",
|
|
218
|
-
|
|
1309
|
+
items: [],
|
|
219
1310
|
streamText: "",
|
|
220
1311
|
status: "",
|
|
1312
|
+
running: null,
|
|
1313
|
+
queued: [],
|
|
1314
|
+
usage: void 0,
|
|
1315
|
+
statusFlash: "",
|
|
221
1316
|
prompt: null,
|
|
222
1317
|
promptDetail: null,
|
|
223
1318
|
inputText: "",
|
|
@@ -252,6 +1347,14 @@ function createTuiStore(handlers, isQuitCommand = (text) => /^(q|exit|quit)$/i.t
|
|
|
252
1347
|
set({ streamText: state.streamText + chunk });
|
|
253
1348
|
}, STREAM_COALESCE_MS);
|
|
254
1349
|
};
|
|
1350
|
+
const STATUS_FLASH_MS = 5e3;
|
|
1351
|
+
let flashTimer = null;
|
|
1352
|
+
const disarmFlashTimer = () => {
|
|
1353
|
+
if (flashTimer !== null) {
|
|
1354
|
+
clearTimeout(flashTimer);
|
|
1355
|
+
flashTimer = null;
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
255
1358
|
/** Normalizes an ask detail: only a non-empty string diff survives. */
|
|
256
1359
|
const normalizeDetail = (detail) => {
|
|
257
1360
|
if (typeof detail?.diff === "string" && detail.diff.length > 0) return { diff: detail.diff };
|
|
@@ -261,30 +1364,93 @@ function createTuiStore(handlers, isQuitCommand = (text) => /^(q|exit|quit)$/i.t
|
|
|
261
1364
|
get state() {
|
|
262
1365
|
return state;
|
|
263
1366
|
},
|
|
1367
|
+
get items() {
|
|
1368
|
+
return state.items;
|
|
1369
|
+
},
|
|
1370
|
+
get running() {
|
|
1371
|
+
return state.running;
|
|
1372
|
+
},
|
|
1373
|
+
get queued() {
|
|
1374
|
+
return state.queued;
|
|
1375
|
+
},
|
|
1376
|
+
get usage() {
|
|
1377
|
+
return state.usage;
|
|
1378
|
+
},
|
|
1379
|
+
get statusFlash() {
|
|
1380
|
+
return state.statusFlash;
|
|
1381
|
+
},
|
|
264
1382
|
subscribe(listener) {
|
|
265
1383
|
listeners.add(listener);
|
|
266
1384
|
return () => listeners.delete(listener);
|
|
267
1385
|
},
|
|
268
|
-
|
|
269
|
-
set({
|
|
1386
|
+
pushItem(item) {
|
|
1387
|
+
set({ items: [...state.items, item] });
|
|
270
1388
|
},
|
|
271
1389
|
appendStream(text) {
|
|
272
1390
|
pendingStream += text;
|
|
273
1391
|
if (pendingStream.length > 0) armStreamTimer();
|
|
274
1392
|
},
|
|
275
|
-
flushStream() {
|
|
1393
|
+
flushStream(options) {
|
|
276
1394
|
disarmStreamTimer();
|
|
277
1395
|
const buffered = `${state.streamText}${pendingStream}`;
|
|
278
1396
|
pendingStream = "";
|
|
279
1397
|
if (buffered.length === 0) return;
|
|
1398
|
+
if (options?.safe === true) {
|
|
1399
|
+
const { flushed, rest } = takeSafeFlush(buffered);
|
|
1400
|
+
if (flushed.length === 0) {
|
|
1401
|
+
if (rest !== state.streamText) set({ streamText: rest });
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
set({
|
|
1405
|
+
items: [...state.items, {
|
|
1406
|
+
kind: "markdown",
|
|
1407
|
+
text: flushed
|
|
1408
|
+
}],
|
|
1409
|
+
streamText: rest
|
|
1410
|
+
});
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
280
1413
|
set({
|
|
281
|
-
|
|
1414
|
+
items: [...state.items, {
|
|
1415
|
+
kind: "markdown",
|
|
1416
|
+
text: buffered
|
|
1417
|
+
}],
|
|
282
1418
|
streamText: ""
|
|
283
1419
|
});
|
|
284
1420
|
},
|
|
285
1421
|
setStatus(status) {
|
|
286
1422
|
set({ status });
|
|
287
1423
|
},
|
|
1424
|
+
setRunning(running) {
|
|
1425
|
+
set({ running });
|
|
1426
|
+
},
|
|
1427
|
+
pushQueued(text) {
|
|
1428
|
+
set({ queued: [...state.queued, text] });
|
|
1429
|
+
},
|
|
1430
|
+
editQueued(text) {
|
|
1431
|
+
const queued = state.queued;
|
|
1432
|
+
if (queued.length === 0) return;
|
|
1433
|
+
set({ queued: [...queued.slice(0, -1), text] });
|
|
1434
|
+
},
|
|
1435
|
+
dropQueued() {
|
|
1436
|
+
if (state.queued.length === 0) return;
|
|
1437
|
+
set({ queued: state.queued.slice(0, -1) });
|
|
1438
|
+
},
|
|
1439
|
+
setUsage(usage) {
|
|
1440
|
+
set({ usage });
|
|
1441
|
+
},
|
|
1442
|
+
flashStatus(text) {
|
|
1443
|
+
disarmFlashTimer();
|
|
1444
|
+
if (text.length === 0) {
|
|
1445
|
+
set({ statusFlash: "" });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
flashTimer = setTimeout(() => {
|
|
1449
|
+
flashTimer = null;
|
|
1450
|
+
set({ statusFlash: "" });
|
|
1451
|
+
}, STATUS_FLASH_MS);
|
|
1452
|
+
set({ statusFlash: text });
|
|
1453
|
+
},
|
|
288
1454
|
backspaceInput() {
|
|
289
1455
|
set({ inputText: state.inputText.slice(0, -1) });
|
|
290
1456
|
},
|
|
@@ -370,4 +1536,4 @@ function createTuiStore(handlers, isQuitCommand = (text) => /^(q|exit|quit)$/i.t
|
|
|
370
1536
|
};
|
|
371
1537
|
}
|
|
372
1538
|
//#endregion
|
|
373
|
-
export { TuiApp, approvalLabel, classifyUnifiedDiffLine, createTuiStore, isAsciiEnv, keypressToDecision, resolveScreenReader, resolveSymbols, startTui, summarizeUnifiedDiff };
|
|
1539
|
+
export { ACTION_CHORDS, APPROVAL_HINT_PARTS, ApprovalCardV2, Divider, FooterBar, QUEUE_PREVIEW_MAX_ROWS, QUIT_HINT, QueuePreview, STATUS_VERB_WIDTH, SUBAGENT_SUMMARY_MAX_LINES, StatusWidget, SubagentCard, TaskInput, ToolRow, TuiApp, applyEditorAction, approvalCardSpec, approvalLabel, approvalSrLabel, classifyUnifiedDiffLine, createHistoryStore, createTuiStore, ctxBar, dividerLine, editorBackspace, editorInsert, emptyEditor, expandPasteChips, footerSegments, formatDuration, formatElapsed, formatTokenCount, formatTokens, hasMarkdownSyntax, historyPath, humanizeApproval, isAsciiEnv, keypressToDecision, loadHistory, markdownDivider, markdownToElements, matches, pasteChip, previewLine, queuePreviewLines, resolveScreenReader, resolveSymbols, spinnerFrame, startTui, statusLines, subagentLines, summarizeUnifiedDiff, takeSafeFlush, toolRowLines, toolRowSrText, withErrorBackstop };
|