@cruxy/cli 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/agent/status.js +56 -0
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +401 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +717 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +204 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +163 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
package/dist/plan/approve.js
CHANGED
|
@@ -10,27 +10,33 @@ import { renderPlan } from "./render.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export async function promptPlanDecision(plan, io) {
|
|
12
12
|
const t = themeForColor(io.color);
|
|
13
|
-
io.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
13
|
+
io.beginPrompt?.();
|
|
14
|
+
try {
|
|
15
|
+
io.write(renderPlan(plan, io.color));
|
|
16
|
+
io.write("\n\n");
|
|
17
|
+
io.write(` ${t.muted("Approving consents to the shape of the work — every action still asks before it runs.")}\n`);
|
|
18
|
+
io.write(` ${t.muted(`[a] approve ${t.glyph.sep}`)} [g] approve ${t.strong("+ allow the read/mutate steps")} this run ${t.muted("(destructive still confirms)")} ${t.muted(`${t.glyph.sep} [e] reject & revise`)} `);
|
|
19
|
+
const key = (await io.readKey()).toLowerCase();
|
|
20
|
+
io.write("\n");
|
|
21
|
+
switch (key) {
|
|
22
|
+
case "a":
|
|
23
|
+
return { kind: "approve" };
|
|
24
|
+
case "g":
|
|
25
|
+
return { kind: "approve-grant" };
|
|
26
|
+
case "e":
|
|
27
|
+
case "n": {
|
|
28
|
+
io.write(" what should change about the plan? ");
|
|
29
|
+
const feedback = (await io.readLine()).trim();
|
|
30
|
+
// No feedback ⇒ treat as an abort, not an empty revision request.
|
|
31
|
+
return feedback ? { kind: "revise", feedback } : { kind: "abort" };
|
|
32
|
+
}
|
|
33
|
+
default:
|
|
34
|
+
// Unrecognized key, empty, EOF, Ctrl-C → default-deny.
|
|
35
|
+
return { kind: "abort" };
|
|
30
36
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
io.endPrompt?.();
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
/**
|
|
@@ -39,8 +45,14 @@ export async function promptPlanDecision(plan, io) {
|
|
|
39
45
|
*/
|
|
40
46
|
export async function promptContinueAfterFailure(io) {
|
|
41
47
|
const t = themeForColor(io.color);
|
|
42
|
-
io.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
io.beginPrompt?.();
|
|
49
|
+
try {
|
|
50
|
+
io.write(` ${t.danger("step failed.")} ${t.muted(`[c] continue with the remaining steps ${t.glyph.sep} [any other key] abort`)} `);
|
|
51
|
+
const key = (await io.readKey()).toLowerCase();
|
|
52
|
+
io.write("\n");
|
|
53
|
+
return key === "c";
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
io.endPrompt?.();
|
|
57
|
+
}
|
|
46
58
|
}
|
package/dist/plan/execute.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { CruxyError } from "../errors/index.js";
|
|
2
2
|
import { promptContinueAfterFailure } from "./approve.js";
|
|
3
|
-
import { renderStepStatus } from "./render.js";
|
|
4
3
|
export async function executePlan(plan, deps) {
|
|
5
4
|
const { runStep, io, renderer } = deps;
|
|
6
5
|
try {
|
|
@@ -13,20 +12,20 @@ export async function executePlan(plan, deps) {
|
|
|
13
12
|
title: step.title,
|
|
14
13
|
});
|
|
15
14
|
renderer?.setPhase({ kind: "executing-step" });
|
|
16
|
-
|
|
15
|
+
renderer?.setPlan(plan.steps);
|
|
17
16
|
try {
|
|
18
17
|
await runStep(step);
|
|
19
18
|
step.status = "done";
|
|
20
|
-
|
|
19
|
+
renderer?.setPlan(plan.steps);
|
|
21
20
|
}
|
|
22
21
|
catch (err) {
|
|
23
22
|
step.status = "failed";
|
|
24
|
-
|
|
23
|
+
renderer?.setPlan(plan.steps);
|
|
25
24
|
// Surface the failure via the U.5 shape when we have it.
|
|
26
25
|
const detail = err instanceof CruxyError
|
|
27
26
|
? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
|
28
27
|
: err.message;
|
|
29
|
-
|
|
28
|
+
renderer?.note(` ${detail}`);
|
|
30
29
|
const cont = await promptContinueAfterFailure(io);
|
|
31
30
|
if (!cont) {
|
|
32
31
|
return { completed: false, halted: true, failedStepId: step.id };
|
|
@@ -38,8 +37,11 @@ export async function executePlan(plan, deps) {
|
|
|
38
37
|
return { completed, halted: false };
|
|
39
38
|
}
|
|
40
39
|
finally {
|
|
41
|
-
// The plan owns
|
|
42
|
-
//
|
|
40
|
+
// The plan owns both registers; release them on every exit path so no stale
|
|
41
|
+
// "[i/n]" prefix and no stale checklist outlives the run. A renderer that
|
|
42
|
+
// keeps its checklist live rather than committing each transition (the TUI)
|
|
43
|
+
// commits one final copy as it clears, so the record survives the release.
|
|
43
44
|
renderer?.progress(null);
|
|
45
|
+
renderer?.setPlan(null);
|
|
44
46
|
}
|
|
45
47
|
}
|
package/dist/plan/render.js
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
import { themeForColor } from "../theme/index.js";
|
|
2
|
+
import { statusMark } from "../render/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* Plan rendering (C.31): data → string so it's testable and color is gated on a
|
|
4
|
-
* boolean (NO_COLOR / non-TTY aware, passed in by the caller).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* boolean (NO_COLOR / non-TTY aware, passed in by the caller). This is the
|
|
6
|
+
* APPROVAL view — the whole plan, with each step's risk tag and rationale,
|
|
7
|
+
* shown once before execution. Per-step status DURING execution is the
|
|
8
|
+
* renderer's `setPlan` surface (`render/plan-view.ts`), which shares the status
|
|
9
|
+
* glyph below so the two views mark a step identically.
|
|
10
|
+
*
|
|
11
|
+
* Glyphs and colors are sourced from the theme (U.1); status is carried by the
|
|
12
|
+
* glyph (○/◐/✓/✗) and risk by the `[read]/[mutate]/[destructive]` label, so
|
|
13
|
+
* meaning survives NO_COLOR.
|
|
9
14
|
*/
|
|
10
|
-
/** Status glyph, colored when enabled. */
|
|
11
|
-
function statusMark(status, t) {
|
|
12
|
-
switch (status) {
|
|
13
|
-
case "pending":
|
|
14
|
-
return t.muted(t.glyph.pending);
|
|
15
|
-
case "running":
|
|
16
|
-
return t.accent(t.glyph.running);
|
|
17
|
-
case "done":
|
|
18
|
-
return t.success(t.glyph.success);
|
|
19
|
-
case "failed":
|
|
20
|
-
return t.danger(t.glyph.failure);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
15
|
/** A short tag for the step's risk estimate. */
|
|
24
16
|
function kindTag(kind, t) {
|
|
25
17
|
switch (kind) {
|
|
@@ -48,8 +40,3 @@ export function renderPlan(plan, color) {
|
|
|
48
40
|
lines.push(renderStep(step, t));
|
|
49
41
|
return lines.join("\n");
|
|
50
42
|
}
|
|
51
|
-
/** A one-line status update for a single step (used during execution). */
|
|
52
|
-
export function renderStepStatus(step, color) {
|
|
53
|
-
const t = themeForColor(color);
|
|
54
|
-
return `${statusMark(step.status, t)} ${t.strong(step.id + ".")} ${step.title}`;
|
|
55
|
-
}
|
package/dist/plan/service.js
CHANGED
|
@@ -86,7 +86,10 @@ export async function runPlanSession(args) {
|
|
|
86
86
|
const plan = holder.plan;
|
|
87
87
|
const decision = await promptPlanDecision(plan, args.io);
|
|
88
88
|
if (decision.kind === "abort") {
|
|
89
|
-
|
|
89
|
+
// Committed output goes through the renderer, not the prompt io (P3):
|
|
90
|
+
// the io is for asking, and a plain write on it bypasses the live region
|
|
91
|
+
// that owns the screen.
|
|
92
|
+
args.renderer?.note("plan aborted — nothing was executed.");
|
|
90
93
|
return finish();
|
|
91
94
|
}
|
|
92
95
|
if (decision.kind !== "revise") {
|
|
@@ -6,6 +6,8 @@ function isSet(value) {
|
|
|
6
6
|
}
|
|
7
7
|
/** Fallback width when the terminal reports none (non-TTY, pipe, unknown). */
|
|
8
8
|
export const DEFAULT_COLUMNS = 80;
|
|
9
|
+
/** Fallback height when the terminal reports none (non-TTY, pipe, unknown). */
|
|
10
|
+
export const DEFAULT_ROWS = 24;
|
|
9
11
|
/**
|
|
10
12
|
* Resolve the terminal width (U.12) — the single rule behind
|
|
11
13
|
* {@link RenderCapabilities.width} and every resize recompute. `COLUMNS` wins
|
|
@@ -22,6 +24,20 @@ export function resolveColumns(stream = process.stdout, env = process.env) {
|
|
|
22
24
|
return stream.columns;
|
|
23
25
|
return DEFAULT_COLUMNS;
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the terminal height — the vertical twin of {@link resolveColumns},
|
|
29
|
+
* with the identical precedence rule: `LINES` wins when set (so `LINES=40 cruxy`
|
|
30
|
+
* and CI overrides work), then the stream's own `rows`, then
|
|
31
|
+
* {@link DEFAULT_ROWS}. Never returns a non-positive height.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveRows(stream = process.stdout, env = process.env) {
|
|
34
|
+
const fromEnv = env.LINES === undefined ? NaN : Number.parseInt(env.LINES, 10);
|
|
35
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
36
|
+
return fromEnv;
|
|
37
|
+
if (typeof stream.rows === "number" && stream.rows > 0)
|
|
38
|
+
return stream.rows;
|
|
39
|
+
return DEFAULT_ROWS;
|
|
40
|
+
}
|
|
25
41
|
/**
|
|
26
42
|
* Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
|
|
27
43
|
* knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
|
|
@@ -44,12 +60,19 @@ export function detectReducedMotion(env = process.env) {
|
|
|
44
60
|
* The axes are independent (U.1/U.11): a NO_COLOR terminal still supports
|
|
45
61
|
* in-place status updates; a dumb terminal supports neither; reduced motion and
|
|
46
62
|
* screen-reader mode compose orthogonally with color and unicode.
|
|
63
|
+
*
|
|
64
|
+
* `stdin` is probed separately from `stream` because they are genuinely
|
|
65
|
+
* different channels: `echo hi | cruxy` has a piped stdin and a TTY stdout, and
|
|
66
|
+
* `cruxy > log` the reverse. Resolving both here — and their conjunction as
|
|
67
|
+
* `interactive` — is what lets every call site stop reading
|
|
68
|
+
* `process.stdin.isTTY` for itself.
|
|
47
69
|
*/
|
|
48
|
-
export function detectCapabilities(stream = process.stdout, env = process.env) {
|
|
70
|
+
export function detectCapabilities(stream = process.stdout, env = process.env, stdin = process.stdin) {
|
|
49
71
|
const tty = Boolean(stream.isTTY);
|
|
50
72
|
const dumb = env.TERM === "dumb";
|
|
51
73
|
const cursor = tty && !dumb;
|
|
52
74
|
const reducedMotion = detectReducedMotion(env);
|
|
75
|
+
const stdinTty = Boolean(stdin.isTTY);
|
|
53
76
|
return {
|
|
54
77
|
tty,
|
|
55
78
|
color: shouldUseColor(stream, env) && !dumb,
|
|
@@ -62,6 +85,12 @@ export function detectCapabilities(stream = process.stdout, env = process.env) {
|
|
|
62
85
|
// Unicode glyph safety (U.1) — independent of color. dumb / CRUXY_ASCII →
|
|
63
86
|
// ASCII glyphs; everything else (incl. pipes) keeps unicode.
|
|
64
87
|
unicode: detectUnicode(env),
|
|
88
|
+
stdinTty,
|
|
89
|
+
// The one interactivity rule, resolved once: a key read needs a terminal to
|
|
90
|
+
// read FROM and a terminal that can be repainted. Either half missing and
|
|
91
|
+
// no component may draw a frame or block on a key.
|
|
92
|
+
interactive: stdinTty && cursor,
|
|
65
93
|
width: resolveColumns(stream, env),
|
|
94
|
+
height: resolveRows(stream, env),
|
|
66
95
|
};
|
|
67
96
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { formatTokens } from "./state.js";
|
|
2
|
+
import { fit } from "./layout.js";
|
|
3
|
+
/**
|
|
4
|
+
* Rendering `/context` (P6 track 3) — the DETAIL VIEW behind the rail's two
|
|
5
|
+
* numbers, not a second copy of them.
|
|
6
|
+
*
|
|
7
|
+
* THE PANEL'S RULES STILL APPLY HERE, and nothing below may weaken them. The
|
|
8
|
+
* context panel's doc comment is explicit that two plain numbers are the honest
|
|
9
|
+
* presentation and that a progress bar would not be: a filled bar reads as a
|
|
10
|
+
* measurement, and this is a chars/4 heuristic measured against a local config
|
|
11
|
+
* default. So there is no bar here either, no percentage-of-window, and every
|
|
12
|
+
* figure carries a `~`. "budget", never "window".
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS ADDS is the two things that genuinely cannot live in a 24-column
|
|
15
|
+
* strip without implying a precision they do not have:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Where the tokens are.** A breakdown by what the content IS — prompts,
|
|
18
|
+
* assistant text, tool calls, tool results, and summaries a previous
|
|
19
|
+
* compaction already folded in — plus the individual messages large enough
|
|
20
|
+
* to be worth naming. "60% of your context is one grep result" is
|
|
21
|
+
* actionable; a single occupancy figure is not.
|
|
22
|
+
* 2. **What compaction would actually drop.** Computed by the SAME `findCut`
|
|
23
|
+
* the seam uses, so the explanation cannot be subtly wrong exactly where it
|
|
24
|
+
* matters. Including the case that surprises people: a history with no safe
|
|
25
|
+
* cut point, where `/compact` will do nothing at all.
|
|
26
|
+
*
|
|
27
|
+
* The reserve is shown as its own row rather than folded into a total. It is
|
|
28
|
+
* part of `used`, it is present in no message, and a breakdown that omitted it
|
|
29
|
+
* would leave several thousand tokens looking unexplained.
|
|
30
|
+
*/
|
|
31
|
+
/** `~34k`, `~900` — the leading tilde is not decoration. See the module note. */
|
|
32
|
+
function approx(n) {
|
|
33
|
+
return `~${formatTokens(n)}`;
|
|
34
|
+
}
|
|
35
|
+
/** `12%` — a share of the measured history, never of the model's real window. */
|
|
36
|
+
function share(tokens, of) {
|
|
37
|
+
if (of <= 0)
|
|
38
|
+
return "";
|
|
39
|
+
return `${Math.round((tokens / of) * 100)}%`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The full `/context` report as lines to print.
|
|
43
|
+
*
|
|
44
|
+
* `width` fits each row so a narrow terminal truncates rather than soft-wrapping
|
|
45
|
+
* a table into a misaligned mess; the default is unbounded for callers that
|
|
46
|
+
* reflow themselves (the TUI's main column).
|
|
47
|
+
*/
|
|
48
|
+
export function contextReportLines(report, t, width = Infinity) {
|
|
49
|
+
const { reading, compaction } = report;
|
|
50
|
+
const lines = [t.heading("context")];
|
|
51
|
+
// The headline, worded exactly as the panel words it — same estimate, same
|
|
52
|
+
// caveats, so the detail view can never read as the more authoritative one.
|
|
53
|
+
lines.push(`${t.strong(`${approx(reading.used)} / ${formatTokens(reading.total)} budget`)} ` +
|
|
54
|
+
t.muted(`(estimated · budget is a local setting, not the model's window)`));
|
|
55
|
+
lines.push(t.muted(`${report.messages} message${report.messages === 1 ? "" : "s"} · compacts above ${approx(reading.compactAt)}`));
|
|
56
|
+
// ── where the tokens are ──────────────────────────────────────────────────
|
|
57
|
+
const historyTokens = report.parts.reduce((sum, p) => sum + p.tokens, 0);
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push(t.strong("where it is"));
|
|
60
|
+
if (report.parts.length === 0) {
|
|
61
|
+
lines.push(t.muted(" no conversation yet"));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
for (const part of report.parts) {
|
|
65
|
+
lines.push(` ${part.part.padEnd(13)} ${approx(part.tokens).padStart(7)} ` +
|
|
66
|
+
t.muted(`${share(part.tokens, historyTokens).padStart(4)} ${part.messages} msg`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Named separately because it is real, unavoidable, and in no message — the
|
|
70
|
+
// one part of the figure a user cannot shrink by pruning the conversation.
|
|
71
|
+
lines.push(` ${"reserve".padEnd(13)} ${approx(report.reserveTokens).padStart(7)} ` +
|
|
72
|
+
t.muted(" system prompt + tool schemas"));
|
|
73
|
+
// ── the biggest single messages ───────────────────────────────────────────
|
|
74
|
+
if (report.largest.length > 0) {
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push(t.strong("largest messages"));
|
|
77
|
+
for (const c of report.largest) {
|
|
78
|
+
const head = ` ${String(c.position).padStart(3)}. ${approx(c.tokens).padStart(7)} ${c.label}`;
|
|
79
|
+
lines.push(`${head}${c.excerpt === "" ? "" : t.muted(` — ${c.excerpt}`)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// ── what compaction would do ──────────────────────────────────────────────
|
|
83
|
+
lines.push("");
|
|
84
|
+
lines.push(t.strong("if you compact now"));
|
|
85
|
+
if (compaction.cut === null) {
|
|
86
|
+
// The case that surprises people, and the reason this section exists at all.
|
|
87
|
+
lines.push(t.muted(" nothing — there is no safe cut point yet. A summary can only replace"));
|
|
88
|
+
lines.push(t.muted(" whole completed turns, so an in-progress turn (or a history with no"));
|
|
89
|
+
lines.push(t.muted(" finished turn behind it) has nothing it can fold away."));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
lines.push(t.muted(` ${compaction.droppedMessages} message${compaction.droppedMessages === 1 ? "" : "s"} ` +
|
|
93
|
+
`(${approx(compaction.droppedTokens)}) folded into a summary; ` +
|
|
94
|
+
`${compaction.keptMessages} kept verbatim (${approx(compaction.keptTokens)})`));
|
|
95
|
+
// A ceiling, not a saving: the summary that replaces the prefix costs
|
|
96
|
+
// tokens of its own, and how many is not knowable until the model writes it.
|
|
97
|
+
lines.push(t.muted(` frees at most ${approx(compaction.droppedTokens)} — the summary that replaces them costs`));
|
|
98
|
+
lines.push(t.muted(" tokens of its own, which cannot be known in advance."));
|
|
99
|
+
}
|
|
100
|
+
lines.push(compaction.overThreshold
|
|
101
|
+
? t.warning(" the next turn will compact on its own")
|
|
102
|
+
: t.muted(" below the threshold — the next turn will not compact"));
|
|
103
|
+
return Number.isFinite(width)
|
|
104
|
+
? lines.map((l) => fit(l, width, t.glyph.ellipsis))
|
|
105
|
+
: lines;
|
|
106
|
+
}
|
package/dist/render/diff.js
CHANGED
|
@@ -7,8 +7,115 @@ import { fit, fitMiddle } from "./layout.js";
|
|
|
7
7
|
* `+`/`-` lines from the exact same code path. Diff add/remove intentionally
|
|
8
8
|
* reuse the `success`/`danger` hues (green/red), as before.
|
|
9
9
|
*/
|
|
10
|
-
/**
|
|
10
|
+
/** Default cap on rendered preview lines before collapsing the rest. A DEFAULT,
|
|
11
|
+
* not a constant of the format: a surface that owns a sized region (the TUI's
|
|
12
|
+
* main column) passes its own cap, so the collapse point matches the space the
|
|
13
|
+
* block will actually be drawn into. */
|
|
11
14
|
export const PREVIEW_MAX_LINES = 40;
|
|
15
|
+
/** Lines a replacement string contributes: "" → 0, "a\nb" and "a\nb\n" → 2. */
|
|
16
|
+
function lineCount(text) {
|
|
17
|
+
if (text === "")
|
|
18
|
+
return 0;
|
|
19
|
+
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
20
|
+
return body.split("\n").length;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether a replacement covers whole lines, which is what makes its count
|
|
24
|
+
* exact. An `edit_file` swap is an arbitrary substring — usually neither empty
|
|
25
|
+
* nor newline-terminated — so most edits are honestly approximate.
|
|
26
|
+
*/
|
|
27
|
+
function lineAligned(text) {
|
|
28
|
+
return text === "" || text.endsWith("\n");
|
|
29
|
+
}
|
|
30
|
+
/** One file's stat from a patch entry. */
|
|
31
|
+
function patchFileStat(file) {
|
|
32
|
+
if (file.op === "delete") {
|
|
33
|
+
// Only a path is recorded, so the removed count is unknowable here.
|
|
34
|
+
return {
|
|
35
|
+
path: file.path,
|
|
36
|
+
op: "delete",
|
|
37
|
+
added: 0,
|
|
38
|
+
removed: null,
|
|
39
|
+
approximate: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (file.op === "create") {
|
|
43
|
+
return {
|
|
44
|
+
path: file.path,
|
|
45
|
+
op: "create",
|
|
46
|
+
// The pre-capped listing plus what the cap dropped: the whole file.
|
|
47
|
+
added: file.lines.length + file.omittedLines,
|
|
48
|
+
removed: 0,
|
|
49
|
+
approximate: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
let added = 0;
|
|
53
|
+
let removed = 0;
|
|
54
|
+
let approximate = false;
|
|
55
|
+
for (const hunk of file.hunks) {
|
|
56
|
+
added += lineCount(hunk.newStr);
|
|
57
|
+
removed += lineCount(hunk.oldStr);
|
|
58
|
+
if (!lineAligned(hunk.oldStr) || !lineAligned(hunk.newStr))
|
|
59
|
+
approximate = true;
|
|
60
|
+
}
|
|
61
|
+
return { path: file.path, op: "update", added, removed, approximate };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Per-file line counts for any preview — the structured form, so a caller can
|
|
65
|
+
* lay out a summary rather than scrape it back out of rendered text.
|
|
66
|
+
*
|
|
67
|
+
* Empty for `pr`, `rollback` and `rollback-set`: the first changes no files
|
|
68
|
+
* here, and the latter two are restores whose own renderers already lead with
|
|
69
|
+
* their blast radius.
|
|
70
|
+
*/
|
|
71
|
+
export function previewStats(preview) {
|
|
72
|
+
if (!preview)
|
|
73
|
+
return [];
|
|
74
|
+
if (preview.type === "patch")
|
|
75
|
+
return preview.files.map(patchFileStat);
|
|
76
|
+
if (preview.type === "edit") {
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
path: "",
|
|
80
|
+
op: "update",
|
|
81
|
+
added: lineCount(preview.newStr),
|
|
82
|
+
removed: lineCount(preview.oldStr),
|
|
83
|
+
approximate: !lineAligned(preview.oldStr) || !lineAligned(preview.newStr),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
if (preview.type === "write") {
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
path: "",
|
|
91
|
+
op: preview.exists ? "update" : "create",
|
|
92
|
+
added: preview.lines.length + preview.omittedLines,
|
|
93
|
+
// Overwriting discards the previous contents, whose length the preview
|
|
94
|
+
// does not record.
|
|
95
|
+
removed: preview.exists ? null : 0,
|
|
96
|
+
approximate: false,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `+12/-3`, `~+1/-1`, `+8` — omitting a side the preview cannot count.
|
|
104
|
+
*
|
|
105
|
+
* Takes only the three fields it reads rather than a whole {@link DiffFileStat},
|
|
106
|
+
* so P7's git-status changes render through this same formatter instead of a
|
|
107
|
+
* second one that would eventually disagree with it about what `null` means.
|
|
108
|
+
*/
|
|
109
|
+
export function formatStat(stat, c) {
|
|
110
|
+
const parts = [];
|
|
111
|
+
if (stat.added !== null && stat.added > 0)
|
|
112
|
+
parts.push(c.success(`+${stat.added}`));
|
|
113
|
+
if (stat.removed !== null && stat.removed > 0)
|
|
114
|
+
parts.push(c.danger(`-${stat.removed}`));
|
|
115
|
+
if (parts.length === 0)
|
|
116
|
+
return "";
|
|
117
|
+
return `${stat.approximate ? "~" : ""}${parts.join("/")}`;
|
|
118
|
+
}
|
|
12
119
|
function diffLines(oldStr, newStr, c) {
|
|
13
120
|
const removed = oldStr.split("\n").map((l) => c.danger(`- ${l}`));
|
|
14
121
|
const added = newStr.split("\n").map((l) => c.success(`+ ${l}`));
|
|
@@ -22,20 +129,25 @@ function diffLines(oldStr, newStr, c) {
|
|
|
22
129
|
function fitPath(path, c, width) {
|
|
23
130
|
return fitMiddle(path, Math.max(1, width - 10), c.glyph.ellipsis);
|
|
24
131
|
}
|
|
132
|
+
/** `update src/x.ts +12/-3` — the verb, the path, then the counts (P3). */
|
|
133
|
+
function fileHeader(verb, file, c, width) {
|
|
134
|
+
const stat = formatStat(patchFileStat(file), c);
|
|
135
|
+
return `${verb} ${fitPath(file.path, c, width)}${stat === "" ? "" : ` ${stat}`}`;
|
|
136
|
+
}
|
|
25
137
|
function renderPatchFiles(files, c, width = Infinity) {
|
|
26
138
|
const out = [];
|
|
27
139
|
for (const file of files) {
|
|
28
140
|
if (file.op === "delete") {
|
|
29
|
-
out.push(c.danger(
|
|
141
|
+
out.push(c.danger(fileHeader("delete", file, c, width)));
|
|
30
142
|
}
|
|
31
143
|
else if (file.op === "create") {
|
|
32
|
-
out.push(c.success(
|
|
144
|
+
out.push(c.success(fileHeader("create", file, c, width)));
|
|
33
145
|
out.push(...file.lines.map((l) => c.success(`+ ${l}`)));
|
|
34
146
|
if (file.omittedLines > 0)
|
|
35
147
|
out.push(c.muted(` ...${file.omittedLines} more lines`));
|
|
36
148
|
}
|
|
37
149
|
else {
|
|
38
|
-
out.push(c.warning(
|
|
150
|
+
out.push(c.warning(fileHeader("update", file, c, width)));
|
|
39
151
|
for (const hunk of file.hunks)
|
|
40
152
|
out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
|
|
41
153
|
}
|
|
@@ -123,14 +235,25 @@ function bodyLines(body) {
|
|
|
123
235
|
/**
|
|
124
236
|
* Render any {@link ActionPreview} as an indented block: a diff for edits and
|
|
125
237
|
* patches, a create/overwrite listing for writes, the publish plan for PRs.
|
|
126
|
-
*
|
|
238
|
+
*
|
|
239
|
+
* `maxLines` defaults to {@link PREVIEW_MAX_LINES} so existing callers are
|
|
240
|
+
* unchanged, but it is a PARAMETER because the right collapse point belongs to
|
|
241
|
+
* whatever will draw the block — the TUI passes the rows its main column
|
|
242
|
+
* actually has, so a preview no longer collapses at 40 lines in a pane that can
|
|
243
|
+
* show 12 or one that could show 80.
|
|
127
244
|
*/
|
|
128
|
-
export function renderActionPreview(preview, c, width = Infinity) {
|
|
245
|
+
export function renderActionPreview(preview, c, width = Infinity, maxLines = PREVIEW_MAX_LINES) {
|
|
129
246
|
if (!preview)
|
|
130
247
|
return "";
|
|
131
248
|
let lines;
|
|
132
249
|
if (preview.type === "edit") {
|
|
133
|
-
|
|
250
|
+
// A headline stat, so a one-line swap is legible without counting the
|
|
251
|
+
// diff rows — and `~` marks it as lines touched, not lines gained/lost.
|
|
252
|
+
const stat = formatStat(previewStats(preview)[0], c);
|
|
253
|
+
lines = [
|
|
254
|
+
...(stat === "" ? [] : [c.muted(`edit ${stat}`)]),
|
|
255
|
+
...diffLines(preview.oldStr, preview.newStr, c),
|
|
256
|
+
];
|
|
134
257
|
}
|
|
135
258
|
else if (preview.type === "patch") {
|
|
136
259
|
lines = renderPatchFiles(preview.files, c, width);
|
|
@@ -145,19 +268,25 @@ export function renderActionPreview(preview, c, width = Infinity) {
|
|
|
145
268
|
lines = renderRollbackSetPreview(preview, c);
|
|
146
269
|
}
|
|
147
270
|
else {
|
|
148
|
-
const
|
|
271
|
+
const stat = formatStat(previewStats(preview)[0], c);
|
|
272
|
+
const label = preview.exists
|
|
149
273
|
? c.warning("OVERWRITE existing")
|
|
150
274
|
: c.success("create");
|
|
275
|
+
const header = stat === "" ? label : `${label} ${stat}`;
|
|
151
276
|
const body = preview.lines.map((l) => ` ${l}`);
|
|
152
277
|
if (preview.omittedLines > 0)
|
|
153
278
|
body.push(c.muted(` ...${preview.omittedLines} more lines`));
|
|
154
279
|
lines = [header, ...body];
|
|
155
280
|
}
|
|
156
|
-
|
|
157
|
-
|
|
281
|
+
// Collapse against the CALLER's budget, marker INCLUDED — a block handed a
|
|
282
|
+
// pane's height must fit that pane. With only one row to spend, the marker
|
|
283
|
+
// wins: "...60 more" is the honest answer, where a single arbitrary diff row
|
|
284
|
+
// would read as the whole change.
|
|
285
|
+
if (Number.isFinite(maxLines) && lines.length > maxLines) {
|
|
286
|
+
const kept = Math.max(0, maxLines - 1);
|
|
158
287
|
lines = [
|
|
159
|
-
...lines.slice(0,
|
|
160
|
-
c.muted(`...${
|
|
288
|
+
...lines.slice(0, kept),
|
|
289
|
+
c.muted(`...${lines.length - kept} more`),
|
|
161
290
|
];
|
|
162
291
|
}
|
|
163
292
|
// Horizontal fit at the single choke point (U.12): every content line is
|
|
@@ -169,3 +298,66 @@ export function renderActionPreview(preview, c, width = Infinity) {
|
|
|
169
298
|
const room = Math.max(1, width - 2);
|
|
170
299
|
return lines.map((l) => ` ${fit(l, room, c.glyph.ellipsis)}`).join("\n");
|
|
171
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Colour a UNIFIED diff — git's own output — for `/diff` (P6 track 4).
|
|
303
|
+
*
|
|
304
|
+
* A second diff renderer would be exactly what this module's header rules out,
|
|
305
|
+
* so it lives here beside the others and reuses their palette: add/remove take
|
|
306
|
+
* the same `success`/`danger` hues an `ActionPreview` does, so a change looks
|
|
307
|
+
* the same whether you saw it in an approval prompt or asked for it afterwards.
|
|
308
|
+
*
|
|
309
|
+
* What differs is the INPUT. Everything else here renders a structured
|
|
310
|
+
* `ActionPreview` cruxy built itself; this takes text git produced, which means
|
|
311
|
+
* the file/hunk structure arrives as line prefixes rather than as fields. So the
|
|
312
|
+
* rule is deliberately narrow — style by leading marker, change nothing else:
|
|
313
|
+
*
|
|
314
|
+
* - `+++` / `---` before `+` / `-`, or every file header reads as a hunk;
|
|
315
|
+
* - `` is git's own note, not a removal;
|
|
316
|
+
* - the body is passed through verbatim. No re-wrapping, no re-indenting, no
|
|
317
|
+
* tab expansion. A diff whose whitespace has been "tidied" is a diff you
|
|
318
|
+
* cannot trust to show you a whitespace bug.
|
|
319
|
+
*
|
|
320
|
+
* Truncation is from the TOP of the tail, not the bottom: the marker says how
|
|
321
|
+
* many lines were dropped, and the lines kept are the FIRST ones, because a diff
|
|
322
|
+
* read from the middle is worse than a diff read from the start and stopped.
|
|
323
|
+
*/
|
|
324
|
+
export function renderUnifiedDiff(diff, c, opts = {}) {
|
|
325
|
+
const maxLines = opts.maxLines ?? PREVIEW_MAX_LINES;
|
|
326
|
+
const width = opts.width ?? Infinity;
|
|
327
|
+
const body = diff.replace(/\n+$/, "");
|
|
328
|
+
// `"".split("\n")` is `[""]`, not `[]` — an empty diff would otherwise render
|
|
329
|
+
// as one blank row. The caller guards this today; the renderer should not
|
|
330
|
+
// depend on that.
|
|
331
|
+
if (body === "")
|
|
332
|
+
return [];
|
|
333
|
+
const raw = body.split("\n");
|
|
334
|
+
const truncated = raw.length > maxLines;
|
|
335
|
+
// Reserve the marker's own row, so the block fits the budget it was given.
|
|
336
|
+
const kept = truncated ? raw.slice(0, Math.max(0, maxLines - 1)) : raw;
|
|
337
|
+
const styled = kept.map((line) => {
|
|
338
|
+
if (line.startsWith("diff --git") || line.startsWith("index ")) {
|
|
339
|
+
return c.strong(line);
|
|
340
|
+
}
|
|
341
|
+
// Before the +/- tests: a `+++ b/file` header is a file marker, not an
|
|
342
|
+
// addition, and colouring it green makes every header read as one.
|
|
343
|
+
if (line.startsWith("+++") || line.startsWith("---"))
|
|
344
|
+
return c.muted(line);
|
|
345
|
+
if (line.startsWith("@@"))
|
|
346
|
+
return c.accent(line);
|
|
347
|
+
// git's own note about a missing trailing newline. It starts with a
|
|
348
|
+
// backslash, but it is prose about the diff rather than part of it.
|
|
349
|
+
if (line.startsWith("\\"))
|
|
350
|
+
return c.muted(line);
|
|
351
|
+
if (line.startsWith("+"))
|
|
352
|
+
return c.success(line);
|
|
353
|
+
if (line.startsWith("-"))
|
|
354
|
+
return c.danger(line);
|
|
355
|
+
return line;
|
|
356
|
+
});
|
|
357
|
+
if (truncated) {
|
|
358
|
+
styled.push(c.muted(`...${raw.length - kept.length} more diff lines`));
|
|
359
|
+
}
|
|
360
|
+
return Number.isFinite(width)
|
|
361
|
+
? styled.map((l) => fit(l, width, c.glyph.ellipsis))
|
|
362
|
+
: styled;
|
|
363
|
+
}
|