@cruxy/cli 1.2.1 → 1.4.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 (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,508 @@
1
+ import { DEFAULT_MODE, MODE_LABELS, modeAutoApproves, } from "../agent/index.js";
2
+ import { completeLine } from "../components/autocomplete.js";
3
+ import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
4
+ import { selectList } from "../components/select.js";
5
+ import { viewLabel, viewOrder } from "./views.js";
6
+ import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
7
+ import { openPalette } from "./palette.js";
8
+ import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
9
+ import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
10
+ import { COLUMN_LABELS, PANEL_LABELS } from "./panels.js";
11
+ /**
12
+ * The TUI's input loop (P1) — the piece that replaces `repl.ts`'s readline
13
+ * loop. It owns exactly two things: the edit buffer and command dispatch.
14
+ * Everything visible is the renderer's; everything the agent does is the
15
+ * session's.
16
+ *
17
+ * The stdin discipline is inherited from the REPL for the same reason it
18
+ * existed there: while a turn runs, the approval prompt grabs stdin in raw mode
19
+ * through the shared `readSingleKey`. Two raw-mode readers on one stdin would
20
+ * contend, so this loop takes stdin only while collecting a line and RELEASES it
21
+ * (`restore()` in a `finally`) before `session.send` — the approval prompt then
22
+ * has uncontested ownership, exactly as with the per-line readline interface.
23
+ *
24
+ * P5 track 1 keeps that behaviour and changes what enforces it. The loop now
25
+ * reads through a {@link KeyLease} rather than a reader of its own: one reader
26
+ * for the whole session, refcounted, so releasing between lines still leaves
27
+ * raw mode (the count returns to zero, and the approval prompt's ownership is
28
+ * unchanged) while a modal opened DURING a read borrows the live reader instead
29
+ * of opening a second one. Release-before-send stops being the only thing
30
+ * standing between the TUI and two readers on one stdin.
31
+ */
32
+ /**
33
+ * Every command the TUI completes on Tab — the shared set plus this shell's own
34
+ * panel commands (P5 track 5).
35
+ *
36
+ * This constant existed before and was consumed by NOTHING: exported from
37
+ * `tui/index.ts`, imported nowhere, while the TUI had no completion at all.
38
+ * It is wired to Tab now rather than deleted, because the thing it was reaching
39
+ * for — the REPL's readline completer, which the TUI cannot use — is real.
40
+ */
41
+ export const TUI_COMMANDS = [
42
+ ...SHARED_COMMANDS,
43
+ "/close",
44
+ "/open",
45
+ "/view",
46
+ ].sort();
47
+ const HELP = [
48
+ "commands:",
49
+ ...SHARED_HELP,
50
+ " /close <panel> hide a panel (sidebar | context | model | git | tools)",
51
+ " or a whole column (rail = all four rail panels)",
52
+ " /open <panel> show a hidden panel",
53
+ " /view [name] show a main-pane view, or list them",
54
+ " Tab complete a slash command",
55
+ " Ctrl+K open the command palette",
56
+ " Ctrl+B focus the sidebar nav (arrows switch view, Esc leaves)",
57
+ " Shift+Tab cycle mode (manual · auto-approve · plan · full-auto)",
58
+ " PgUp / PgDn scroll the pane; Esc returns to the live view",
59
+ " Ctrl+D leave cruxy",
60
+ ];
61
+ /**
62
+ * Group names `/close` and `/open` accept beside individual panels. `rail` is
63
+ * the one that matters for compatibility: it is what P1 shipped and what users
64
+ * already have in their fingers, so it keeps working — as the whole stack.
65
+ */
66
+ const PANEL_GROUPS = {
67
+ rail: RAIL_PANELS,
68
+ };
69
+ const emptyEditor = () => ({ text: "", cursor: 0 });
70
+ /**
71
+ * Render the input row: mode chip, prompt, text, and a visible caret at the
72
+ * cursor. The caret is drawn rather than moved, because the frame owns the real
73
+ * cursor — it parks after the last painted row so a repaint can erase upward.
74
+ *
75
+ * The chip is shown for every mode EXCEPT `manual` (P5 track 4). Manual is the
76
+ * default and asks about everything, so a chip there would be noise on every
77
+ * session; the modes worth a permanent marker are the ones where the agent is
78
+ * doing something you did not individually agree to, and those must never be
79
+ * inferable only from what has already happened. `full-auto` and `auto-approve`
80
+ * carry the warning role rather than the muted one for the same reason.
81
+ */
82
+ export function renderInput(editor, theme, mode = DEFAULT_MODE) {
83
+ const chip = mode === DEFAULT_MODE
84
+ ? ""
85
+ : `${modeAutoApproves(mode) ? theme.warning(`[${MODE_LABELS[mode]}]`) : theme.muted(`[${MODE_LABELS[mode]}]`)} `;
86
+ const prompt = `${chip}${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
87
+ const before = editor.text.slice(0, editor.cursor);
88
+ const at = editor.text.slice(editor.cursor, editor.cursor + 1);
89
+ const after = editor.text.slice(editor.cursor + 1);
90
+ const caret = at === "" ? theme.accent(theme.glyph.cursorBar) : theme.strong(at);
91
+ return prompt + before + caret + after;
92
+ }
93
+ /**
94
+ * Parse `/close git` → ["git"], `/close rail` → the four rail panels. Returns
95
+ * null when the argument is missing or unknown — never a guess, so a typo can
96
+ * not close something the user did not name.
97
+ */
98
+ function panelArg(input, command) {
99
+ const arg = input.slice(command.length).trim().toLowerCase();
100
+ const group = PANEL_GROUPS[arg];
101
+ if (group)
102
+ return { label: arg, panels: group };
103
+ const match = CLOSABLE_PANELS.find((p) => p === arg);
104
+ return match ? { label: PANEL_LABELS[match], panels: [match] } : null;
105
+ }
106
+ /**
107
+ * Keys the focused sidebar nav claims (P7 track 2). Returns whether it took
108
+ * the key — anything it declines falls through to the input line untouched.
109
+ *
110
+ * The set is deliberately small. Up/down move the selection and Enter is not
111
+ * needed to commit it: selecting a view IS the action, and a two-step
112
+ * move-then-confirm would make the arrows do nothing visible on their own.
113
+ * Esc and Ctrl+B both hand the keyboard back, so the way out is whichever of
114
+ * the two the user reaches for first.
115
+ *
116
+ * A PRINTABLE CHARACTER RETURNS FOCUS AND IS NOT SWALLOWED. Someone who starts
117
+ * typing has stopped navigating, and losing the first letter of a prompt to a
118
+ * focus mode they forgot they were in is the failure that makes people stop
119
+ * using the binding.
120
+ */
121
+ function handleNavKey(key, renderer) {
122
+ switch (key.kind) {
123
+ case "up":
124
+ renderer.cycleView(-1);
125
+ return true;
126
+ case "down":
127
+ renderer.cycleView(1);
128
+ return true;
129
+ case "escape":
130
+ case "ctrl-b":
131
+ case "enter":
132
+ renderer.focusSidebar(false);
133
+ return true;
134
+ case "char":
135
+ // Claim the focus change only; the character itself falls through to the
136
+ // editor on this same keypress.
137
+ renderer.focusSidebar(false);
138
+ return false;
139
+ default:
140
+ return false;
141
+ }
142
+ }
143
+ /**
144
+ * Read one line from the TUI's input row. Takes raw stdin for the duration and
145
+ * always releases it. Resolves `null` on Ctrl+D / EOF.
146
+ */
147
+ async function readLine(keys, renderer, hooks) {
148
+ const editor = emptyEditor();
149
+ const paint = () => renderer.setInput(renderInput(editor, renderer.theme, hooks.mode()));
150
+ paint();
151
+ keys.begin();
152
+ try {
153
+ for (;;) {
154
+ const key = await keys.read();
155
+ // FOCUS IS A ROUTING DECISION, resolved before the editor sees anything
156
+ // (P7 track 2). While the sidebar holds the keyboard the arrows move the
157
+ // view selection instead of the cursor — but only the keys that mean
158
+ // something there are claimed, so scrolling, the palette and the mode ring
159
+ // keep working exactly as they do at the input line. A view is persistent
160
+ // and NON-EXCLUSIVE; this is not a modal, and nothing here blocks.
161
+ if (renderer.sidebarHasFocus() && handleNavKey(key, renderer)) {
162
+ paint();
163
+ continue;
164
+ }
165
+ switch (key.kind) {
166
+ case "enter": {
167
+ const text = editor.text;
168
+ editor.text = "";
169
+ editor.cursor = 0;
170
+ // Submitting returns to the live view (P7 track 1). Scrollback holds
171
+ // its position against output arriving on its own, which is the point
172
+ // — but the user asking for that output is not "on its own", and
173
+ // answering into a pane they cannot see is the one case where holding
174
+ // still is wrong.
175
+ renderer.scrollToLive();
176
+ paint();
177
+ return text;
178
+ }
179
+ case "eof":
180
+ return null;
181
+ case "ctrl-c":
182
+ // A populated buffer: clear it (an escape hatch from a half-typed
183
+ // line). An empty one: Ctrl-C means leave, same as the REPL.
184
+ if (editor.text === "")
185
+ return null;
186
+ editor.text = "";
187
+ editor.cursor = 0;
188
+ paint();
189
+ break;
190
+ case "backspace":
191
+ if (editor.cursor > 0) {
192
+ editor.text =
193
+ editor.text.slice(0, editor.cursor - 1) +
194
+ editor.text.slice(editor.cursor);
195
+ editor.cursor--;
196
+ paint();
197
+ }
198
+ break;
199
+ case "left":
200
+ if (editor.cursor > 0) {
201
+ editor.cursor--;
202
+ paint();
203
+ }
204
+ break;
205
+ case "right":
206
+ if (editor.cursor < editor.text.length) {
207
+ editor.cursor++;
208
+ paint();
209
+ }
210
+ break;
211
+ case "char":
212
+ editor.text =
213
+ editor.text.slice(0, editor.cursor) +
214
+ key.char +
215
+ editor.text.slice(editor.cursor);
216
+ editor.cursor += key.char.length;
217
+ paint();
218
+ break;
219
+ case "tab": {
220
+ // Complete a slash command in place (P5 track 5). Only the leading
221
+ // word of a `/…` line completes — everything else is prose bound for
222
+ // the model, and Tab must never mangle it. Completing REWRITES the
223
+ // buffer and never submits: Enter stays the only trigger, exactly as
224
+ // the REPL's readline completer behaves.
225
+ const head = editor.text.slice(0, editor.cursor);
226
+ if (head.startsWith("/") && !/\s/.test(head)) {
227
+ const { line, suggestions } = completeLine(head, TUI_COMMANDS);
228
+ if (suggestions.length > 1) {
229
+ renderer.println(renderer.theme.muted(suggestions.join(" ")));
230
+ }
231
+ if (line !== head) {
232
+ editor.text = line + editor.text.slice(editor.cursor);
233
+ editor.cursor = line.length;
234
+ }
235
+ paint();
236
+ }
237
+ break;
238
+ }
239
+ case "ctrl-k": {
240
+ // The command palette (P5 track 6). It runs on the SAME reader this
241
+ // loop is holding — track 1's lease makes the nested claim a no-op —
242
+ // and paints into the same frame, so the conversation stays behind it.
243
+ const picked = await hooks.openPalette();
244
+ if (picked !== null) {
245
+ // Inserted at the cursor, not appended: the palette is reachable
246
+ // mid-line, and a command pasted onto the end of a half-typed
247
+ // sentence is not what anyone meant by it.
248
+ editor.text =
249
+ editor.text.slice(0, editor.cursor) +
250
+ picked +
251
+ editor.text.slice(editor.cursor);
252
+ editor.cursor += picked.length;
253
+ }
254
+ // Repaint either way: the drawer coming down leaves rows to reclaim.
255
+ paint();
256
+ break;
257
+ }
258
+ case "shift-tab":
259
+ // Cycle the mode WITHOUT disturbing the line being typed (P5 track 4).
260
+ // Mode is a property of the session, not of the message — losing a
261
+ // half-written prompt to a mode switch would make the binding one
262
+ // people learn not to press.
263
+ hooks.cycleMode();
264
+ paint();
265
+ break;
266
+ case "page-up":
267
+ case "page-down":
268
+ // Scroll the conversation (P7 track 1). Like Shift+Tab, this leaves
269
+ // the line being typed completely alone: reading back over what was
270
+ // said is the most ordinary thing to do WHILE composing a reply, and
271
+ // a scroll that cost the draft would be unusable for exactly that.
272
+ //
273
+ // No `paint()`: the input row has not changed, and the renderer
274
+ // schedules its own repaint for the rows that did.
275
+ renderer.scrollPage(key.kind === "page-up" ? 1 : -1);
276
+ break;
277
+ case "escape":
278
+ // Esc leaves the scrolled view — a mode needs a visible exit, and the
279
+ // notice names this key. It stays inert at the live tail rather than
280
+ // being claimed unconditionally, so the binding remains free for
281
+ // whatever a later track wants Esc to mean when nothing is scrolled.
282
+ renderer.scrollToLive();
283
+ break;
284
+ case "ctrl-b":
285
+ // Take the keyboard to the sidebar nav (P7 track 2). Refused when the
286
+ // sidebar is closed or dropped for width, and SAID rather than
287
+ // silently ignored: focus the user cannot see would make the arrows
288
+ // change meaning with nothing on screen to account for it.
289
+ if (!renderer.focusSidebar(true)) {
290
+ renderer.println(renderer.theme.muted("(no sidebar to focus — /open sidebar, or widen the terminal)"));
291
+ }
292
+ paint();
293
+ break;
294
+ default:
295
+ // arrows: no binding yet, deliberately inert rather than leaking a
296
+ // control char into the buffer.
297
+ break;
298
+ }
299
+ }
300
+ }
301
+ finally {
302
+ keys.restore();
303
+ }
304
+ }
305
+ /** Render a failed turn into the conversation and carry on — never exit. */
306
+ function printTurnError(renderer, err) {
307
+ const cruxy = fromUnknown(err);
308
+ const text = formatError(cruxy, {
309
+ verbose: isVerbose(),
310
+ color: shouldUseColor(process.stdout),
311
+ });
312
+ for (const line of text.split("\n"))
313
+ renderer.println(line);
314
+ }
315
+ /** Echo the submitted line into the conversation, the way the REPL echoes a turn. */
316
+ function echoPrompt(renderer, text) {
317
+ const t = renderer.theme;
318
+ renderer.println(`${t.accent("cruxy")} ${t.muted(t.glyph.caret)} ${text}`);
319
+ }
320
+ /**
321
+ * Dispatch one submitted line. Returns `"exit"` when the loop should end,
322
+ * `null` to continue.
323
+ */
324
+ async function dispatch(input, session, renderer, out, slashCommands, checkpoints, pick) {
325
+ const trimmed = input.trim();
326
+ if (trimmed === "")
327
+ return null;
328
+ // This shell's own commands first — they are about panels, which the REPL
329
+ // has none of.
330
+ if (trimmed === "/help") {
331
+ for (const line of HELP)
332
+ renderer.println(renderer.theme.muted(line));
333
+ renderer.println();
334
+ return null;
335
+ }
336
+ if (trimmed === "/close" || trimmed.startsWith("/close ")) {
337
+ handlePanel(trimmed, "/close", false, renderer);
338
+ return null;
339
+ }
340
+ if (trimmed === "/open" || trimmed.startsWith("/open ")) {
341
+ handlePanel(trimmed, "/open", true, renderer);
342
+ return null;
343
+ }
344
+ if (trimmed === "/view" || trimmed.startsWith("/view ")) {
345
+ handleView(trimmed, renderer);
346
+ return null;
347
+ }
348
+ // Everything else is the SHARED implementation (P5 track 5). P1 forked this
349
+ // loop out of `repl.ts` and left every one of these behind — including
350
+ // `/plan`, which was then the only way to reach plan mode at all.
351
+ const outcome = await dispatchCommand(input, {
352
+ session,
353
+ out,
354
+ slashCommands,
355
+ tty: true, // the TUI only runs on a terminal
356
+ ...(pick ? { pick } : {}),
357
+ });
358
+ if (outcome.kind === "exit")
359
+ return "exit";
360
+ if (outcome.kind === "handled")
361
+ return null;
362
+ // A real turn. Assistant text streams into the main column through the
363
+ // renderer; the loop below owns nothing but input.
364
+ echoPrompt(renderer, outcome.text);
365
+ try {
366
+ checkpoints?.beginRun(outcome.text);
367
+ await session.send(outcome.text, renderer);
368
+ }
369
+ catch (err) {
370
+ printTurnError(renderer, err);
371
+ }
372
+ return null;
373
+ }
374
+ /**
375
+ * `/view [name]` — select a main-pane view, or list what there is (P7 track 2).
376
+ *
377
+ * Exists so switching views never depends on the sidebar. The nav is the
378
+ * discoverable path, but it can be closed or dropped for width, and a UI whose
379
+ * only route to a surface can vanish is one that strands people. This is also
380
+ * the scriptable path, which is what makes the register testable end to end.
381
+ */
382
+ function handleView(input, renderer) {
383
+ const t = renderer.theme;
384
+ const sources = renderer.viewSources();
385
+ const arg = input.slice("/view".length).trim().toLowerCase();
386
+ const ids = viewOrder(sources);
387
+ if (arg === "") {
388
+ renderer.println(t.muted("views:"));
389
+ for (const id of ids) {
390
+ const mark = id === renderer.view() ? t.glyph.pointer : " ";
391
+ renderer.println(t.muted(` ${mark} ${viewLabel(id, sources)}`));
392
+ }
393
+ return;
394
+ }
395
+ // Matched on the id AND the label, because the sidebar shows the label and
396
+ // that is what a user will type back. They are the same string for every
397
+ // view so far; keeping both accepted means they can diverge without the
398
+ // command quietly becoming wrong.
399
+ const target = ids.find((id) => id === arg || viewLabel(id, sources).toLowerCase() === arg);
400
+ if (target === undefined || !renderer.setView(target)) {
401
+ renderer.println(t.muted(`unknown view: ${arg}`));
402
+ renderer.println(t.muted(`try: ${ids.map((id) => viewLabel(id, sources)).join(" | ")}`));
403
+ return;
404
+ }
405
+ renderer.println(t.muted(`showing ${viewLabel(target, sources)}`));
406
+ }
407
+ /** `/close <panel>` and `/open <panel>` share everything but the target state. */
408
+ function handlePanel(input, command, open, renderer) {
409
+ const t = renderer.theme;
410
+ const target = panelArg(input, command);
411
+ if (target === null) {
412
+ const names = [...CLOSABLE_PANELS.map((p) => PANEL_LABELS[p]), "rail"].join(" | ");
413
+ renderer.println(t.muted(`usage: ${command} <${names}>`));
414
+ return;
415
+ }
416
+ const { label, panels } = target;
417
+ // A group counts as changed when ANY of its panels moved, so `/open rail`
418
+ // with one panel already open still opens the other three.
419
+ const changed = panels
420
+ .map((p) => renderer.setPanelOpen(p, open))
421
+ .some(Boolean);
422
+ if (!changed) {
423
+ renderer.println(t.muted(`${label} is already ${open ? "open" : "closed"}`));
424
+ return;
425
+ }
426
+ const verb = open ? "opened" : "closed";
427
+ const restore = open ? "/close" : "/open";
428
+ renderer.println(t.muted(`${label} ${verb} — ${restore} ${label} to undo`));
429
+ if (!open)
430
+ return;
431
+ // Opening a panel the screen cannot show must SAY so; silently painting
432
+ // nothing would read as a broken command. Width and height fail differently
433
+ // and are fixed differently, so they are reported separately rather than
434
+ // collapsed into one vague "doesn't fit".
435
+ const columns = renderer.droppedColumns();
436
+ const lost = columns.find((c) => panels.some((p) => columnOf(p) === c));
437
+ if (lost !== undefined) {
438
+ renderer.println(t.muted(`(not enough width for the ${COLUMN_LABELS[lost]} — widen the terminal)`));
439
+ return;
440
+ }
441
+ const short = renderer.droppedRailPanels();
442
+ if (panels.some((p) => p !== "sidebar" && short.includes(p))) {
443
+ renderer.println(t.muted(`(not enough height to show it yet — make the terminal taller)`));
444
+ }
445
+ }
446
+ /**
447
+ * Drive the TUI: paint the shell, then prompt → read → dispatch until the user
448
+ * leaves. `initialMessage` (from `cruxy "<message>"`) runs as the first turn
449
+ * before the first prompt, so the shell is already on screen while it streams.
450
+ */
451
+ export async function runTui(session, renderer, opts = {}) {
452
+ const stdin = opts.stdin ?? process.stdin;
453
+ renderer.setInput(renderInput(emptyEditor(), renderer.theme, session.getMode()));
454
+ if (opts.initialMessage && opts.initialMessage.trim() !== "") {
455
+ const first = opts.initialMessage.trim();
456
+ echoPrompt(renderer, first);
457
+ try {
458
+ opts.checkpoints?.beginRun(first);
459
+ await session.send(first, renderer);
460
+ }
461
+ catch (err) {
462
+ printTurnError(renderer, err);
463
+ }
464
+ }
465
+ // One reader for the whole session, leased per line. Released before any turn
466
+ // runs — see the stdin note at the top of this file.
467
+ const lease = opts.lease ?? createKeyLease(stdin);
468
+ // The TUI's command output: committed into the conversation column. No
469
+ // width fitting — `main` reflows at paint time, so truncating here would
470
+ // clip a line the column was about to wrap correctly.
471
+ const out = {
472
+ print: (line = "") => renderer.println(line),
473
+ theme: renderer.theme,
474
+ fit: (line) => line,
475
+ };
476
+ const hooks = {
477
+ mode: () => session.getMode(),
478
+ cycleMode: () => announceMode(out, session.cycleMode()),
479
+ openPalette: () => openPalette(renderer, lease, opts.slashCommands ?? []),
480
+ };
481
+ // The TUI's picker (P6 track 1): an overlay drawer on the SAME reader this
482
+ // loop holds, exactly like the palette. Declines rather than painting into a
483
+ // terminal with no room for a drawer — a modal that renders nothing while
484
+ // still eating every keystroke is indistinguishable from a hang.
485
+ const pick = async (items, pickOpts) => {
486
+ if (!canOverlay(renderer))
487
+ return null;
488
+ const result = await selectList(items, {
489
+ title: pickOpts.title,
490
+ toLabel: pickOpts.toLabel,
491
+ ...(pickOpts.initialIndex === undefined
492
+ ? {}
493
+ : { initialIndex: pickOpts.initialIndex }),
494
+ // The drawer's budget, minus the title row and the key hint the
495
+ // component draws around the list.
496
+ maxVisible: Math.max(1, renderer.overlayRows() - 2),
497
+ }, createOverlayIO(renderer, lease));
498
+ return result.kind === "selected" ? result.value : null;
499
+ };
500
+ for (;;) {
501
+ const line = await readLine(lease.handle(), renderer, hooks);
502
+ if (line === null)
503
+ return "eof";
504
+ const outcome = await dispatch(line, session, renderer, out, opts.slashCommands ?? [], opts.checkpoints, pick);
505
+ if (outcome !== null)
506
+ return outcome;
507
+ }
508
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The approval prompt as an in-viewport modal (P5 track 2).
3
+ *
4
+ * Before this, the prompt was the one thing in the TUI that could not be drawn:
5
+ * it wrote to stderr, so the renderer's only way to keep those bytes legible was
6
+ * to clear the entire shell for the duration. You answered "may I overwrite
7
+ * this file?" with the diff, the conversation, and the file's own name gone from
8
+ * the screen. The question survived; everything that made it answerable did not.
9
+ *
10
+ * Nothing about the prompt's LOGIC changes here — `promptForApproval` still
11
+ * renders the same bytes, reads the same 4-way choice, and default-denies on
12
+ * anything unrecognized. Only the surface moves: `write` composes overlay rows
13
+ * instead of terminal bytes, and the reads come from the session's key lease
14
+ * rather than a reader of their own.
15
+ *
16
+ * The displaced-state latch is untouched and still lives in the renderer:
17
+ * `beginPrompt` sets `awaiting-approval` (stashing the phase and its clock) and
18
+ * `endPrompt` calls `promptResolved` to put them back. That part was already
19
+ * correct — this only stops the phase from meaning "blank the screen".
20
+ */
21
+ /**
22
+ * Build a {@link PromptIO} that draws into the TUI's overlay drawer.
23
+ *
24
+ * The write model is append-and-recompose, which is what lets the existing
25
+ * prompt code run unmodified: `promptForApproval` emits its block, then a
26
+ * newline, then possibly a follow-up label, treating `write` as a terminal that
27
+ * remembers. Buffering the whole transcript and re-splitting it on every write
28
+ * gives exactly that behaviour without the prompt knowing it is in a modal.
29
+ */
30
+ export function createOverlayPromptIO(surface, lease, color) {
31
+ /** Everything the prompt has written this interaction, verbatim. */
32
+ let transcript = "";
33
+ /** The line being typed into a `readLine` follow-up, if one is open. */
34
+ let typing = null;
35
+ /** This interaction's claim on the shared reader; null while none is open. */
36
+ let keys = null;
37
+ const paint = () => {
38
+ // A trailing "\n" from the prompt means "that line is done", not "add a
39
+ // blank row" — `split` would produce an empty last element and the drawer
40
+ // would grow a dead row under the choices.
41
+ const body = transcript.replace(/\n$/, "");
42
+ const rows = body === "" ? [] : body.split("\n");
43
+ // The caret is drawn, not moved: the frame parks the real cursor after the
44
+ // last painted row so a repaint can erase upward, exactly as the input row
45
+ // does. An invisible cursor in a text field reads as a hang, so the field
46
+ // always shows where the next character lands.
47
+ if (typing !== null) {
48
+ rows[Math.max(0, rows.length - 1)] =
49
+ `${rows[rows.length - 1] ?? ""}${typing}▏`;
50
+ }
51
+ surface.setOverlay(rows);
52
+ };
53
+ return {
54
+ color,
55
+ // The drawer spans the viewport, so the prompt reflows against the width it
56
+ // is actually painted at — not stderr's, which is what it measured when it
57
+ // was writing to stderr.
58
+ get columns() {
59
+ return surface.overlayWidth();
60
+ },
61
+ beginPrompt() {
62
+ transcript = "";
63
+ typing = null;
64
+ // Borrow the session's reader. When the input loop is between lines this
65
+ // is the first claim and enters raw mode; when a modal is opened from
66
+ // inside a read it is a no-op on a terminal already in raw mode.
67
+ keys = lease.handle();
68
+ keys.begin();
69
+ },
70
+ endPrompt() {
71
+ // Take the drawer down and hand the reader back on EVERY path, including
72
+ // a default-deny on EOF. A modal that yields nothing back leaves the
73
+ // conversation permanently short a few rows.
74
+ surface.setOverlay(null);
75
+ transcript = "";
76
+ typing = null;
77
+ keys?.restore();
78
+ keys = null;
79
+ },
80
+ write(text) {
81
+ transcript += text;
82
+ paint();
83
+ },
84
+ async readKey() {
85
+ const key = await read(keys);
86
+ return keyToChar(key);
87
+ },
88
+ /**
89
+ * The follow-up line for `n` (reason) and `t` (instruction), edited in the
90
+ * drawer. Append-only plus backspace: this is a one-line reason field, not
91
+ * the input row, and cursor movement inside it would be the first thing to
92
+ * need a second editor implementation for no user-visible gain.
93
+ *
94
+ * Escape and Ctrl-C resolve "" — which `promptForApproval` reads as "no
95
+ * reason given", collapsing `instruct` to a plain reject. Backing out of the
96
+ * reason field can only ever make the decision MORE conservative.
97
+ */
98
+ async readLine() {
99
+ typing = "";
100
+ paint();
101
+ try {
102
+ for (;;) {
103
+ const key = await read(keys);
104
+ switch (key.kind) {
105
+ case "enter":
106
+ return typing;
107
+ case "eof":
108
+ case "ctrl-c":
109
+ case "escape":
110
+ return "";
111
+ case "backspace":
112
+ if (typing.length > 0)
113
+ typing = typing.slice(0, -1);
114
+ break;
115
+ case "char":
116
+ typing += key.char;
117
+ break;
118
+ default:
119
+ // Arrows and tab: inert rather than leaking a control char into
120
+ // text that is sent verbatim to the agent.
121
+ break;
122
+ }
123
+ paint();
124
+ }
125
+ }
126
+ finally {
127
+ typing = null;
128
+ paint();
129
+ }
130
+ },
131
+ };
132
+ }
133
+ /**
134
+ * Read one key, or EOF when no reader is open.
135
+ *
136
+ * A read outside a `beginPrompt`/`endPrompt` bracket is a wiring bug, and EOF is
137
+ * the only safe answer to it: the prompt maps EOF to default-deny, so the
138
+ * failure mode is a refused action rather than a hang or an approval nobody gave.
139
+ */
140
+ async function read(keys) {
141
+ if (keys === null)
142
+ return { kind: "eof" };
143
+ return keys.read();
144
+ }
145
+ /**
146
+ * The {@link PromptIO.readKey} contract, matching `readSingleKey` exactly: the
147
+ * printable character, `"\n"` for enter, and `""` for everything that means "no
148
+ * answer" — so the prompt's default-deny mapping is identical in the drawer and
149
+ * on a bare terminal.
150
+ */
151
+ function keyToChar(key) {
152
+ switch (key.kind) {
153
+ case "char":
154
+ return key.char;
155
+ case "enter":
156
+ return "\n";
157
+ default:
158
+ return "";
159
+ }
160
+ }