@cruxy/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,12 @@ export CRUXY_API_KEY=cxy_live_... # …or just use an env var (always wins)
58
58
  validated, and never auto-executed.
59
59
  - **Agent** — streaming output, multi-turn interactive sessions, context
60
60
  compaction, and awareness of git state and project instructions (`CRUXY.md`).
61
+ - **Streaming render** — flicker-free live output: a single in-place status line
62
+ (spinner while the model thinks / tools run), append-only committed text,
63
+ syntax-highlighted code fences, tool-call notes, and diffs drawn by the same
64
+ renderer as the approval prompt. Degrades cleanly: piped/CI output is plain
65
+ append-only text with zero ANSI (chrome on stderr), `NO_COLOR` drops color,
66
+ and `CRUXY_NO_SPINNER=1` stills the animation.
61
67
  - **Plan mode** (opt-in: `cruxy run --plan`, `/plan`, or `agent.planMode`) — the
62
68
  agent proposes a structured, step-by-step plan; you approve it once, then it
63
69
  executes with live per-step status. Approving consents to the _shape_ of the
@@ -1,5 +1,6 @@
1
1
  import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
+ import type { StreamRenderer } from "../render/index.js";
3
4
  import type { ToolContext } from "../tools/index.js";
4
5
  import { ToolRegistry } from "../tools/index.js";
