@cruxy/cli 1.2.1 → 1.3.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 (76) 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/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +6 -6
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/summary.js +106 -17
  72. package/dist/usage/types.js +5 -2
  73. package/dist/usage/weighted.js +77 -0
  74. package/dist/utils/git.js +50 -4
  75. package/package.json +1 -1
  76. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,223 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Session persistence (P2): the on-disk shape of a conversation.
4
+ *
5
+ * The file is an APPEND-ONLY event log (JSONL), not a snapshot of the message
6
+ * array. That distinction is the whole design:
7
+ *
8
+ * - `Session.messages` is rewritten in place — compaction replaces an older
9
+ * prefix with a synthetic summary pair (see `agent/session.ts`). A snapshot
10
+ * format would have to rewrite the whole file on every turn, which is O(n²)
11
+ * in turns and loses the history of what was compacted away.
12
+ * - An event log records the rewrite as an EVENT (`compaction`, carrying what
13
+ * it replaced), so the file only ever grows, and replay reconstructs the
14
+ * current array exactly. What the model can still see and what the user
15
+ * actually said stay separately recoverable.
16
+ *
17
+ * Replay is a fold over the events; see `replay.ts`.
18
+ */
19
+ /**
20
+ * Bump when the event shapes change incompatibly. Present on the `meta` line
21
+ * only — every subsequent line is self-describing via `kind`.
22
+ */
23
+ export const SESSION_FILE_VERSION = 1;
24
+ /**
25
+ * FORWARD COMPATIBILITY — why every schema below is `.passthrough()`.
26
+ *
27
+ * This is the same decision, for the same recorded reason, as
28
+ * `usage/types.ts`: a `.strict()` schema means the first field a NEWER cruxy
29
+ * writes makes an OLDER binary reject the line. For usage that silently lost
30
+ * accounting; here it would silently lose a user's CONVERSATION, which is
31
+ * strictly worse. Downgrading a CLI, or running two versions against one home
32
+ * directory, is ordinary — losing a session to it is not acceptable.
33
+ *
34
+ * `.passthrough()` also PRESERVES unknown keys rather than stripping them, so
35
+ * anything that reads and re-emits an event hands a newer CLI's fields back
36
+ * intact.
37
+ *
38
+ * The tradeoff accepted, identically: a typo'd key is no longer a parse error.
39
+ * Worth it — every field the code actually reads is still fully validated, and
40
+ * a malformed LINE is skipped rather than taking the session down (see
41
+ * `replay.ts`).
42
+ */
43
+ // ── message shapes (mirrors @cruxy/sdk, validated on the way back in) ────────
44
+ /**
45
+ * The SDK's content blocks, restated as schemas.
46
+ *
47
+ * These must round-trip EXACTLY. `tool_use` / `tool_result` pairing is
48
+ * load-bearing: the provider rejects a history where a `tool_use` has no
49
+ * matching `tool_result`, which is the entire reason `Session.findCut` walks
50
+ * back to a real user prompt before compacting. A replay that dropped, merged
51
+ * or reordered a block would produce a history that fails on the next turn —
52
+ * so nothing here is lossy, and unknown block types are preserved rather than
53
+ * filtered (a newer CLI's block must survive an older one reading the file).
54
+ */
55
+ export const TextBlockSchema = z
56
+ .object({ type: z.literal("text"), text: z.string() })
57
+ .passthrough();
58
+ export const ToolUseBlockSchema = z
59
+ .object({
60
+ type: z.literal("tool_use"),
61
+ id: z.string(),
62
+ name: z.string(),
63
+ input: z.unknown(),
64
+ })
65
+ .passthrough();
66
+ export const ToolResultBlockSchema = z
67
+ .object({
68
+ type: z.literal("tool_result"),
69
+ tool_use_id: z.string(),
70
+ content: z.string(),
71
+ is_error: z.boolean().optional(),
72
+ })
73
+ .passthrough();
74
+ /**
75
+ * A block of a kind this build knows, OR any other object carrying a string
76
+ * `type`. The fallback arm is deliberate: a newer cruxy that adds a block kind
77
+ * must not have its sessions truncated by an older one. The block is carried
78
+ * through untouched and handed back to the provider as-is.
79
+ */
80
+ export const ContentBlockSchema = z.union([
81
+ TextBlockSchema,
82
+ ToolUseBlockSchema,
83
+ ToolResultBlockSchema,
84
+ z.object({ type: z.string() }).passthrough(),
85
+ ]);
86
+ export const MessageSchema = z
87
+ .object({
88
+ role: z.enum(["user", "assistant"]),
89
+ content: z.union([z.string(), z.array(ContentBlockSchema)]),
90
+ })
91
+ .passthrough();
92
+ // ── events ───────────────────────────────────────────────────────────────────
93
+ /** One declared workspace root, recorded so a resume can validate against it. */
94
+ export const RootRefSchema = z
95
+ .object({ name: z.string(), path: z.string() })
96
+ .passthrough();
97
+ /**
98
+ * The first line of every session file: everything needed to identify the
99
+ * session and to decide whether resuming it HERE is safe.
100
+ *
101
+ * `cwd` and `roots` are metadata, never restored. Resuming a session recorded
102
+ * in another directory would silently point a history full of file paths, diffs
103
+ * and tool results at an unrelated tree — so the resume path compares and warns
104
+ * loudly rather than quietly proceeding (see `replay.ts`/`resume.ts`).
105
+ */
106
+ export const SessionMetaSchema = z
107
+ .object({
108
+ kind: z.literal("meta"),
109
+ version: z.literal(SESSION_FILE_VERSION),
110
+ sessionId: z.string(),
111
+ startedAt: z.string(),
112
+ /** The primary root at session start — the directory history refers to. */
113
+ cwd: z.string(),
114
+ /** Every declared root (C.26). Multi-root sessions key on the primary, so
115
+ * this is what makes the asymmetry visible rather than silently dropped. */
116
+ roots: z.array(RootRefSchema).default([]),
117
+ cliVersion: z.string().optional(),
118
+ provider: z.string().optional(),
119
+ model: z.string().optional(),
120
+ })
121
+ .passthrough();
122
+ /**
123
+ * Messages appended since the last event. The ONLY growth path — every turn's
124
+ * user prompt, assistant blocks and tool results arrive through here.
125
+ *
126
+ * `runId` is the CheckpointGate's run id for the turn (see `gate.ts`), NOT a
127
+ * second id minted here: `cruxy rollback <id>` and this log must agree on what
128
+ * a turn is, or the undo unit means two different things. It is absent when
129
+ * checkpoints are disabled — the only case where no run id exists at all.
130
+ */
131
+ export const AppendEventSchema = z
132
+ .object({
133
+ kind: z.literal("append"),
134
+ at: z.string(),
135
+ runId: z.string().optional(),
136
+ messages: z.array(MessageSchema),
137
+ })
138
+ .passthrough();
139
+ /**
140
+ * A compaction: the oldest `replaced` messages were folded into `summary`
141
+ * (the synthetic user/assistant pair carrying `COMPACTION_MARKER`).
142
+ *
143
+ * Recording the COUNT plus the replacement — rather than rewriting the array —
144
+ * is what keeps the file append-only. The replaced messages remain earlier in
145
+ * the log, so the full conversation is still recoverable even though the model
146
+ * can no longer see it.
147
+ */
148
+ export const CompactionEventSchema = z
149
+ .object({
150
+ kind: z.literal("compaction"),
151
+ at: z.string(),
152
+ runId: z.string().optional(),
153
+ /** How many messages from the head were folded away. */
154
+ replaced: z.number().int().nonnegative(),
155
+ /** What replaced them (the synthetic pair). */
156
+ summary: z.array(MessageSchema),
157
+ })
158
+ .passthrough();
159
+ /** `/clear`: history dropped, session kept. Replay resets to an empty array. */
160
+ export const ClearEventSchema = z
161
+ .object({ kind: z.literal("clear"), at: z.string() })
162
+ .passthrough();
163
+ /**
164
+ * `/plan` toggled. The last one wins on replay.
165
+ *
166
+ * Superseded by {@link SessionModeEventSchema} (P5 track 3) and still READ, never
167
+ * written: sessions recorded before modes existed carry these, and dropping the
168
+ * case would silently resume them in manual. Expand-contract — the new writer
169
+ * emits `mode`, the reader understands both, and a pre-P5 log keeps meaning what
170
+ * it meant.
171
+ */
172
+ export const PlanModeEventSchema = z
173
+ .object({
174
+ kind: z.literal("plan-mode"),
175
+ at: z.string(),
176
+ enabled: z.boolean(),
177
+ })
178
+ .passthrough();
179
+ /**
180
+ * The session mode changed (P5 track 3). The last one wins on replay, exactly
181
+ * like the event it replaces.
182
+ *
183
+ * `mode` is a plain string here rather than an enum so an unknown value — a log
184
+ * written by a newer build that added a mode — parses instead of poisoning the
185
+ * whole line. The fold decides what to do with one it does not recognise.
186
+ */
187
+ export const SessionModeEventSchema = z
188
+ .object({
189
+ kind: z.literal("mode"),
190
+ at: z.string(),
191
+ mode: z.string(),
192
+ })
193
+ .passthrough();
194
+ /**
195
+ * One turn's token usage, as the provider reported it. Summed on replay to
196
+ * restore `Session.usage`.
197
+ *
198
+ * This is a COPY, deliberately. `~/.cruxy/usage/runs.json` keeps only the last
199
+ * 50 runs (`usage/store.ts`), while sessions are kept indefinitely — so a
200
+ * session will routinely outlive its own usage records. Without this the
201
+ * restored `Session.usage` would silently read 0 for an older conversation.
202
+ * `runId` still points at the usage store for the richer per-tier/cost
203
+ * breakdown WHEN it is still there; nothing here assumes it is.
204
+ */
205
+ export const UsageEventSchema = z
206
+ .object({
207
+ kind: z.literal("usage"),
208
+ at: z.string(),
209
+ runId: z.string().optional(),
210
+ inputTokens: z.number().int().nonnegative(),
211
+ outputTokens: z.number().int().nonnegative(),
212
+ })
213
+ .passthrough();
214
+ /** Every event, discriminated on `kind`. */
215
+ export const SessionEventSchema = z.discriminatedUnion("kind", [
216
+ SessionMetaSchema,
217
+ AppendEventSchema,
218
+ CompactionEventSchema,
219
+ ClearEventSchema,
220
+ PlanModeEventSchema,
221
+ SessionModeEventSchema,
222
+ UsageEventSchema,
223
+ ]);
@@ -437,6 +437,29 @@ class SubagentRenderer {
437
437
  }
