@cruxy/cli 1.2.0 → 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 (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  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 +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -3,13 +3,20 @@ import { loadConfig } from "../../config/index.js";
3
3
  import { shouldUseColor, usageError } from "../../errors/index.js";
4
4
  import { themeForColor } from "../../theme/index.js";
5
5
  import { logger } from "../../utils/logger.js";
6
- import { loadUsage, summarizeRuns, renderSummary, } from "../../usage/index.js";
6
+ import { loadUsage, runCountLabel, selectRuns, usageReport, } from "../../usage/index.js";
7
7
  /**
8
- * `cruxy usage` (C.22) — show LOCAL token usage and (when priced) cost, read
8
+ * `cruxy usage` (C.22) — show LOCAL token usage, weighted tokens and cost, read
9
9
  * back from `~/.cruxy/usage`. Read-only and local: it prints your own accounting
10
- * and transmits nothing. Every figure is real a request the provider never
11
- * reported usage for is shown as unreported, never a fabricated number, and cost
12
- * appears only for tiers you have priced.
10
+ * and transmits nothing (this command makes no request of its own; it only reads
11
+ * figures the gateway already sent on chat responses). Every figure is real — a
12
+ * request the provider never reported usage for is shown as unreported, never a
13
+ * fabricated number.
14
+ *
15
+ * The report itself lives in `usage/report.ts` (P6 track 2). This file is now
16
+ * only the commander half: flags in, scope out, lines printed. `/usage` renders
17
+ * the SAME report from inside a session, so the two surfaces cannot drift — and
18
+ * in particular cannot end up disagreeing about what the weighted figure means,
19
+ * which is the part that has to be said identically every time.
13
20
  */
14
21
  export function usageCommand() {
15
22
  return new Command("usage")
@@ -24,44 +31,25 @@ export function usageCommand() {
24
31
  const { data, error } = loadUsage();
25
32
  if (error)
26
33
  throw error;
27
- const scoped = selectRuns(data.runs, opts);
28
- if (scoped.length === 0) {
29
- logger.print(t.muted("no usage recorded yet"));
30
- if (!config.usage.enabled) {
31
- logger.print(t.muted("usage tracking is off (usage.enabled = false)"));
32
- }
33
- return;
34
- }
35
- const summary = summarizeRuns(scoped, {
36
- prices: config.usage.prices,
37
- currency: config.usage.currency,
38
- });
39
- const scopeLabel = opts.session
40
- ? "current session"
41
- : opts.last
42
- ? `last ${scoped.length} run${scoped.length === 1 ? "" : "s"}`
43
- : `all ${scoped.length} run${scoped.length === 1 ? "" : "s"}`;
44
- logger.print(t.heading(`usage — ${scopeLabel}`));
45
- logger.print(renderSummary(summary, t));
46
- // State when NO price is configured, so an absent cost never reads as $0.
47
- // Self-contained: set a number, see cost — copy-pasteable commands, no
48
- // docs lookup and no jargon needed.
49
- if (!summary.priced) {
50
- logger.print(t.muted([
51
- "cost omitted — no prices set. To see cost, set your per-million-token",
52
- "rates for each tier (kavi, vaani, mira):",
53
- " cruxy config set usage.prices.kavi.input 0.5",
54
- " cruxy config set usage.prices.kavi.output 1.5",
55
- ].join("\n")));
34
+ const scope = resolveScope(data.runs, opts);
35
+ const scoped = selectRuns(data.runs, scope);
36
+ for (const line of usageReport(scoped, t, {
37
+ scopeLabel: scopeLabel(opts, scoped.length),
38
+ trackingEnabled: config.usage.enabled,
39
+ legacyPriceConfig: config.usage.currency !== undefined ||
40
+ config.usage.prices !== undefined,
41
+ })) {
42
+ logger.print(line);
56
43
  }
57
44
  });
58
45
  }
59
46
  /**
60
- * Narrow the stored runs to the requested scope. `--last N` keeps the newest N;
61
- * `--session` keeps the runs sharing the most recent run's session id; no flag
62
- * keeps everything retained. `--session` and `--last` are mutually exclusive.
47
+ * Turn the flags into a {@link UsageScope}. `--session` is INFERRED here — from
48
+ * the newest run's session id because a command line has no session of its
49
+ * own to ask. (`/usage` inside a session does not infer: it knows its id.)
50
+ * `--session` and `--last` are mutually exclusive.
63
51
  */
64
- function selectRuns(runs, opts) {
52
+ function resolveScope(runs, opts) {
65
53
  if (opts.session && opts.last !== undefined) {
66
54
  throw usageError("pass only one of --session or --last", [
67
55
  "cruxy usage --session (the most recent session)",
@@ -73,16 +61,28 @@ function selectRuns(runs, opts) {
73
61
  if (!Number.isInteger(n) || n <= 0) {
74
62
  throw usageError(`--last must be a positive integer (got "${opts.last}")`);
75
63
  }
76
- return runs.slice(-n);
64
+ return { last: n };
77
65
  }
78
66
  if (opts.session) {
79
67
  const latest = runs[runs.length - 1];
68
+ // Nothing stored: an empty scope over an empty store is already empty, and
69
+ // the report renders the "nothing yet" answer.
80
70
  if (!latest)
81
- return [];
82
- // Runs without a session id can't be grouped; scope to the latest run alone.
83
- if (latest.sessionId === undefined)
84
- return [latest];
85
- return runs.filter((r) => r.sessionId === latest.sessionId);
71
+ return {};
72
+ // Runs without a session id can't be grouped; scope to the latest alone by
73
+ // asking for the last 1 rather than for an `undefined` session, which would
74
+ // match every other ungrouped run in the store.
75
+ return latest.sessionId === undefined
76
+ ? { last: 1 }
77
+ : { sessionId: latest.sessionId };
86
78
  }
87
- return [...runs];
79
+ return {};
80
+ }
81
+ /** The heading's scope phrase, matching the flag the user actually passed. */
82
+ function scopeLabel(opts, count) {
83
+ if (opts.session)
84
+ return "current session";
85
+ if (opts.last)
86
+ return `last ${runCountLabel(count)}`;
87
+ return `all ${runCountLabel(count)}`;
88
88
  }
@@ -2,6 +2,7 @@ import { resolveApiKey } from "../config/index.js";
2
2
  import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
3
3
  import { createRenderer } from "../render/index.js";
4
4
  import { sessionWorkspace } from "../workspace/index.js";
5
+ import { DEFAULT_MODE } from "../agent/index.js";
5
6
  import { buildAgentSession } from "./session-factory.js";
6
7
  /**
7
8
  * CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
@@ -25,7 +26,7 @@ export async function runFirstWinTask(config, cwd, prompt) {
25
26
  const renderer = createRenderer();
26
27
  // The onboarding first-win is inherently single-root (it runs before any
27
28
  // `--root` is parsed), so it acts over a trivial workspace on its cwd.
28
- const session = buildAgentSession(config, apiKey, sessionWorkspace(cwd), true, false, renderer);
29
+ const session = buildAgentSession(config, apiKey, sessionWorkspace(cwd), true, DEFAULT_MODE, renderer);
29
30
  try {
30
31
  await session.send(prompt, renderer);
31
32
  }
@@ -4,7 +4,10 @@ import { PRODUCT_MASTHEAD } from "../brand/index.js";
4
4
  import { logger } from "../utils/logger.js";
5
5
  import { shouldUseColor, usageError } from "../errors/index.js";
6
6
  import { themeForColor } from "../theme/index.js";
7
- import { runCommand } from "./commands/run.js";
7
+ import { detectCapabilities } from "../render/index.js";
8
+ import { supportsTui } from "../tui/index.js";
9
+ import { executeRun, runCommand } from "./commands/run.js";
10
+ import { suggestCommand } from "./suggest.js";
8
11
  import { configCommand } from "./commands/config.js";
9
12
  import { indexCommand } from "./commands/index.js";
10
13
  import { skillsCommand } from "./commands/skills.js";
@@ -28,7 +31,8 @@ export function buildProgram() {
28
31
  .version(APP_VERSION, "-v, --version", "print the cruxy version")
29
32
  .option("-c, --config <path>", "use a specific config file")
30
33
  .option("--log-level <level>", "debug | info | warn | error | silent")
31
- .option("--verbose", "shorthand for --log-level debug");
34
+ .option("--verbose", "shorthand for --log-level debug")
35
+ .option("--resume [id]", "resume a saved session by id; omit the id to pick from recent sessions");
32
36
  // Apply global options as early as possible.
33
37
  program.hook("preAction", (thisCommand) => {
34
38
  const opts = thisCommand.opts();
@@ -51,17 +55,40 @@ export function buildProgram() {
51
55
  program.addCommand(memoryCommand());
52
56
  program.addCommand(usageCommand());
53
57
  program.addCommand(mcpCommand());
54
- // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
55
- // means an unknown command (Commander runs the default action with it as an
56
- // operand rather than erroring), so reject it as a usage error.
57
- program.action(async (_opts, command) => {
58
- if (command.args.length > 0) {
59
- throw usageError(`unknown command: ${command.args[0]}`, [
60
- "run `cruxy --help` to see available commands",
61
- ]);
58
+ // Default action: bare `cruxy` opens the TUI; `cruxy <message>` opens it and
59
+ // runs that message as the first turn. Operands reach here only when they
60
+ // matched no subcommand, so a bare token is a MESSAGE by default — only a
61
+ // near-miss for a real command name is refused (see `suggestCommand`).
62
+ program.arguments("[message...]");
63
+ program.action(async (messageParts, opts, command) => {
64
+ const operands = messageParts ?? command.args;
65
+ if (operands.length === 1) {
66
+ const known = command.commands.map((c) => c.name());
67
+ const meant = suggestCommand(operands[0], known);
68
+ if (meant !== null) {
69
+ throw usageError(`unknown command: ${operands[0]}`, [
70
+ `did you mean \`cruxy ${meant}\`?`,
71
+ "run `cruxy --help` to see available commands",
72
+ ]);
73
+ }
62
74
  }
63
- // First-run with no key (and a TTY) guided setup, with the first-win demo.
64
- // Otherwise fall through to the banner (an already-set-up user, or non-TTY).
75
+ // Nothing to do and nowhere to do it: no message, and an environment that
76
+ // cannot host the TUI (a pipe, CI, TERM=dumb, a screen reader). Print
77
+ // guidance and exit 0 — a bare `cruxy` in a script has always succeeded,
78
+ // and a foundation change must not turn that into a failure.
79
+ //
80
+ // `--resume` is excluded: it names something specific to do, so it must
81
+ // reach the run pipeline (and fail loud on a bad id) rather than being
82
+ // answered with a banner.
83
+ if (operands.length === 0 &&
84
+ opts.resume === undefined &&
85
+ !supportsTui(detectCapabilities())) {
86
+ printGuidance();
87
+ return;
88
+ }
89
+ // First-run with no key (and a TTY) → guided setup, with the first-win
90
+ // demo. `executeRun` runs the same check for the keyed path; doing it here
91
+ // first keeps a brand-new user out of a TUI they cannot yet use.
65
92
  const { config } = loadConfig();
66
93
  const onboarding = maybeRunOnboarding(config, {
67
94
  ttyInteractive: Boolean(process.stdin.isTTY),
@@ -72,12 +99,15 @@ export function buildProgram() {
72
99
  await onboarding;
73
100
  return;
74
101
  }
75
- const t = themeForColor(shouldUseColor(process.stdout));
76
- logger.print(t.accent(`${APP_NAME} v${APP_VERSION}`));
77
- logger.print(t.muted("an agentic coding CLI\n"));
78
- logger.print("The interactive REPL lands in C.3 (terminal UI).");
79
- logger.print(`For now try: ${t.strong('cruxy run "<task>"')} or ${t.strong("cruxy config path")}`);
80
- logger.print(`See all commands: ${t.strong("cruxy --help")}`);
102
+ // The TUI is a REQUEST: on a pipe, a screen reader, or TERM=dumb the
103
+ // factory declines it and this is exactly the pre-existing REPL/one-shot
104
+ // path, with the pre-existing renderers.
105
+ await executeRun(operands, {
106
+ root: [],
107
+ tui: true,
108
+ commandName: "cruxy",
109
+ ...(opts.resume !== undefined ? { resume: opts.resume } : {}),
110
+ });
81
111
  });
82
112
  // Throw CommanderError instead of calling process.exit, and suppress
83
113
  // Commander's own "error:" line — so parse errors (unknown command/option,
@@ -87,6 +117,18 @@ export function buildProgram() {
87
117
  routeErrorsToBoundary(program);
88
118
  return program;
89
119
  }
120
+ /**
121
+ * The banner for an environment that cannot open the TUI and was given nothing
122
+ * to run. Exits 0 — this is guidance, not a failure.
123
+ */
124
+ function printGuidance() {
125
+ const t = themeForColor(shouldUseColor(process.stdout));
126
+ logger.print(t.accent(`${APP_NAME} v${APP_VERSION}`));
127
+ logger.print(t.muted("an agentic coding CLI\n"));
128
+ logger.print(`Start a session: ${t.strong("cruxy")} ${t.muted("(needs a terminal)")}`);
129
+ logger.print(`Run one task: ${t.strong('cruxy "<task>"')}`);
130
+ logger.print(`See all commands: ${t.strong("cruxy --help")}`);
131
+ }
90
132
  /** Recursively make a command (and its subcommands) throw on error, silently. */
91
133
  function routeErrorsToBoundary(command) {
92
134
  command.exitOverride();
package/dist/cli/repl.js CHANGED
@@ -1,12 +1,38 @@
1
1
  import readline from "node:readline";
2
2
  import { makeReplCompleter } from "../components/index.js";
3
- import { resolveSlash } from "../hooks/index.js";
4
- import { runGatedShell } from "../tools/shell/exec.js";
5
- import { addRootToWorkspace } from "../workspace/index.js";
6
3
  import { themeForColor } from "../theme/index.js";
7
- import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
4
+ import { shouldUseColor } from "../errors/index.js";
8
5
  import { createRenderer, fit, resolveColumns, } from "../render/index.js";
6
+ import { SHARED_COMMANDS, SHARED_HELP, dispatchCommand, printCommandError, } from "./session-commands.js";
7
+ import { defaultComponentIO, selectList } from "../components/index.js";
9
8
  import { logger } from "../utils/logger.js";
9
+ /**
10
+ * The REPL's picker (P6 track 1): the standalone stderr frame every U.7
11
+ * component builds by default, over its own raw-mode reader.
12
+ *
13
+ * Safe here specifically because of how {@link readLine} works — the readline
14
+ * interface is created per line and CLOSED before dispatch runs, so nothing else
15
+ * holds stdin at this point. That is the same property the approval prompt has
16
+ * always relied on; this is not a new claim on the terminal.
17
+ *
18
+ * Declines on a non-interactive stdin rather than letting `resolveNonInteractive`
19
+ * throw: `/model` with no argument is a request to browse, and a piped session
20
+ * answering it with a fatal `CRUXY_E_INTERACTIVE_REQUIRED` would kill a REPL over
21
+ * a command that has a perfectly good text form.
22
+ */
23
+ const replPicker = async (items, opts) => {
24
+ const io = defaultComponentIO();
25
+ if (!io.interactive)
26
+ return null;
27
+ const result = await selectList(items, {
28
+ title: opts.title,
29
+ toLabel: opts.toLabel,
30
+ ...(opts.initialIndex === undefined
31
+ ? {}
32
+ : { initialIndex: opts.initialIndex }),
33
+ }, io);
34
+ return result.kind === "selected" ? result.value : null;
35
+ };
10
36
  /** The REPL prompts on stdout; its chrome resolves against stdout's color. */
11
37
  const theme = themeForColor(shouldUseColor(process.stdout));
12
38
  /** Fit a committed REPL line to stdout's current width (U.12), id/status-first. */
@@ -15,32 +41,26 @@ function fitOut(line) {
15
41
  }
16
42
  const PROMPT = `${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
17
43
  /**
18
- * The REPL's slash commands the autocomplete candidate set (U.7). Keep in
19
- * sync with the dispatch below and the HELP text.
44
+ * Where the REPL's command output goes: straight to the logger, fitted to
45
+ * stdout's width. The TUI supplies its own; everything else about a command is
46
+ * shared (see `session-commands.ts`).
47
+ */
48
+ const replOutput = {
49
+ print: (line = "") => logger.print(line),
50
+ theme,
51
+ fit: fitOut,
52
+ };
53
+ /**
54
+ * The REPL's slash commands — the autocomplete candidate set (U.7). Derived from
55
+ * the shared list so a command added there is completable here without a second
56
+ * edit; this shell adds nothing of its own.
20
57
  */
21
- export const REPL_COMMANDS = [
22
- "/help",
23
- "/clear",
24
- "/compact",
25
- "/reload",
26
- "/plan",
27
- "/jobs",
28
- "/logs",
29
- "/cancel",
30
- "/exit",
31
- "/quit",
32
- ];
33
- const HELP = `Commands:
34
- /help show this help
35
- /clear clear the conversation history (keep the session)
36
- /compact summarize older history to free up context now
37
- /reload re-read project instructions (CRUXY.md)
38
- /plan toggle plan mode (propose a plan before executing)
39
- /jobs list background jobs and their status
40
- /logs <id> show a background job's log
41
- /cancel <id> cancel a background job
42
- /exit, /quit leave cruxy
43
- Ctrl+D leave cruxy`;
58
+ export const REPL_COMMANDS = SHARED_COMMANDS;
59
+ const HELP = [
60
+ "Commands:",
61
+ ...SHARED_HELP,
62
+ " Ctrl+D leave cruxy",
63
+ ].join("\n");
44
64
  const defaultIO = () => ({
45
65
  input: process.stdin,
46
66
  output: process.stdout,
@@ -83,86 +103,11 @@ function readLine(io, prompt) {
83
103
  });
84
104
  });
85
105
  }
86
- /**
87
- * Run a shell-bound custom slash command (C.19) through the SAME gated +
88
- * sandboxed path as `run_command` (`runGatedShell` over the session's tool
89
- * context) — never a privileged route. Prints the result like a shell run; a
90
- * gate rejection or a thrown coded error (e.g. sandbox) is surfaced, not fatal.
91
- */
92
- async function runSlashShell(spec, session) {
93
- logger.print(theme.muted(`running /${spec.name}${theme.glyph.ellipsis}`));
94
- try {
95
- const outcome = await runGatedShell(spec.command ?? "", session.toolContext);
96
- if (!outcome.approved) {
97
- logger.print(theme.muted(outcome.rejection ?? "command denied by the user"));
98
- return;
99
- }
100
- const e = outcome.exec;
101
- if (e.timedOut) {
102
- logger.print(theme.warning("timed out"));
103
- return;
104
- }
105
- if (e.spawnError !== undefined) {
106
- logger.print(theme.danger(e.spawnError));
107
- return;
108
- }
109
- logger.print(`exit code ${e.exitCode ?? e.signal ?? "unknown"}\n${e.output}`);
110
- }
111
- catch (err) {
112
- printReplError(err);
113
- }
114
- }
115
- /**
116
- * Render a non-fatal error inline (classified + formatted, the same 4-part
117
- * shape as the fatal boundary) and return to the prompt — the REPL must survive
118
- * a failed turn rather than exit.
119
- */
120
- function printReplError(err) {
121
- const cruxy = fromUnknown(err);
122
- logger.print(formatError(cruxy, {
123
- verbose: isVerbose(),
124
- color: shouldUseColor(process.stdout),
125
- }));
126
- }
127
- /**
128
- * `/add-root <name> <path>` (C.26 step 5) — an explicit, human-only way to
129
- * declare another workspace root. This is a REPL command, NOT a model tool: the
130
- * model can only reach the tool registry, and nothing named `add_root` is
131
- * registered there, so the allowlist argument is unchanged. It validates the
132
- * addition (TTY-only; same existence/name/overlap refusal as `--root`) and, on
133
- * success, tells the user to relaunch with `--root` to activate it — a
134
- * mid-session hot-swap would leave the checkpoint gate + hook router (both wired
135
- * from the session-start workspace) half-attributed, so activation is deferred
136
- * to a clean relaunch. A newly declared root always starts untrusted.
137
- */
138
- async function handleAddRoot(input, session) {
139
- const parts = input
140
- .slice("/add-root".length)
141
- .trim()
142
- .split(/\s+/)
143
- .filter(Boolean);
144
- if (parts.length !== 2) {
145
- logger.print(theme.muted("usage: /add-root <name> <path>"));
146
- return;
147
- }
148
- const [name, rootPath] = parts;
149
- const ctx = session.toolContext;
150
- const current = ctx.workspace;
151
- try {
152
- const next = await addRootToWorkspace(current, { name, path: rootPath }, { cwd: ctx.cwd, tty: Boolean(process.stdin.isTTY) });
153
- const abs = next.rootByName(name).absPath;
154
- logger.print(theme.muted(`validated root "${name}" (${abs}). relaunch with \`--root ${name}=${rootPath}\` to activate it — ` +
155
- `it starts untrusted (its hooks and project memory stay inert until you run \`cruxy hooks/memory trust\`).`));
156
- }
157
- catch (err) {
158
- printReplError(err);
159
- }
160
- }
161
106
  /**
162
107
  * Service any background-job approvals that piled up (C.28) — the auto-surface
163
108
  * point. Called when the foreground is idle (between turns), so a paused job's
164
- * gated action is decided through the SAME U.3 prompt as a foreground one, one at
165
- * a time. No pending → a no-op (nothing printed). The job resumes on approval.
109
+ * gated action is decided through the SAME U.3 prompt as a foreground one, one
110
+ * at a time. No pending → a no-op (nothing printed). The job resumes on approval.
166
111
  */
167
112
  async function drainJobApprovals(session) {
168
113
  const jobs = session.jobs;
@@ -171,78 +116,6 @@ async function drainJobApprovals(session) {
171
116
  logger.print(theme.muted(`\n${theme.glyph.bullet} a background job needs your approval:`));
172
117
  await jobs.serviceApprovals();
173
118
  }
174
- /** Render the background-job list (`/jobs`). */
175
- function handleJobsList(session) {
176
- const jobs = session.jobs;
177
- if (!jobs) {
178
- logger.print(theme.muted("background jobs are disabled — enable with `cruxy config set jobs.enabled true`"));
179
- return;
180
- }
181
- const list = jobs.list();
182
- if (list.length === 0) {
183
- logger.print(theme.muted("no background jobs this session"));
184
- return;
185
- }
186
- for (const j of list) {
187
- const status = j.status === "failed" ? theme.danger(j.status) : theme.accent(j.status);
188
- const pending = j.pendingApproval
189
- ? theme.muted(` — needs approval: ${j.pendingApproval}`)
190
- : "";
191
- const err = j.error ? theme.muted(` (${j.error})`) : "";
192
- // Fit id-first so the job id + status always survive; the label/notes tail
193
- // truncates with an honest ellipsis at narrow width (U.12).
194
- logger.print(fitOut(`${theme.strong(j.id)} ${status} ${j.label}${pending}${err}`));
195
- }
196
- }
197
- /** Print one job's log (`/logs <id>`). */
198
- function handleJobLogs(input, session) {
199
- const jobs = session.jobs;
200
- if (!jobs) {
201
- logger.print(theme.muted("background jobs are disabled"));
202
- return;
203
- }
204
- const id = input.slice("/logs".length).trim();
205
- if (!id) {
206
- logger.print(theme.muted("usage: /logs <id>"));
207
- return;
208
- }
209
- try {
210
- const log = jobs.logs(id);
211
- if (log.dropped > 0) {
212
- logger.print(theme.muted(`… ${log.dropped} earlier line(s) rolled off`));
213
- }
214
- for (const line of log.lines) {
215
- const text = line.stream === "err" ? theme.danger(line.text) : line.text;
216
- logger.print(fitOut(text));
217
- }
218
- logger.print(theme.muted(`(${log.status})`));
219
- }
220
- catch (err) {
221
- printReplError(err);
222
- }
223
- }
224
- /** Cancel a job (`/cancel <id>`). */
225
- async function handleJobCancel(input, session) {
226
- const jobs = session.jobs;
227
- if (!jobs) {
228
- logger.print(theme.muted("background jobs are disabled"));
229
- return;
230
- }
231
- const id = input.slice("/cancel".length).trim();
232
- if (!id) {
233
- logger.print(theme.muted("usage: /cancel <id>"));
234
- return;
235
- }
236
- try {
237
- const cancelled = jobs.cancel(id);
238
- logger.print(theme.muted(cancelled
239
- ? `cancelling ${id} (its process tree is killed; any checkpoint survives for rollback)`
240
- : `${id} is already finished`));
241
- }
242
- catch (err) {
243
- printReplError(err);
244
- }
245
- }
246
119
  /**
247
120
  * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
248
121
  * commands or run a turn, repeat. Assistant text and tool-call progress stream
@@ -279,80 +152,25 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
279
152
  const trimmed = line.trim();
280
153
  if (trimmed === "")
281
154
  continue; // empty line → reprompt, no model call
282
- if (trimmed === "/exit" || trimmed === "/quit") {
283
- logger.print(theme.muted("bye"));
284
- return;
285
- }
286
- if (trimmed === "/clear") {
287
- session.clear();
288
- logger.print(theme.muted("history cleared"));
289
- continue;
290
- }
291
- if (trimmed === "/compact") {
292
- try {
293
- const n = await session.compact();
294
- logger.print(theme.muted(n ? `compacted ${n} older messages` : "nothing to compact yet"));
295
- }
296
- catch (err) {
297
- printReplError(err);
298
- }
299
- continue;
300
- }
301
- if (trimmed === "/reload") {
302
- const loaded = session.reloadProjectInstructions();
303
- logger.print(theme.muted(loaded
304
- ? "reloaded project instructions (CRUXY.md)"
305
- : "no project instructions found"));
306
- continue;
307
- }
308
- if (trimmed === "/plan") {
309
- session.setPlanMode(!session.getPlanMode());
310
- const on = session.getPlanMode();
311
- logger.print(theme.muted(on
312
- ? "plan mode on — the next prompt proposes a plan for approval"
313
- : "plan mode off"));
314
- continue;
315
- }
316
155
  if (trimmed === "/help") {
317
156
  logger.print(HELP);
318
157
  continue;
319
158
  }
320
- if (trimmed === "/jobs") {
321
- handleJobsList(session);
322
- continue;
323
- }
324
- if (trimmed === "/logs" || trimmed.startsWith("/logs ")) {
325
- handleJobLogs(trimmed, session);
326
- continue;
327
- }
328
- if (trimmed === "/cancel" || trimmed.startsWith("/cancel ")) {
329
- await handleJobCancel(trimmed, session);
330
- continue;
331
- }
332
- if (trimmed === "/add-root" || trimmed.startsWith("/add-root ")) {
333
- await handleAddRoot(trimmed, session);
334
- continue;
335
- }
336
- // Custom slash commands (C.19) — consulted AFTER builtins, so a custom
337
- // command can never shadow /help, /exit, etc. A `prompt` command expands to
338
- // text fed to the agent (safe); a `shell` command runs through the SAME
339
- // gate + sandbox as any command (never a bypass). Unknown "/…" input falls
340
- // through to a normal turn, preserving prior behavior.
341
- const slash = resolveSlash(trimmed, slashCommands);
342
- if (slash.kind === "prompt") {
343
- try {
344
- checkpoints?.beginRun(slash.prompt);
345
- await session.send(slash.prompt, renderer);
346
- }
347
- catch (err) {
348
- printReplError(err);
349
- }
350
- continue;
159
+ // Every other command is the SHARED implementation (P5 track 5) — the same
160
+ // one the TUI dispatches through, so the two shells cannot drift again.
161
+ const outcome = await dispatchCommand(line, {
162
+ session,
163
+ out: replOutput,
164
+ slashCommands,
165
+ tty: Boolean(process.stdin.isTTY),
166
+ pick: replPicker,
167
+ });
168
+ if (outcome.kind === "exit") {
169
+ logger.print(theme.muted("bye"));
170
+ return;
351
171
  }
352
- if (slash.kind === "shell") {
353
- await runSlashShell(slash.spec, session);
172
+ if (outcome.kind === "handled")
354
173
  continue;
355
- }
356
174
  // A real turn. Assistant text streams through the renderer delta by delta
357
175
  // (leading blank lines trimmed, code fences highlighted); the agent loop
358
176
  // closes each segment with one newline, so the next prompt lands on its own
@@ -361,11 +179,11 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
361
179
  try {
362
180
  // Each REPL turn is its own undo unit (C.32): a fresh checkpoint latch,
363
181
  // so `cruxy rollback` reverts exactly one turn's mutations.
364
- checkpoints?.beginRun(trimmed);
365
- await session.send(line, renderer);
182
+ checkpoints?.beginRun(outcome.text);
183
+ await session.send(outcome.text, renderer);
366
184
  }
367
185
  catch (err) {
368
- logger.error(err.message);
186
+ printCommandError(replOutput, err);
369
187
  }
370
188
  }
371
189
  }