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