438
438
  /** The plan executor owns the progress register (C.31) — never the child. */
439
439
  progress() { }
440
+ /**
441
+ * Same ownership rule for the plan checklist (P3): a child running its own
442
+ * task must never overwrite the parent's plan on screen.
443
+ */
444
+ setPlan() { }
445
+ /**
446
+ * Forwarded: a child's test run is a real outcome the user should see, unlike
447
+ * its assistant text. The label is not prefixed — the report carries the
448
+ * command it ran, which already identifies it.
449
+ */
450
+ /**
451
+ * NOT forwarded, deliberately. A subagent may run on a different tier from
452
+ * the main loop (C.30 routes by task class), and the parent's model panel and
453
+ * header describe the SESSION's tier. Forwarding would let a subagent's tier
454
+ * overwrite it and linger after the subagent finished — the header would
455
+ * report a tier the conversation is not running on.
456
+ */
457
+ servedRouting() {
458
+ // no-op
459
+ }
460
+ testResult(report) {
461
+ this.inner.testResult(report);
462
+ }
440
463
  toolLifecycle(event) {
441
464
  this.inner.toolLifecycle({
442
465
  ...event,
@@ -144,6 +144,14 @@ export function makeRunTestsTool(deps = {}) {
144
144
  shell: ctx.config.shell,
145
145
  });
146
146
  budget.record(result.passed);
147
+ // The structured result goes to the renderer here, from the same object
148
+ // the payload below is built from — never from re-reading that payload.
149
+ try {
150
+ deps.onResult?.(result, resolved);
151
+ }
152
+ catch {
153
+ // A renderer problem is not a test-run problem.
154
+ }
147
155
  const payload = renderResult(result, resolved, {
148
156
  run: result.passed ? 0 : budget.spent,
149
157
  max,
@@ -3,7 +3,7 @@ import { listFilesTool } from "./list-files.js";
3
3
  import { gitStatusTool } from "./git-status.js";
4
4
  import { readFileTool, writeFileTool, editFileTool, applyPatchTool, globTool, grepFilesTool, } from "./file/index.js";
5
5
  import { runCommandTool } from "./shell/index.js";
6
- import { makeRunTestsTool } from "../testing/run-tests-tool.js";
6
+ import { makeRunTestsTool, } from "../testing/run-tests-tool.js";
7
7
  import { searchCodebaseTool } from "./search-codebase.js";
8
8
  import { listSkillsTool } from "./list-skills.js";
9
9
  import { loadSkillTool } from "./load-skill.js";
@@ -55,7 +55,7 @@ function toInputSchema(schema) {
55
55
  return json;
56
56
  }
57
57
  /** Build the default registry with every built-in tool registered. */
58
- export function buildDefaultRegistry() {
58
+ export function buildDefaultRegistry(opts = {}) {
59
59
  const registry = new ToolRegistry();
60
60
  registry.register(listFilesTool);
61
61
  registry.register(readFileTool);
@@ -67,7 +67,7 @@ export function buildDefaultRegistry() {
67
67
  registry.register(gitStatusTool);
68
68
  registry.register(runCommandTool);
69
69
  // A fresh tool per registry — its iteration budget (C.13) is session-scoped.
70
- registry.register(makeRunTestsTool());
70
+ registry.register(makeRunTestsTool(opts.onTestResult ? { onResult: opts.onTestResult } : {}));
71
71
  registry.register(searchCodebaseTool);
72
72
  registry.register(listSkillsTool);
73
73
  registry.register(loadSkillTool);
@@ -0,0 +1,385 @@
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 { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
6
+ import { openPalette } from "./palette.js";
7
+ import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
8
+ import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
9
+ import { COLUMN_LABELS, PANEL_LABELS } from "./panels.js";
10
+ /**
11
+ * The TUI's input loop (P1) — the piece that replaces `repl.ts`'s readline
12
+ * loop. It owns exactly two things: the edit buffer and command dispatch.
13
+ * Everything visible is the renderer's; everything the agent does is the
14
+ * session's.
15
+ *
16
+ * The stdin discipline is inherited from the REPL for the same reason it
17
+ * existed there: while a turn runs, the approval prompt grabs stdin in raw mode
18
+ * through the shared `readSingleKey`. Two raw-mode readers on one stdin would
19
+ * contend, so this loop takes stdin only while collecting a line and RELEASES it
20
+ * (`restore()` in a `finally`) before `session.send` — the approval prompt then
21
+ * has uncontested ownership, exactly as with the per-line readline interface.
22
+ *
23
+ * P5 track 1 keeps that behaviour and changes what enforces it. The loop now
24
+ * reads through a {@link KeyLease} rather than a reader of its own: one reader
25
+ * for the whole session, refcounted, so releasing between lines still leaves
26
+ * raw mode (the count returns to zero, and the approval prompt's ownership is
27
+ * unchanged) while a modal opened DURING a read borrows the live reader instead
28
+ * of opening a second one. Release-before-send stops being the only thing
29
+ * standing between the TUI and two readers on one stdin.
30
+ */
31
+ /**
32
+ * Every command the TUI completes on Tab — the shared set plus this shell's own
33
+ * panel commands (P5 track 5).
34
+ *
35
+ * This constant existed before and was consumed by NOTHING: exported from
36
+ * `tui/index.ts`, imported nowhere, while the TUI had no completion at all.
37
+ * It is wired to Tab now rather than deleted, because the thing it was reaching
38
+ * for — the REPL's readline completer, which the TUI cannot use — is real.
39
+ */
40
+ export const TUI_COMMANDS = [
41
+ ...SHARED_COMMANDS,
42
+ "/close",
43
+ "/open",
44
+ ].sort();
45
+ const HELP = [
46
+ "commands:",
47
+ ...SHARED_HELP,
48
+ " /close <panel> hide a panel (sidebar | context | model | git | tools)",
49
+ " or a whole column (rail = all four rail panels)",
50
+ " /open <panel> show a hidden panel",
51
+ " Tab complete a slash command",
52
+ " Ctrl+K open the command palette",
53
+ " Shift+Tab cycle mode (manual · auto-approve · plan · full-auto)",
54
+ " Ctrl+D leave cruxy",
55
+ ];
56
+ /**
57
+ * Group names `/close` and `/open` accept beside individual panels. `rail` is
58
+ * the one that matters for compatibility: it is what P1 shipped and what users
59
+ * already have in their fingers, so it keeps working — as the whole stack.
60
+ */
61
+ const PANEL_GROUPS = {
62
+ rail: RAIL_PANELS,
63
+ };
64
+ const emptyEditor = () => ({ text: "", cursor: 0 });
65
+ /**
66
+ * Render the input row: mode chip, prompt, text, and a visible caret at the
67
+ * cursor. The caret is drawn rather than moved, because the frame owns the real
68
+ * cursor — it parks after the last painted row so a repaint can erase upward.
69
+ *
70
+ * The chip is shown for every mode EXCEPT `manual` (P5 track 4). Manual is the
71
+ * default and asks about everything, so a chip there would be noise on every
72
+ * session; the modes worth a permanent marker are the ones where the agent is
73
+ * doing something you did not individually agree to, and those must never be
74
+ * inferable only from what has already happened. `full-auto` and `auto-approve`
75
+ * carry the warning role rather than the muted one for the same reason.
76
+ */
77
+ export function renderInput(editor, theme, mode = DEFAULT_MODE) {
78
+ const chip = mode === DEFAULT_MODE
79
+ ? ""
80
+ : `${modeAutoApproves(mode) ? theme.warning(`[${MODE_LABELS[mode]}]`) : theme.muted(`[${MODE_LABELS[mode]}]`)} `;
81
+ const prompt = `${chip}${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
82
+ const before = editor.text.slice(0, editor.cursor);
83
+ const at = editor.text.slice(editor.cursor, editor.cursor + 1);
84
+ const after = editor.text.slice(editor.cursor + 1);
85
+ const caret = at === "" ? theme.accent(theme.glyph.cursorBar) : theme.strong(at);
86
+ return prompt + before + caret + after;
87
+ }
88
+ /**
89
+ * Parse `/close git` → ["git"], `/close rail` → the four rail panels. Returns
90
+ * null when the argument is missing or unknown — never a guess, so a typo can
91
+ * not close something the user did not name.
92
+ */
93
+ function panelArg(input, command) {
94
+ const arg = input.slice(command.length).trim().toLowerCase();
95
+ const group = PANEL_GROUPS[arg];
96
+ if (group)
97
+ return { label: arg, panels: group };
98
+ const match = CLOSABLE_PANELS.find((p) => p === arg);
99
+ return match ? { label: PANEL_LABELS[match], panels: [match] } : null;
100
+ }
101
+ /**
102
+ * Read one line from the TUI's input row. Takes raw stdin for the duration and
103
+ * always releases it. Resolves `null` on Ctrl+D / EOF.
104
+ */
105
+ async function readLine(keys, renderer, hooks) {
106
+ const editor = emptyEditor();
107
+ const paint = () => renderer.setInput(renderInput(editor, renderer.theme, hooks.mode()));
108
+ paint();
109
+ keys.begin();
110
+ try {
111
+ for (;;) {
112
+ const key = await keys.read();
113
+ switch (key.kind) {
114
+ case "enter": {
115
+ const text = editor.text;
116
+ editor.text = "";
117
+ editor.cursor = 0;
118
+ paint();
119
+ return text;
120
+ }
121
+ case "eof":
122
+ return null;
123
+ case "ctrl-c":
124
+ // A populated buffer: clear it (an escape hatch from a half-typed
125
+ // line). An empty one: Ctrl-C means leave, same as the REPL.
126
+ if (editor.text === "")
127
+ return null;
128
+ editor.text = "";
129
+ editor.cursor = 0;
130
+ paint();
131
+ break;
132
+ case "backspace":
133
+ if (editor.cursor > 0) {
134
+ editor.text =
135
+ editor.text.slice(0, editor.cursor - 1) +
136
+ editor.text.slice(editor.cursor);
137
+ editor.cursor--;
138
+ paint();
139
+ }
140
+ break;
141
+ case "left":
142
+ if (editor.cursor > 0) {
143
+ editor.cursor--;
144
+ paint();
145
+ }
146
+ break;
147
+ case "right":
148
+ if (editor.cursor < editor.text.length) {
149
+ editor.cursor++;
150
+ paint();
151
+ }
152
+ break;
153
+ case "char":
154
+ editor.text =
155
+ editor.text.slice(0, editor.cursor) +
156
+ key.char +
157
+ editor.text.slice(editor.cursor);
158
+ editor.cursor += key.char.length;
159
+ paint();
160
+ break;
161
+ case "tab": {
162
+ // Complete a slash command in place (P5 track 5). Only the leading
163
+ // word of a `/…` line completes — everything else is prose bound for
164
+ // the model, and Tab must never mangle it. Completing REWRITES the
165
+ // buffer and never submits: Enter stays the only trigger, exactly as
166
+ // the REPL's readline completer behaves.
167
+ const head = editor.text.slice(0, editor.cursor);
168
+ if (head.startsWith("/") && !/\s/.test(head)) {
169
+ const { line, suggestions } = completeLine(head, TUI_COMMANDS);
170
+ if (suggestions.length > 1) {
171
+ renderer.println(renderer.theme.muted(suggestions.join(" ")));
172
+ }
173
+ if (line !== head) {
174
+ editor.text = line + editor.text.slice(editor.cursor);
175
+ editor.cursor = line.length;
176
+ }
177
+ paint();
178
+ }
179
+ break;
180
+ }
181
+ case "ctrl-k": {
182
+ // The command palette (P5 track 6). It runs on the SAME reader this
183
+ // loop is holding — track 1's lease makes the nested claim a no-op —
184
+ // and paints into the same frame, so the conversation stays behind it.
185
+ const picked = await hooks.openPalette();
186
+ if (picked !== null) {
187
+ // Inserted at the cursor, not appended: the palette is reachable
188
+ // mid-line, and a command pasted onto the end of a half-typed
189
+ // sentence is not what anyone meant by it.
190
+ editor.text =
191
+ editor.text.slice(0, editor.cursor) +
192
+ picked +
193
+ editor.text.slice(editor.cursor);
194
+ editor.cursor += picked.length;
195
+ }
196
+ // Repaint either way: the drawer coming down leaves rows to reclaim.
197
+ paint();
198
+ break;
199
+ }
200
+ case "shift-tab":
201
+ // Cycle the mode WITHOUT disturbing the line being typed (P5 track 4).
202
+ // Mode is a property of the session, not of the message — losing a
203
+ // half-written prompt to a mode switch would make the binding one
204
+ // people learn not to press.
205
+ hooks.cycleMode();
206
+ paint();
207
+ break;
208
+ default:
209
+ // escape / arrows up-down / ctrl-k: no binding yet, deliberately
210
+ // inert rather than leaking a control char into the buffer.
211
+ break;
212
+ }
213
+ }
214
+ }
215
+ finally {
216
+ keys.restore();
217
+ }
218
+ }
219
+ /** Render a failed turn into the conversation and carry on — never exit. */
220
+ function printTurnError(renderer, err) {
221
+ const cruxy = fromUnknown(err);
222
+ const text = formatError(cruxy, {
223
+ verbose: isVerbose(),
224
+ color: shouldUseColor(process.stdout),
225
+ });
226
+ for (const line of text.split("\n"))
227
+ renderer.println(line);
228
+ }
229
+ /** Echo the submitted line into the conversation, the way the REPL echoes a turn. */
230
+ function echoPrompt(renderer, text) {
231
+ const t = renderer.theme;
232
+ renderer.println(`${t.accent("cruxy")} ${t.muted(t.glyph.caret)} ${text}`);
233
+ }
234
+ /**
235
+ * Dispatch one submitted line. Returns `"exit"` when the loop should end,
236
+ * `null` to continue.
237
+ */
238
+ async function dispatch(input, session, renderer, out, slashCommands, checkpoints, pick) {
239
+ const trimmed = input.trim();
240
+ if (trimmed === "")
241
+ return null;
242
+ // This shell's own commands first — they are about panels, which the REPL
243
+ // has none of.
244
+ if (trimmed === "/help") {
245
+ for (const line of HELP)
246
+ renderer.println(renderer.theme.muted(line));
247
+ renderer.println();
248
+ return null;
249
+ }
250
+ if (trimmed === "/close" || trimmed.startsWith("/close ")) {
251
+ handlePanel(trimmed, "/close", false, renderer);
252
+ return null;
253
+ }
254
+ if (trimmed === "/open" || trimmed.startsWith("/open ")) {
255
+ handlePanel(trimmed, "/open", true, renderer);
256
+ return null;
257
+ }
258
+ // Everything else is the SHARED implementation (P5 track 5). P1 forked this
259
+ // loop out of `repl.ts` and left every one of these behind — including
260
+ // `/plan`, which was then the only way to reach plan mode at all.
261
+ const outcome = await dispatchCommand(input, {
262
+ session,
263
+ out,
264
+ slashCommands,
265
+ tty: true, // the TUI only runs on a terminal
266
+ ...(pick ? { pick } : {}),
267
+ });
268
+ if (outcome.kind === "exit")
269
+ return "exit";
270
+ if (outcome.kind === "handled")
271
+ return null;
272
+ // A real turn. Assistant text streams into the main column through the
273
+ // renderer; the loop below owns nothing but input.
274
+ echoPrompt(renderer, outcome.text);
275
+ try {
276
+ checkpoints?.beginRun(outcome.text);
277
+ await session.send(outcome.text, renderer);
278
+ }
279
+ catch (err) {
280
+ printTurnError(renderer, err);
281
+ }
282
+ return null;
283
+ }
284
+ /** `/close <panel>` and `/open <panel>` share everything but the target state. */
285
+ function handlePanel(input, command, open, renderer) {
286
+ const t = renderer.theme;
287
+ const target = panelArg(input, command);
288
+ if (target === null) {
289
+ const names = [...CLOSABLE_PANELS.map((p) => PANEL_LABELS[p]), "rail"].join(" | ");
290
+ renderer.println(t.muted(`usage: ${command} <${names}>`));
291
+ return;
292
+ }
293
+ const { label, panels } = target;
294
+ // A group counts as changed when ANY of its panels moved, so `/open rail`
295
+ // with one panel already open still opens the other three.
296
+ const changed = panels
297
+ .map((p) => renderer.setPanelOpen(p, open))
298
+ .some(Boolean);
299
+ if (!changed) {
300
+ renderer.println(t.muted(`${label} is already ${open ? "open" : "closed"}`));
301
+ return;
302
+ }
303
+ const verb = open ? "opened" : "closed";
304
+ const restore = open ? "/close" : "/open";
305
+ renderer.println(t.muted(`${label} ${verb} — ${restore} ${label} to undo`));
306
+ if (!open)
307
+ return;
308
+ // Opening a panel the screen cannot show must SAY so; silently painting
309
+ // nothing would read as a broken command. Width and height fail differently
310
+ // and are fixed differently, so they are reported separately rather than
311
+ // collapsed into one vague "doesn't fit".
312
+ const columns = renderer.droppedColumns();
313
+ const lost = columns.find((c) => panels.some((p) => columnOf(p) === c));
314
+ if (lost !== undefined) {
315
+ renderer.println(t.muted(`(not enough width for the ${COLUMN_LABELS[lost]} — widen the terminal)`));
316
+ return;
317
+ }
318
+ const short = renderer.droppedRailPanels();
319
+ if (panels.some((p) => p !== "sidebar" && short.includes(p))) {
320
+ renderer.println(t.muted(`(not enough height to show it yet — make the terminal taller)`));
321
+ }
322
+ }
323
+ /**
324
+ * Drive the TUI: paint the shell, then prompt → read → dispatch until the user
325
+ * leaves. `initialMessage` (from `cruxy "<message>"`) runs as the first turn
326
+ * before the first prompt, so the shell is already on screen while it streams.
327
+ */
328
+ export async function runTui(session, renderer, opts = {}) {
329
+ const stdin = opts.stdin ?? process.stdin;
330
+ renderer.setInput(renderInput(emptyEditor(), renderer.theme, session.getMode()));
331
+ if (opts.initialMessage && opts.initialMessage.trim() !== "") {
332
+ const first = opts.initialMessage.trim();
333
+ echoPrompt(renderer, first);
334
+ try {
335
+ opts.checkpoints?.beginRun(first);
336
+ await session.send(first, renderer);
337
+ }
338
+ catch (err) {
339
+ printTurnError(renderer, err);
340
+ }
341
+ }
342
+ // One reader for the whole session, leased per line. Released before any turn
343
+ // runs — see the stdin note at the top of this file.
344
+ const lease = opts.lease ?? createKeyLease(stdin);
345
+ // The TUI's command output: committed into the conversation column. No
346
+ // width fitting — `main` reflows at paint time, so truncating here would
347
+ // clip a line the column was about to wrap correctly.
348
+ const out = {
349
+ print: (line = "") => renderer.println(line),
350
+ theme: renderer.theme,
351
+ fit: (line) => line,
352
+ };
353
+ const hooks = {
354
+ mode: () => session.getMode(),
355
+ cycleMode: () => announceMode(out, session.cycleMode()),
356
+ openPalette: () => openPalette(renderer, lease, opts.slashCommands ?? []),
357
+ };
358
+ // The TUI's picker (P6 track 1): an overlay drawer on the SAME reader this
359
+ // loop holds, exactly like the palette. Declines rather than painting into a
360
+ // terminal with no room for a drawer — a modal that renders nothing while
361
+ // still eating every keystroke is indistinguishable from a hang.
362
+ const pick = async (items, pickOpts) => {
363
+ if (!canOverlay(renderer))
364
+ return null;
365
+ const result = await selectList(items, {
366
+ title: pickOpts.title,
367
+ toLabel: pickOpts.toLabel,
368
+ ...(pickOpts.initialIndex === undefined
369
+ ? {}
370
+ : { initialIndex: pickOpts.initialIndex }),
371
+ // The drawer's budget, minus the title row and the key hint the
372
+ // component draws around the list.
373
+ maxVisible: Math.max(1, renderer.overlayRows() - 2),
374
+ }, createOverlayIO(renderer, lease));
375
+ return result.kind === "selected" ? result.value : null;
376
+ };
377
+ for (;;) {
378
+ const line = await readLine(lease.handle(), renderer, hooks);
379
+ if (line === null)
380
+ return "eof";
381
+ const outcome = await dispatch(line, session, renderer, out, opts.slashCommands ?? [], opts.checkpoints, pick);
382
+ if (outcome !== null)
383
+ return outcome;
384
+ }
385
+ }