5
6
  export interface RunAgentArgs {
@@ -17,12 +18,13 @@ export interface RunAgentArgs {
17
18
  /** Ambient capabilities handed to each tool. */
18
19
  ctx: ToolContext;
19
20
  /**
20
- * Optional sink for assistant text as it streams: called per text delta, then
21
- * once with a lone "\n" to close each non-empty text segment on its own line.
22
- * When set, `runAgent` streams live and does not buffer-print the turn (the
23
- * caller renders); when omitted, behavior is unchanged (one buffered print).
21
+ * The render seam (U.2): assistant text streams through `renderer.write`
22
+ * delta by delta (each non-empty segment closed via `endSegment`), and
23
+ * tool-call progress is surfaced as transient `status` + committed `note`
24
+ * lines. When omitted, behavior is unchanged (one buffered print per turn,
25
+ * no tool-call chrome). The loop never touches stdout directly.
24
26
  */
25
- onText?: (delta: string) => void;
27
+ renderer?: StreamRenderer;
26
28
  /** Git context (branch + dirty) for the system prompt's Environment section. */
27
29
  git?: {
28
30
  branch: string;
@@ -10,10 +10,23 @@ import { buildSystemPrompt } from "./prompts.js";
10
10
  * silently running tool-less.
11
11
  */
12
12
  export async function runAgent(args) {
13
- const { provider, registry, config, ctx } = args;
13
+ const { provider, config, renderer } = args;
14
14
  if (!provider.supportsTools) {
15
15
  throw providerUnsupported(config.model.provider);
16
16
  }
17
+ renderer?.beginTurn();
18
+ try {
19
+ return await driveLoop(args, renderer);
20
+ }
21
+ finally {
22
+ // Always leave the terminal clean: no orphaned status line, no held text —
23
+ // even when a provider error aborts the turn mid-stream.
24
+ renderer?.endTurn();
25
+ }
26
+ }
27
+ /** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
28
+ async function driveLoop(args, renderer) {
29
+ const { provider, registry, config, ctx } = args;
17
30
  const { logger } = ctx;
18
31
  // Work on a copy so we never mutate the caller's array as a side effect; the
19
32
  // extended history is returned for the caller to adopt.
@@ -42,6 +55,8 @@ export async function runAgent(args) {
42
55
  let turnText = "";
43
56
  const pending = new Map();
44
57
  const toolUses = [];
58
+ // Live progress while waiting on the model; dismissed by the first delta.
59
+ renderer?.status("thinking…");
45
60
  for await (const ev of provider.stream({
46
61
  system,
47
62
  messages,
@@ -50,7 +65,7 @@ export async function runAgent(args) {
50
65
  switch (ev.type) {
51
66
  case "text_delta":
52
67
  turnText += ev.text;
53
- args.onText?.(ev.text);
68
+ renderer?.write(ev.text);
54
69
  break;
55
70
  case "tool_use_start":
56
71
  pending.set(ev.index, { id: ev.id, name: ev.name });
@@ -83,13 +98,13 @@ export async function runAgent(args) {
83
98
  }
84
99
  // ── Record the assistant turn ───────────────────────────────────────────
85
100
  if (turnText) {
86
- // Streaming (onText set): the text already reached the user delta by delta,
87
- // so close the segment with a single newline through the *same* sinkno
88
- // separate buffered print racing the stream so tool output, the next
89
- // turn, or an approval prompt starts on its own line. Otherwise render the
90
- // whole buffered block (no-callback path, unchanged).
91
- if (args.onText)
92
- args.onText("\n");
101
+ // Streaming (renderer set): the text already reached the user delta by
102
+ // delta, so close the segment through the *same* rendererit flushes any
103
+ // held partial line and terminates with one newline, so tool output, the
104
+ // next turn, or an approval prompt starts on its own line. Otherwise
105
+ // render the whole buffered block (no-renderer path, unchanged).
106
+ if (renderer)
107
+ renderer.endSegment();
93
108
  else
94
109
  logger.print(turnText);
95
110
  }
@@ -105,13 +120,40 @@ export async function runAgent(args) {
105
120
  // ── Execute each tool call, collecting one tool_result per call ──────────
106
121
  const toolResults = [];
107
122
  for (const call of toolUses) {
108
- toolResults.push(await runToolCall(call, registry, ctx));
123
+ const label = describeToolCall(call);
124
+ renderer?.status(`${label}…`);
125
+ const result = await runToolCall(call, registry, ctx);
126
+ renderer?.note(`${result.is_error ? "✗" : "✓"} ${label}`);
127
+ toolResults.push(result);
109
128
  }
110
129
  messages.push({ role: "user", content: toolResults });
111
130
  }
112
131
  logger.warn(`reached maxIterations (${maxIterations}) without completing`);
113
132
  return { messages, iterations, stop: "max_iterations", usage };
114
133
  }
134
+ /** Input keys worth surfacing in tool-call chrome, in preference order. */
135
+ const HINT_KEYS = ["path", "file_path", "command", "pattern", "query", "url"];
136
+ /** Longest hint shown before truncation — chrome, not information of record. */
137
+ const HINT_MAX = 60;
138
+ /**
139
+ * A short human label for a tool call ("read_file src/x.ts"): the tool name
140
+ * plus the first recognizable scalar argument, if any. Best-effort — unknown
141
+ * shapes fall back to the bare name.
142
+ */
143
+ function describeToolCall(call) {
144
+ const input = call.input;
145
+ if (typeof input === "object" && input !== null) {
146
+ for (const key of HINT_KEYS) {
147
+ const value = input[key];
148
+ if (typeof value === "string" && value !== "") {
149
+ const flat = value.replace(/\s+/g, " ").trim();
150
+ const hint = flat.length > HINT_MAX ? flat.slice(0, HINT_MAX - 1) + "…" : flat;
151
+ return `${call.name} ${hint}`;
152
+ }
153
+ }
154
+ }
155
+ return call.name;
156
+ }
115
157
  /**
116
158
  * Dispatch a single reassembled tool call to its tool and shape the outcome as
117
159
  * a `tool_result` block. Unknown tools and invalid arguments become `is_error`
@@ -1,5 +1,6 @@
1
1
  import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
+ import type { StreamRenderer } from "../render/index.js";
3
4
  import type { ToolContext } from "../tools/index.js";
4
5
  import type { ToolRegistry } from "../tools/index.js";
5
6
  import { type AgentResult } from "./loop.js";
@@ -12,7 +13,7 @@ import { type AgentResult } from "./loop.js";
12
13
  export type PlanRunner = (args: {
13
14
  messages: Message[];
14
15
  projectInstructions: string | null;
15
- onText?: (delta: string) => void;
16
+ renderer?: StreamRenderer;
16
17
  }) => Promise<AgentResult>;
17
18
  export interface SessionArgs {
18
19
  /** A constructed provider to stream from. */
@@ -76,10 +77,11 @@ export declare class Session {
76
77
  * the threshold, drive the agent loop over the full history, adopt the
77
78
  * extended history, and accumulate usage. Returns the turn's `AgentResult`.
78
79
  *
79
- * `onText`, when supplied, receives assistant text deltas as they stream so the
80
- * caller can render them live (see the REPL); history is unaffected.
80
+ * `renderer`, when supplied, receives assistant text deltas and tool-call
81
+ * progress as they stream so the caller sees the turn live (see the REPL);
82
+ * history is unaffected.
81
83
  */
82
- send(userPrompt: string, onText?: (delta: string) => void): Promise<AgentResult>;
84
+ send(userPrompt: string, renderer?: StreamRenderer): Promise<AgentResult>;
83
85
  /**
84
86
  * Re-read project instructions (CRUXY.md / AGENTS.md) from the working
85
87
  * directory so edits take effect without restarting. Returns the new text, or
@@ -74,10 +74,11 @@ export class Session {
74
74
  * the threshold, drive the agent loop over the full history, adopt the
75
75
  * extended history, and accumulate usage. Returns the turn's `AgentResult`.
76
76
  *
77
- * `onText`, when supplied, receives assistant text deltas as they stream so the
78
- * caller can render them live (see the REPL); history is unaffected.
77
+ * `renderer`, when supplied, receives assistant text deltas and tool-call
78
+ * progress as they stream so the caller sees the turn live (see the REPL);
79
+ * history is unaffected.
79
80
  */
80
- async send(userPrompt, onText) {
81
+ async send(userPrompt, renderer) {
81
82
  this.messages.push({ role: "user", content: userPrompt });
82
83
  // Compact *before* the agent call so the turn runs against a bounded history.
83
84
  await this.maybeCompact();
@@ -88,7 +89,7 @@ export class Session {
88
89
  ? await this.args.planRunner({
89
90
  messages: this.messages,
90
91
  projectInstructions: this.projectInstructions,
91
- onText,
92
+ renderer,
92
93
  })
93
94
  : await runAgent({
94
95
  messages: this.messages,
@@ -96,7 +97,7 @@ export class Session {
96
97
  // After the spread so a mid-session `/reload` wins over the initial value.
97
98
  projectInstructions: this.projectInstructions,
98
99
  planMode: false, // the plan directive belongs only to the runner's propose phase
99
- onText,
100
+ renderer,
100
101
  });
101
102
  this.messages = result.messages;
102
103
  this.usage.input_tokens += result.usage.input_tokens;
@@ -1,4 +1,13 @@
1
1
  import type { ApprovalRequest } from "./types.js";
2
+ /**
3
+ * The interactive prompt: render a pending action (a real diff for file edits,
4
+ * the exact command + cwd for shell) and read a 4-way choice. Rendering is data
5
+ * → string so it's testable; color is gated on `io.color` (NO_COLOR / non-TTY
6
+ * aware). **Default-deny**: EOF / Ctrl-C / any unrecognized key → reject.
7
+ *
8
+ * Diff/preview rendering is the shared implementation in `render/diff.ts` —
9
+ * the streaming path and this prompt draw the same bytes for the same change.
10
+ */
2
11
  /** The four user choices (plus the implicit default-deny). */
3
12
  export type PromptChoice = {
4
13
  kind: "once";
@@ -1,7 +1,6 @@
1
1
  import path from "node:path";
2
2
  import pc from "picocolors";
3
- /** Cap on rendered preview lines before collapsing the rest. */
4
- const PREVIEW_MAX_LINES = 40;
3
+ import { renderActionPreview } from "../render/diff.js";
5
4
  /**
6
5
  * Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
7
6
  * read a follow-up line (reason / instruction). Anything else — including EOF —
@@ -53,7 +52,7 @@ function detail(request, c) {
53
52
  ` ${c.dim(`in ${request.cwd}`)}`,
54
53
  ].join("\n");
55
54
  }
56
- return renderPreview(request.action.preview, c);
55
+ return renderActionPreview(request.action.preview, c);
57
56
  }
58
57
  /** The choices line, including a short label of what an `a` grant would cover. */
59
58
  function choices(scope, c) {
@@ -71,80 +70,6 @@ function scopeLabel(scope) {
71
70
  return `changes under ${path.basename(scope.root)}/`;
72
71
  return null;
73
72
  }
74
- // ── diff rendering (shared with the old C.6 renderer) ──────────────────────────
75
- function diffLines(oldStr, newStr, c) {
76
- const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
77
- const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
78
- return [...removed, ...added];
79
- }
80
- function renderPatchFiles(files, c) {
81
- const out = [];
82
- for (const file of files) {
83
- if (file.op === "delete") {
84
- out.push(c.red(`delete ${file.path}`));
85
- }
86
- else if (file.op === "create") {
87
- out.push(c.green(`create ${file.path}`));
88
- out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
89
- if (file.omittedLines > 0)
90
- out.push(c.dim(` ...${file.omittedLines} more lines`));
91
- }
92
- else {
93
- out.push(c.yellow(`update ${file.path}`));
94
- for (const hunk of file.hunks)
95
- out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
96
- }
97
- }
98
- return out;
99
- }
100
- /** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
101
- function renderPrPreview(preview, c) {
102
- const out = [];
103
- out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
104
- out.push("");
105
- out.push(c.bold("commit"));
106
- out.push(` ${preview.commitSubject}`);
107
- for (const line of bodyLines(preview.commitBody))
108
- out.push(c.dim(` ${line}`));
109
- out.push("");
110
- out.push(`${c.bold("pull request")} ${preview.prTitle}`);
111
- for (const line of bodyLines(preview.prBody))
112
- out.push(c.dim(` ${line}`));
113
- return out;
114
- }
115
- /** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
116
- function bodyLines(body) {
117
- const lines = body.replace(/\s+$/, "").split("\n");
118
- return lines.length === 1 && lines[0] === "" ? [] : lines;
119
- }
120
- function renderPreview(preview, c) {
121
- if (!preview)
122
- return "";
123
- let lines;
124
- if (preview.type === "edit") {
125
- lines = diffLines(preview.oldStr, preview.newStr, c);
126
- }
127
- else if (preview.type === "patch") {
128
- lines = renderPatchFiles(preview.files, c);
129
- }
130
- else if (preview.type === "pr") {
131
- lines = renderPrPreview(preview, c);
132
- }
133
- else {
134
- const header = preview.exists
135
- ? c.yellow("OVERWRITE existing")
136
- : c.green("create");
137
- const body = preview.lines.map((l) => ` ${l}`);
138
- if (preview.omittedLines > 0)
139
- body.push(c.dim(` ...${preview.omittedLines} more lines`));
140
- lines = [header, ...body];
141
- }
142
- if (lines.length > PREVIEW_MAX_LINES) {
143
- const hidden = lines.length - PREVIEW_MAX_LINES;
144
- lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
145
- }
146
- return lines.map((l) => ` ${l}`).join("\n");
147
- }
148
73
  // ── default stdin-backed PromptIO ──────────────────────────────────────────────
149
74
  /** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
150
75
  export function defaultPromptIO(color) {
@@ -3,8 +3,8 @@ import pc from "picocolors";
3
3
  import { logger } from "../../utils/logger.js";
4
4
  import { loadConfig, resolveApiKey } from "../../config/index.js";
5
5
  import { authMissingKey, usageError } from "../../errors/index.js";
6
+ import { createRenderer } from "../../render/index.js";
6
7
  import { runInteractive } from "../repl.js";
7
- import { createStreamPrinter } from "../stream-print.js";
8
8
  import { buildAgentSession } from "../session-factory.js";
9
9
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
10
10
  export function runCommand() {
@@ -50,22 +50,28 @@ export function runCommand() {
50
50
  }
51
51
  // Plan mode is opt-in: --plan flag overrides the config default.
52
52
  const planMode = opts.plan ?? config.agent.planMode;
53
- const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode);
53
+ // One renderer for the whole run (U.2): the streaming path and the
54
+ // approval prompt's status-suspend hook must share the same live region.
55
+ const renderer = createRenderer();
56
+ const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer);
54
57
  if (interactive) {
55
- await runInteractive(session);
58
+ await runInteractive(session, undefined, renderer);
56
59
  return;
57
60
  }
58
61
  // One-shot: a single turn, then exit. Preserves scripting/pipe use.
59
- // Assistant text streams to stdout delta by delta (same as the REPL),
60
- // through a printer that trims the model's leading blank lines; the agent
61
- // loop terminates the line.
62
+ // Assistant text streams to stdout delta by delta (same as the REPL);
63
+ // piped output degrades to the plain renderer (no ANSI, chrome on stderr).
62
64
  logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
63
65
  // Provider/network/auth failures propagate to the top-level boundary,
64
66
  // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
65
67
  // the matching code — a one-shot run must fail non-zero on error.
66
- const print = createStreamPrinter((text) => process.stdout.write(text));
67
- const result = await session.send(prompt, print);
68
- logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
69
- `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
68
+ try {
69
+ const result = await session.send(prompt, renderer);
70
+ logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
71
+ `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
72
+ }
73
+ finally {
74
+ renderer.close();
75
+ }
70
76
  });
71
77
  }
@@ -1,6 +1,6 @@
1
1
  import { resolveApiKey } from "../config/index.js";
2
2
  import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
3
- import { createStreamPrinter } from "./stream-print.js";
3
+ import { createRenderer } from "../render/index.js";
4
4
  import { buildAgentSession } from "./session-factory.js";
5
5
  /**
6
6
  * CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
@@ -23,9 +23,14 @@ export async function runFirstWinTask(config, cwd, prompt) {
23
23
  const apiKey = resolveApiKey(config.model.provider);
24
24
  if (!apiKey)
25
25
  return; // defensive — the key was just persisted
26
- const session = buildAgentSession(config, apiKey, cwd, true);
27
- const print = createStreamPrinter((text) => process.stdout.write(text));
28
- await session.send(prompt, print);
26
+ const renderer = createRenderer();
27
+ const session = buildAgentSession(config, apiKey, cwd, true, false, renderer);
28
+ try {
29
+ await session.send(prompt, renderer);
30
+ }
31
+ finally {
32
+ renderer.close();
33
+ }
29
34
  }
30
35
  /**
31
36
  * Run the guided first-run flow **iff** this is a first run; otherwise return
@@ -1,5 +1,6 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
2
  import type { Session } from "../agent/index.js";
3
+ import { type StreamRenderer } from "../render/index.js";
3
4
  /** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
4
5
  export interface ReplIO {
5
6
  input: Readable;
@@ -7,9 +8,13 @@ export interface ReplIO {
7
8
  }
8
9
  /**
9
10
  * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
10
- * commands or run a turn, repeat. Assistant text streams to stdout from within
11
- * `session.send` (via the logger); this loop only owns input and control.
11
+ * commands or run a turn, repeat. Assistant text and tool-call progress stream
12
+ * through the `renderer` from within `session.send`; this loop only owns input
13
+ * and control.
12
14
  *
13
- * `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
15
+ * `io` defaults to real stdin/stdout; tests inject a scripted stream pair. The
16
+ * renderer defaults to whatever `io.output` supports (a TTY gets the managed
17
+ * live region, anything else the plain append-only renderer); `cruxy run`
18
+ * passes its own so the approval prompt's status-suspend hook shares it.
14
19
  */
15
- export declare function runInteractive(session: Session, io?: ReplIO): Promise<void>;
20
+ export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer): Promise<void>;
package/dist/cli/repl.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import readline from "node:readline";
2
2
  import pc from "picocolors";
3
3
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
4
+ import { createRenderer } from "../render/index.js";
4
5
  import { logger } from "../utils/logger.js";
5
- import { createStreamPrinter } from "./stream-print.js";
6
6
  const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
7
7
  const HELP = `Commands:
8
8
  /help show this help
@@ -65,13 +65,25 @@ function printReplError(err) {
65
65
  }
66
66
  /**
67
67
  * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
68
- * commands or run a turn, repeat. Assistant text streams to stdout from within
69
- * `session.send` (via the logger); this loop only owns input and control.
68
+ * commands or run a turn, repeat. Assistant text and tool-call progress stream
69
+ * through the `renderer` from within `session.send`; this loop only owns input
70
+ * and control.
70
71
  *
71
- * `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
72
+ * `io` defaults to real stdin/stdout; tests inject a scripted stream pair. The
73
+ * renderer defaults to whatever `io.output` supports (a TTY gets the managed
74
+ * live region, anything else the plain append-only renderer); `cruxy run`
75
+ * passes its own so the approval prompt's status-suspend hook shares it.
72
76
  */
73
- export async function runInteractive(session, io = defaultIO()) {
77
+ export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr)) {
74
78
  logger.print(pc.dim("interactive session — /help for commands, /exit or Ctrl+D to quit"));
79
+ try {
80
+ await replLoop(session, io, renderer);
81
+ }
82
+ finally {
83
+ renderer.close();
84
+ }
85
+ }
86
+ async function replLoop(session, io, renderer) {
75
87
  for (;;) {
76
88
  const line = await readLine(io, PROMPT);
77
89
  // EOF / Ctrl+D.
@@ -120,14 +132,13 @@ export async function runInteractive(session, io = defaultIO()) {
120
132
  logger.print(HELP);
121
133
  continue;
122
134
  }
123
- // A real turn. Assistant text streams to the output delta by delta through a
124
- // single printer (which trims the model's leading blank lines); the agent
125
- // loop closes the segment with one newline, so the next prompt lands on its
126
- // own line. Errors (provider/API failures) log and return to the prompt
127
- // rather than killing the REPL.
135
+ // A real turn. Assistant text streams through the renderer delta by delta
136
+ // (leading blank lines trimmed, code fences highlighted); the agent loop
137
+ // closes each segment with one newline, so the next prompt lands on its own
138
+ // line. Errors (provider/API failures) log and return to the prompt rather
139
+ // than killing the REPL.
128
140
  try {
129
- const print = createStreamPrinter((text) => io.output.write(text));
130
- await session.send(line, print);
141
+ await session.send(line, renderer);
131
142
  }
132
143
  catch (err) {
133
144
  logger.error(err.message);
@@ -1,4 +1,5 @@
1
1
  import type { CruxyConfig } from "../config/index.js";
2
+ import type { StreamRenderer } from "../render/index.js";
2
3
  import { Session } from "../agent/index.js";
3
4
  /**
4
5
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
@@ -9,4 +10,4 @@ import { Session } from "../agent/index.js";
9
10
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
10
11
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
11
12
  */
12
- export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean): Session;
13
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer): Session;
@@ -7,6 +7,23 @@ import { shouldUseColor } from "../errors/index.js";
7
7
  import { buildDefaultRegistry } from "../tools/index.js";
8
8
  import { Session } from "../agent/index.js";
9
9
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
+ /**
11
+ * Wrap a PromptIO so the renderer's transient status line is erased before any
12
+ * prompt text lands (U.2): the approval prompt writes to stderr while the
13
+ * spinner owns the last stdout row of the same terminal — clearing first keeps
14
+ * the prompt from tearing through the live region.
15
+ */
16
+ function suspendStatusOnPrompt(io, renderer) {
17
+ if (!renderer)
18
+ return io;
19
+ return {
20
+ ...io,
21
+ write: (text) => {
22
+ renderer.status(null);
23
+ io.write(text);
24
+ },
25
+ };
26
+ }
10
27
  /**
11
28
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
12
29
  * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
@@ -16,7 +33,7 @@ import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
16
33
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
17
34
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
18
35
  */
19
- export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false) {
36
+ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer) {
20
37
  const provider = createProvider({
21
38
  provider: config.model.provider,
22
39
  apiKey,
@@ -31,7 +48,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
31
48
  if (planMode) {
32
49
  // One io + allowlist shared by the plan-approval prompt and the per-action
33
50
  // gate, so a grant recorded during execution is honored by U.3's own check.
34
- const io = defaultPromptIO(shouldUseColor());
51
+ const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
35
52
  const allowlist = new SessionAllowlist();
36
53
  const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io));
37
54
  const approval = new ApprovalService({
@@ -46,7 +63,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
46
63
  logger,
47
64
  requestApproval: (action) => approval.requestApproval(action),
48
65
  };
49
- const planRunner = ({ messages, projectInstructions, onText, }) => runPlanSession({
66
+ const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
50
67
  provider,
51
68
  config,
52
69
  ctx,
@@ -57,7 +74,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
57
74
  messages,
58
75
  git,
59
76
  projectInstructions,
60
- onText,
77
+ renderer: turnRenderer,
61
78
  });
62
79
  return new Session({
63
80
  provider,
@@ -70,7 +87,11 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
70
87
  planRunner,
71
88
  });
72
89
  }
73
- const approval = new ApprovalService({ cwd, interactive: ttyInteractive });
90
+ const approval = new ApprovalService({
91
+ cwd,
92
+ interactive: ttyInteractive,
93
+ io: suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer),
94
+ });
74
95
  const ctx = {
75
96
  cwd,
76
97
  config,
@@ -3,6 +3,7 @@ import type { CruxyConfig } from "../config/index.js";
3
3
  import type { PromptIO } from "../approval/index.js";
4
4
  import { ToolRegistry, type ToolContext } from "../tools/index.js";
5
5
  import { type AgentResult } from "../agent/loop.js";
6
+ import type { StreamRenderer } from "../render/index.js";
6
7
  import { PlanExecutionPolicy } from "./policy.js";
7
8
  /**
8
9
  * Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
@@ -32,7 +33,7 @@ export interface PlanSessionArgs {
32
33
  dirty: boolean;
33
34
  } | null;
34
35
  projectInstructions?: string | null;
35
- onText?: (delta: string) => void;
36
+ renderer?: StreamRenderer;
36
37
  /** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
37
38
  maxRevisions?: number;
38
39
  }
@@ -73,7 +73,7 @@ export async function runPlanSession(args) {
73
73
  ctx: args.ctx,
74
74
  git: args.git,
75
75
  projectInstructions: args.projectInstructions,
76
- onText: args.onText,
76
+ renderer: args.renderer,
77
77
  planMode: true,
78
78
  }));
79
79
  if (!holder.plan) {
@@ -105,7 +105,7 @@ export async function runPlanSession(args) {
105
105
  ctx: args.ctx,
106
106
  git: args.git,
107
107
  projectInstructions: args.projectInstructions,
108
- onText: args.onText,
108
+ renderer: args.renderer,
109
109
  }));
110
110
  };
111
111
  await executePlan(plan, { runStep, io: args.io });
@@ -0,0 +1,12 @@
1
+ import type { RenderCapabilities, RenderStream } from "./types.js";
2
+ /**
3
+ * Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
4
+ * given its inputs (stream + env are injectable), so every row of the
5
+ * degradation matrix is directly testable.
6
+ *
7
+ * Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
8
+ * refinement: `TERM=dumb` terminals get no color even though they are TTYs.
9
+ * Cursor control and color are independent axes — a NO_COLOR terminal still
10
+ * supports in-place status updates; a dumb terminal supports neither.
11
+ */
12
+ export declare function detectCapabilities(stream?: RenderStream, env?: NodeJS.ProcessEnv): RenderCapabilities;
@@ -0,0 +1,27 @@
1
+ import { shouldUseColor } from "../errors/index.js";
2
+ /**
3
+ * Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
4
+ * given its inputs (stream + env are injectable), so every row of the
5
+ * degradation matrix is directly testable.
6
+ *
7
+ * Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
8
+ * refinement: `TERM=dumb` terminals get no color even though they are TTYs.
9
+ * Cursor control and color are independent axes — a NO_COLOR terminal still
10
+ * supports in-place status updates; a dumb terminal supports neither.
11
+ */
12
+ export function detectCapabilities(stream = process.stdout, env = process.env) {
13
+ const tty = Boolean(stream.isTTY);
14
+ const dumb = env.TERM === "dumb";
15
+ const cursor = tty && !dumb;
16
+ return {
17
+ tty,
18
+ color: shouldUseColor(stream, env) && !dumb,
19
+ cursor,
20
+ // Same set-and-non-empty convention as NO_COLOR: any value disables.
21
+ spinner: cursor &&
22
+ !(env.CRUXY_NO_SPINNER !== undefined && env.CRUXY_NO_SPINNER !== ""),
23
+ width: typeof stream.columns === "number" && stream.columns > 0
24
+ ? stream.columns
25
+ : 80,
26
+ };
27
+ }