@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.
Files changed (3) hide show
  1. package/dist/index.d.mts +488 -22
  2. package/dist/index.mjs +1291 -125
  3. 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/screen-reader.ts
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
- * Screen-reader mode: ink serializes the tree to plain linear text (no
40
- * borders, no colors), keeps `<Static>` append-only, and rewrites the dynamic
41
- * region only when it changes. Enabled by the `--screen-reader` flag or the
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 resolveScreenReader(flag, env) {
45
- return flag === true || env === "1";
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
- //#endregion
48
- //#region src/symbols.ts
49
- const UNICODE_SYMBOLS = {
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
- /** CHANTIER_ASCII=1 requests the ASCII-safe rendering. */
63
- function isAsciiEnv(env) {
64
- return env === "1";
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
- const PROMPT_HINT_PARTS = [
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 { mode, prompt } = store.state;
86
- if (key.ctrl) {
87
- store.abort("ctrl-c");
88
- return;
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
- const bundledReturn = /[\r\n]/.test(input ?? "");
100
- if (input !== void 0 && input.length > 0) {
101
- for (const char of input) if (char !== "\r" && char !== "\n") store.typeInput(char);
1162
+ if (key.escape) {
1163
+ store.abort("escape");
1164
+ return;
102
1165
  }
103
- if (key.return || bundledReturn) store.submitTask(store.state.inputText);
1166
+ if (key.ctrl) store.abort("ctrl-c");
104
1167
  return;
105
1168
  }
106
- if (prompt !== null) {
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.lines],
118
- children: (item, index) => createElement(Text, { key: index }, String(item))
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.status.length > 0) children.push(createElement(Text, { dimColor: true }, state.status));
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 { tool, input } = state.prompt;
127
- children.push(createElement(Box, {
128
- flexDirection: "column",
129
- borderStyle: symbols.border,
130
- borderColor: "yellow",
131
- paddingX: 1,
132
- "aria-role": "button"
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 (state.mode === "input" && !state.finished) children.push(createElement(Box, {
140
- borderStyle: symbols.border,
141
- borderColor: "green",
142
- paddingX: 1
143
- }, createElement(Text, { color: "green" }, "> "), createElement(Text, null, state.inputText), createElement(Text, { dimColor: true }, ` ${INPUT_HINT_PARTS.join(` ${symbols.hintSeparator} `)}`)));
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
- /** The unified-diff attachment card, or null when the ask carries no diff. */
147
- function diffCard(detail, symbols) {
148
- if (detail === null) return null;
149
- if (typeof detail.diff !== "string" || detail.diff.length === 0) return null;
150
- const preview = summarizeUnifiedDiff(detail.diff);
151
- return createElement(Box, {
152
- flexDirection: "column",
153
- borderStyle: symbols.border,
154
- borderColor: "cyan",
155
- paddingX: 1,
156
- "aria-label": `proposed change: ${preview.additions} ${preview.additions === 1 ? "addition" : "additions"}, ${preview.deletions} ${preview.deletions === 1 ? "deletion" : "deletions"}`
157
- }, createElement(Text, { dimColor: true }, "proposed change"), ...preview.lines.map((line, index) => createElement(Text, {
158
- key: index,
159
- ...diffTextStyle(classifyUnifiedDiffLine(line))
160
- }, line)), preview.hiddenLines > 0 ? createElement(Text, { dimColor: true }, `+${preview.hiddenLines} more lines`) : null);
161
- }
162
- function diffTextStyle(kind) {
163
- if (kind === "add") return { color: "green" };
164
- if (kind === "del") return { color: "red" };
165
- if (kind === "meta") return { dimColor: true };
166
- return {};
167
- }
168
- function summarizeInput(input, ellipsis) {
169
- const json = JSON.stringify(input);
170
- return json.length > 160 ? `${json.slice(0, 160)}${ellipsis}` : json;
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, { store }), {
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
- lines: [],
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
- pushLine(line) {
269
- set({ lines: [...state.lines, line] });
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
- lines: [...state.lines, ...buffered.split("\n")],
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 };