@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.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +41 -2
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +185 -72
- 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 +374 -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 +755 -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 +27 -3
- package/dist/components/select.js +3 -3
- 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 +198 -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 +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/usage/collect.js +21 -3
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/store.js +7 -1
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +73 -4
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +2 -2
- package/dist/usage/cost.js +0 -29
package/dist/config/project.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { PROJECT_INSTRUCTION_FILENAMES } from "../constants.js";
|
|
4
4
|
/** Cap on instruction file size; the rest is dropped with a notice. */
|
|
@@ -34,3 +34,55 @@ export function loadProjectInstructions(cwd) {
|
|
|
34
34
|
}
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The starting `CRUXY.md` (P6 track 4).
|
|
39
|
+
*
|
|
40
|
+
* Deliberately a SKELETON with prompts rather than filled-in guesses. cruxy
|
|
41
|
+
* cannot know this project's conventions at scaffold time, and a template that
|
|
42
|
+
* asserted some would be injected into every subsequent turn's system prompt as
|
|
43
|
+
* though the user had written it — instructions the agent follows and nobody
|
|
44
|
+
* chose. Empty headings ask; invented content misleads.
|
|
45
|
+
*/
|
|
46
|
+
export const PROJECT_INSTRUCTIONS_TEMPLATE = `# Project instructions for cruxy
|
|
47
|
+
|
|
48
|
+
These notes are loaded into cruxy's context on every run. Keep them short and
|
|
49
|
+
high-signal — conventions, where things live, how to build and test.
|
|
50
|
+
|
|
51
|
+
## Conventions
|
|
52
|
+
|
|
53
|
+
- (e.g. language, style, naming rules the agent should follow)
|
|
54
|
+
|
|
55
|
+
## Build & test
|
|
56
|
+
|
|
57
|
+
- (e.g. how to install deps, run the app, run the test suite)
|
|
58
|
+
|
|
59
|
+
## Gotchas
|
|
60
|
+
|
|
61
|
+
- (e.g. anything non-obvious about this codebase)
|
|
62
|
+
`;
|
|
63
|
+
/**
|
|
64
|
+
* Write the starter `CRUXY.md` into `cwd`, unless project instructions already
|
|
65
|
+
* exist there.
|
|
66
|
+
*
|
|
67
|
+
* NEVER OVERWRITES, and the check is {@link loadProjectInstructions} rather than
|
|
68
|
+
* a bare `existsSync("CRUXY.md")` — a project whose instructions live in
|
|
69
|
+
* `AGENTS.md` already has them, and scaffolding a second file beside it would
|
|
70
|
+
* create two sources for one thing where the loader honours only the first.
|
|
71
|
+
*
|
|
72
|
+
* Extracted from the onboarding scaffold step (P6 track 4) so `/init` and
|
|
73
|
+
* `cruxy init` write the same file. The step's own y/N prompt and IO stay in
|
|
74
|
+
* `onboarding/steps.ts`, which is the part that genuinely differs between an
|
|
75
|
+
* onboarding flow and a slash command.
|
|
76
|
+
*/
|
|
77
|
+
export function scaffoldProjectInstructions(cwd) {
|
|
78
|
+
if (loadProjectInstructions(cwd) !== null)
|
|
79
|
+
return { kind: "exists" };
|
|
80
|
+
const file = join(cwd, PROJECT_INSTRUCTION_FILENAMES[0]);
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(file, PROJECT_INSTRUCTIONS_TEMPLATE, "utf8");
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
return { kind: "failed", message: err.message };
|
|
86
|
+
}
|
|
87
|
+
return { kind: "written", file };
|
|
88
|
+
}
|
package/dist/config/schema.js
CHANGED
|
@@ -49,8 +49,14 @@ export const AgentConfigSchema = z
|
|
|
49
49
|
* cumulative across turns.
|
|
50
50
|
*/
|
|
51
51
|
maxTokensPerTurn: z.number().int().nonnegative().default(0),
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// `autoApprove` used to sit here (P5 track 3 removed it). It had ZERO
|
|
53
|
+
// consumers — nothing in the codebase ever read it — while promising to
|
|
54
|
+
// "skip per-action confirmation prompts", and `ApprovalConfigSchema` below
|
|
55
|
+
// stated in the same file that no such mode exists. A flag on disk also
|
|
56
|
+
// disarms every approval in every session that loads it, with nothing on
|
|
57
|
+
// screen to say so. Auto-approve is now a runtime SESSION MODE
|
|
58
|
+
// (`agent/mode.ts`): chosen in the session it affects, shown while active,
|
|
59
|
+
// gone when the session ends.
|
|
54
60
|
/** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
|
|
55
61
|
planMode: z.boolean().default(false),
|
|
56
62
|
})
|
|
@@ -120,9 +126,13 @@ export const ContextConfigSchema = z
|
|
|
120
126
|
.strict();
|
|
121
127
|
/**
|
|
122
128
|
* How tool-action approval is resolved. Only `prompt` exists: ask interactively
|
|
123
|
-
* and **deny by default** when non-interactive.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
129
|
+
* and **deny by default** when non-interactive.
|
|
130
|
+
*
|
|
131
|
+
* There is still deliberately no auto-approve mode HERE, and that is the point
|
|
132
|
+
* this schema has always made: not that unattended execution is forbidden, but
|
|
133
|
+
* that it must not be armed from a file. It is a runtime session mode
|
|
134
|
+
* (`agent/mode.ts`) reached through the policy seam in `src/approval` — chosen
|
|
135
|
+
* in the session it affects and visible the whole time it is on.
|
|
126
136
|
*/
|
|
127
137
|
export const ApprovalConfigSchema = z
|
|
128
138
|
.object({
|
|
@@ -436,7 +446,13 @@ export const LspConfigSchema = z
|
|
|
436
446
|
maxResults: z.number().int().positive().default(100),
|
|
437
447
|
})
|
|
438
448
|
.strict();
|
|
439
|
-
/**
|
|
449
|
+
/**
|
|
450
|
+
* RETIRED (C.22): a per-tier price, in the user's own currency, PER MILLION
|
|
451
|
+
* TOKENS. Nothing reads it. Kept only so `usage.prices` still VALIDATES — see
|
|
452
|
+
* {@link UsageConfigSchema}.
|
|
453
|
+
*
|
|
454
|
+
* @deprecated cost comes from the gateway now; delete `usage.prices` from config.
|
|
455
|
+
*/
|
|
440
456
|
export const TierPriceSchema = z
|
|
441
457
|
.object({
|
|
442
458
|
/** Price per 1,000,000 input tokens. */
|
|
@@ -447,22 +463,39 @@ export const TierPriceSchema = z
|
|
|
447
463
|
.strict();
|
|
448
464
|
/**
|
|
449
465
|
* Usage telemetry + cost tracking (C.22): LOCAL usage accounting only — nothing
|
|
450
|
-
* here is ever transmitted.
|
|
451
|
-
*
|
|
452
|
-
* and
|
|
453
|
-
*
|
|
454
|
-
*
|
|
466
|
+
* here is ever transmitted.
|
|
467
|
+
*
|
|
468
|
+
* `currency` and `prices` are RETIRED and read by nothing. They modelled a
|
|
469
|
+
* per-million-token cost basis that no cruxy subscriber is billed on: the
|
|
470
|
+
* gateway meters a weighted token pool and quotes its own cost per request, and
|
|
471
|
+
* `cruxy usage` now shows both of those instead of arithmetic over a number the
|
|
472
|
+
* user typed in.
|
|
473
|
+
*
|
|
474
|
+
* They are still ACCEPTED, and deliberately so. This schema is `.strict()` and
|
|
475
|
+
* `loadConfig` turns any parse failure into a hard error, so deleting the keys
|
|
476
|
+
* would mean every user who had set them gets a CLI that refuses to start until
|
|
477
|
+
* they hand-edit a file — a broken binary as the punishment for having used a
|
|
478
|
+
* documented feature. Tolerating a dead key costs nothing; rejecting it costs
|
|
479
|
+
* the whole tool. `cruxy usage` tells anyone who still has them set that they no
|
|
480
|
+
* longer do anything, which is the part that actually needed saying.
|
|
481
|
+
*
|
|
482
|
+
* Both are now `.optional()` with NO default, so a freshly-initialized config
|
|
483
|
+
* doesn't write retired keys into a new file — absent is the new normal, present
|
|
484
|
+
* is the tolerated legacy.
|
|
455
485
|
*/
|
|
456
486
|
export const UsageConfigSchema = z
|
|
457
487
|
.object({
|
|
458
488
|
/** Master switch. When false, no usage is collected, persisted, or shown. */
|
|
459
489
|
enabled: z.boolean().default(true),
|
|
460
|
-
/**
|
|
461
|
-
*
|
|
462
|
-
|
|
490
|
+
/**
|
|
491
|
+
* @deprecated retired, unread. Cost is shown in the gateway's own currency.
|
|
492
|
+
*/
|
|
493
|
+
currency: z.string().optional(),
|
|
463
494
|
/** How many past runs to keep in the store; older ones are pruned oldest-first. */
|
|
464
495
|
retention: z.number().int().positive().default(50),
|
|
465
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* @deprecated retired, unread. Cost comes from the gateway per request.
|
|
498
|
+
*/
|
|
466
499
|
prices: z
|
|
467
500
|
.object({
|
|
468
501
|
kavi: TierPriceSchema.optional(),
|
|
@@ -470,7 +503,7 @@ export const UsageConfigSchema = z
|
|
|
470
503
|
mira: TierPriceSchema.optional(),
|
|
471
504
|
})
|
|
472
505
|
.strict()
|
|
473
|
-
.
|
|
506
|
+
.optional(),
|
|
474
507
|
})
|
|
475
508
|
.strict();
|
|
476
509
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { themeForColor } from "../theme/index.js";
|
|
2
|
+
import { changedSteps } from "../render/plan-view.js";
|
|
2
3
|
/** Non-terminal capabilities: a background job renders to NOTHING on screen. */
|
|
3
4
|
const OFFSCREEN_CAPS = {
|
|
4
5
|
tty: false,
|
|
@@ -8,7 +9,12 @@ const OFFSCREEN_CAPS = {
|
|
|
8
9
|
reducedMotion: true,
|
|
9
10
|
screenReader: false,
|
|
10
11
|
unicode: true,
|
|
12
|
+
// No stdin and no screen: a job is never interactive and never repaints, so
|
|
13
|
+
// both input-axis flags are false regardless of what the foreground has.
|
|
14
|
+
stdinTty: false,
|
|
15
|
+
interactive: false,
|
|
11
16
|
width: 80,
|
|
17
|
+
height: 24,
|
|
12
18
|
};
|
|
13
19
|
/**
|
|
14
20
|
* A {@link StreamRenderer} for a background job (C.28) that captures activity into
|
|
@@ -26,6 +32,8 @@ export class JobLogRenderer {
|
|
|
26
32
|
caps = OFFSCREEN_CAPS;
|
|
27
33
|
theme = themeForColor(false);
|
|
28
34
|
pending = "";
|
|
35
|
+
/** Last plan snapshot logged, so `setPlan` records only what changed. */
|
|
36
|
+
planSteps = [];
|
|
29
37
|
constructor(sink) {
|
|
30
38
|
this.sink = sink;
|
|
31
39
|
}
|
|
@@ -51,6 +59,14 @@ export class JobLogRenderer {
|
|
|
51
59
|
note(text) {
|
|
52
60
|
this.sink("out", text);
|
|
53
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Ignored: a job log is an append-only transcript, and a served tier is
|
|
64
|
+
* standing state rather than an event. The run's tier is recorded by the
|
|
65
|
+
* usage store, which is where a job's accounting is read from.
|
|
66
|
+
*/
|
|
67
|
+
servedRouting() {
|
|
68
|
+
// no-op
|
|
69
|
+
}
|
|
54
70
|
toolLifecycle(event) {
|
|
55
71
|
if (event.event !== "end")
|
|
56
72
|
return; // only the committed outcome is log-worthy
|
|
@@ -60,6 +76,37 @@ export class JobLogRenderer {
|
|
|
60
76
|
endTurn() {
|
|
61
77
|
this.endSegment();
|
|
62
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Plan steps are committed outcomes, so a background job's log records them —
|
|
81
|
+
* one line per step whose status actually changed (the whole list arrives on
|
|
82
|
+
* every transition). Status is a word, not a glyph, matching this log's
|
|
83
|
+
* `[ok]`/`[fail]` style: nothing here is a terminal, so a themed mark would
|
|
84
|
+
* only add bytes `cruxy logs` has to strip.
|
|
85
|
+
*/
|
|
86
|
+
setPlan(steps) {
|
|
87
|
+
if (steps === null) {
|
|
88
|
+
this.planSteps = [];
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const step of changedSteps(this.planSteps, steps)) {
|
|
92
|
+
this.sink(step.status === "failed" ? "err" : "out", `[${step.status}] ${step.id}. ${step.title}`);
|
|
93
|
+
}
|
|
94
|
+
this.planSteps = steps.map((s) => ({ ...s }));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A job's test run is exactly the kind of outcome `cruxy logs` exists to
|
|
98
|
+
* show. Plain text, no theme glyphs, matching this log's `[ok]`/`[fail]`
|
|
99
|
+
* style — and no count this renderer was not given.
|
|
100
|
+
*/
|
|
101
|
+
testResult(report) {
|
|
102
|
+
const counted = report.total !== undefined
|
|
103
|
+
? ` ${report.failures.length}/${report.total} failed`
|
|
104
|
+
: "";
|
|
105
|
+
this.sink(report.passed ? "out" : "err", `[${report.passed ? "tests ok" : "tests failed"}]${report.passed ? "" : counted} ${report.command} (${Math.round(report.durationMs)}ms)`);
|
|
106
|
+
for (const f of report.failures) {
|
|
107
|
+
this.sink("err", ` ${f.name}${f.file === undefined ? "" : ` ${f.file}${f.line === undefined ? "" : `:${f.line}`}`}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
63
110
|
// Transient decor / previews have no place in an append-only job log.
|
|
64
111
|
preview() { }
|
|
65
112
|
status() { }
|
package/dist/onboarding/steps.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
1
|
import { themeForColor } from "../theme/index.js";
|
|
4
2
|
import { CREATE_KEY_URL } from "../constants.js";
|
|
5
|
-
import { loadProjectInstructions } from "../config/index.js";
|
|
3
|
+
import { loadProjectInstructions, scaffoldProjectInstructions, } from "../config/index.js";
|
|
6
4
|
/**
|
|
7
5
|
* The individual onboarding steps (U.6). Each returns a {@link StepResult} and
|
|
8
6
|
* never throws across its boundary; the secret is read masked and is never echoed
|
|
@@ -45,26 +43,14 @@ export async function acquireKeyStep(io, deps, provider) {
|
|
|
45
43
|
}
|
|
46
44
|
return { status: "failed", message: "key rejected after 3 attempts" };
|
|
47
45
|
}
|
|
48
|
-
const CRUXY_MD_TEMPLATE = `# Project instructions for cruxy
|
|
49
|
-
|
|
50
|
-
These notes are loaded into cruxy's context on every run. Keep them short and
|
|
51
|
-
high-signal — conventions, where things live, how to build and test.
|
|
52
|
-
|
|
53
|
-
## Conventions
|
|
54
|
-
|
|
55
|
-
- (e.g. language, style, naming rules the agent should follow)
|
|
56
|
-
|
|
57
|
-
## Build & test
|
|
58
|
-
|
|
59
|
-
- (e.g. how to install deps, run the app, run the test suite)
|
|
60
|
-
|
|
61
|
-
## Gotchas
|
|
62
|
-
|
|
63
|
-
- (e.g. anything non-obvious about this codebase)
|
|
64
|
-
`;
|
|
65
46
|
/**
|
|
66
47
|
* Offer to scaffold a project `CRUXY.md`. Skipped silently when one already
|
|
67
48
|
* exists (or `AGENTS.md`); otherwise a `y` confirmation writes the template.
|
|
49
|
+
*
|
|
50
|
+
* The template and the write moved to `config/project.ts` (P6 track 4) so
|
|
51
|
+
* `/init` writes the same file. What stays here is the part that genuinely
|
|
52
|
+
* differs between an onboarding flow and a slash command: the y/N prompt and
|
|
53
|
+
* the IO it is drawn on.
|
|
68
54
|
*/
|
|
69
55
|
export async function scaffoldStep(io, cwd) {
|
|
70
56
|
const col = c(io);
|
|
@@ -76,8 +62,13 @@ export async function scaffoldStep(io, cwd) {
|
|
|
76
62
|
io.write("\n");
|
|
77
63
|
if (key !== "y")
|
|
78
64
|
return { status: "skipped" };
|
|
79
|
-
const
|
|
80
|
-
|
|
65
|
+
const outcome = scaffoldProjectInstructions(cwd);
|
|
66
|
+
if (outcome.kind === "failed") {
|
|
67
|
+
// Previously this threw out of the step on an unwritable directory. A step
|
|
68
|
+
// must not throw across its boundary, so a failed write is reported.
|
|
69
|
+
io.write(`${col.danger(col.glyph.failure)} could not write CRUXY.md — ${outcome.message}\n`);
|
|
70
|
+
return { status: "failed", message: outcome.message };
|
|
71
|
+
}
|
|
81
72
|
io.write(`${col.success(col.glyph.success)} wrote ${col.strong("CRUXY.md")}\n`);
|
|
82
73
|
return { status: "ok" };
|
|
83
74
|
}
|
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
|
+
}
|