@cruxy/cli 1.11.0 → 1.11.2

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.
@@ -4,6 +4,7 @@ import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKe
4
4
  import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
5
5
  import { sessionFilesByRecency } from "./list.js";
6
6
  import { SESSION_FILE_EXT } from "./paths.js";
7
+ import { removeOwnerFile, sessionHeldBy } from "./owner.js";
7
8
  /** `<sessionId>.jsonl` → `<sessionId>`. */
8
9
  function idOf(file) {
9
10
  return path.basename(file, SESSION_FILE_EXT);
@@ -59,6 +60,15 @@ export function pruneSessions(cwd, opts) {
59
60
  result.kept++;
60
61
  continue;
61
62
  }
63
+ // A session ANOTHER live cruxy has open is spared for the same reason our
64
+ // own is (P1): its writer would recreate the file on its next append with
65
+ // no `meta` line, and that session would be unloadable from then on. The
66
+ // stamp is pid + start-time, so a crashed owner's file is prunable again
67
+ // the moment it is looked at — nothing outlives the crash.
68
+ if (sessionHeldBy(ref.file)) {
69
+ result.kept++;
70
+ continue;
71
+ }
62
72
  const tooOld = ref.mtimeMs < cutoff;
63
73
  const beyondCap = index >= retention;
64
74
  if (!tooOld && !beyondCap) {
@@ -67,6 +77,7 @@ export function pruneSessions(cwd, opts) {
67
77
  }
68
78
  try {
69
79
  unlinkSync(ref.file);
80
+ removeOwnerFile(ref.file); // a stale stamp has nothing left to own
70
81
  result.removed.push({ ...ref, sessionId });
71
82
  result.bytesFreed += ref.size;
72
83
  }
@@ -2,6 +2,7 @@ import { selectList } from "../components/index.js";
2
2
  import { usageError } from "../errors/index.js";
3
3
  import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
4
4
  import { replaySession } from "./replay.js";
5
+ import { describeHolder, ownerFile, sessionHeldBy } from "./owner.js";
5
6
  /** How many sessions the bare-`--resume` picker offers. */
6
7
  export const PICKER_LIMIT = 10;
7
8
  /** Short, stable id form — enough to identify a session, short enough to type. */
@@ -75,6 +76,18 @@ export function priorDirectoriesWarning(state, cwd) {
75
76
  * worth failing loudly on, unlike an individual torn line.
76
77
  */
77
78
  export function loadResume(session, cwd) {
79
+ // OWNERSHIP (P1). Asked BEFORE the replay, and answered by the OS rather
80
+ // than by a file's presence: a stamp whose process is gone is not a holder.
81
+ // Two processes appending to one log produce a transcript that replays
82
+ // cleanly and means nothing, so this is a refusal, not a warning.
83
+ const holder = sessionHeldBy(session.file);
84
+ if (holder) {
85
+ throw usageError(`session ${shortId(session.sessionId)} is open in another cruxy (${describeHolder(holder)})`, [
86
+ "continue it there, or quit that cruxy and resume here",
87
+ "start a new session with `cruxy`",
88
+ `if that process is not a cruxy, remove ${ownerFile(session.file)}`,
89
+ ]);
90
+ }
78
91
  let state;
79
92
  try {
80
93
  state = replaySession(session.file);
@@ -189,9 +202,15 @@ export async function resumePicker(cwd, opts = {}) {
189
202
  ];
190
203
  const picked = await selectList(rows, {
191
204
  title: "resume a session",
192
- toLabel: (row) => row.kind === "new"
193
- ? "+ new session"
194
- : describeSession(row.session, now),
205
+ toLabel: (row) => {
206
+ if (row.kind === "new")
207
+ return "+ new session";
208
+ const label = describeSession(row.session, now);
209
+ // A row another live cruxy holds is offered — the user may want to
210
+ // see it — but says so, so picking it is not a surprise refusal.
211
+ const holder = sessionHeldBy(row.session.file);
212
+ return holder ? `${label} · open in another cruxy` : label;
213
+ },
195
214
  // Non-interactive with no id named: starting fresh is the safe default,
196
215
  // never an arbitrary session picked on the user's behalf.
197
216
  defaultValue: { kind: "new" },
@@ -7,7 +7,7 @@ import { UNRESOLVED_TIER, } from "../budget/index.js";
7
7
  import { resolveTaskModel } from "../routing/index.js";
8
8
  import { Workspace } from "../workspace/index.js";
9
9
  import { Budget, resolveBudget } from "../agent/budget.js";
10
- import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
10
+ import { isWriteTool, scopeRegistry } from "./registry-scope.js";
11
11
  import { Semaphore } from "./semaphore.js";
12
12
  import { makeSpawnSubagentTool } from "./spawn-tool.js";
13
13
  /** Longest task excerpt shown in render chrome — display, not record. */
@@ -556,7 +556,7 @@ function taskLabel(task) {
556
556
  /** A child that holds any mutating tool — the disjoint-scope check's unit. A
557
557
  * spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
558
558
  function isWriter(spec) {
559
- return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
559
+ return (spec.tools ?? []).some(isWriteTool);
560
560
  }
561
561
  /**
562
562
  * The tokens a child is KNOWN to have spent, or `undefined` when no request
@@ -17,8 +17,20 @@ export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
17
17
  /**
18
18
  * Mutating tools (C.33): a child holding ANY of these is a "writer" for the
19
19
  * disjoint-scope check. Two writers in one parallel batch must target distinct
20
- * roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
21
- * side-effecting tool set (file writes, shell/test, VCS).
20
+ * roots, or the batch is refused pre-dispatch.
21
+ *
22
+ * This is the set of REGISTERED tool names that gate on `ctx.requestApproval`
23
+ * and act on a workspace root: file writes, shell/test, the PR tool, and the
24
+ * background-job dispatcher (a job is a whole agent run that may be granted
25
+ * any of the others). `registry-scope.test.ts` pins every name here to a tool
26
+ * that actually exists — until P1 this set named three tools that never did
27
+ * (`git_commit`, `git_branch`, `open_pr`) and omitted `create_pull_request`,
28
+ * so a child holding the PR tool was never counted as a writer and two of them
29
+ * could be dispatched against one root.
30
+ *
31
+ * Deliberately NOT here: `remember` (writes the memory store, not a root —
32
+ * root-disjointness says nothing about it) and the spawn tools (stripped from
33
+ * every child scope). MCP tools are covered by prefix in {@link isWriteTool}.
22
34
  */
23
35
  export const SUBAGENT_WRITE_TOOLS = new Set([
24
36
  "write_file",
@@ -26,10 +38,21 @@ export const SUBAGENT_WRITE_TOOLS = new Set([
26
38
  "apply_patch",
27
39
  "run_command",
28
40
  "run_tests",
29
- "git_commit",
30
- "git_branch",
31
- "open_pr",
41
+ "create_pull_request",
42
+ "run_in_background",
32
43
  ]);
44
+ /** The wire-name prefix every MCP-proxied tool carries (see `mcp/adapter.ts`). */
45
+ const MCP_TOOL_PREFIX = "mcp__";
46
+ /**
47
+ * Whether granting `name` to a child makes it a writer. The named set above,
48
+ * plus every MCP tool: the adapter gates each call on approval precisely
49
+ * because a server's tool can mutate anything, and the classifier never lets
50
+ * the server declare itself read-only — so the disjointness check must not
51
+ * either.
52
+ */
53
+ export function isWriteTool(name) {
54
+ return SUBAGENT_WRITE_TOOLS.has(name) || name.startsWith(MCP_TOOL_PREFIX);
55
+ }
33
56
  /**
34
57
  * The default child toolset: read-only investigation plus skills. Mirrors the
35
58
  * C.31 propose-phase set — no writes, no shell, no VCS unless the parent
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { resolveToolPath, toPosix } from "./paths.js";
5
5
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
+ import { changedSince, snapshotFile, snapshotOf, } from "./snapshot.js";
6
7
  /** How many leading lines of a created file the approval preview shows. */
7
8
  const PREVIEW_LINES = 20;
8
9
  /**
@@ -162,6 +163,20 @@ export const applyPatchTool = {
162
163
  if (!decision.allow) {
163
164
  return { ok: false, error: decision.feedback ?? "patch denied" };
164
165
  }
166
+ // The approval covered THESE files in THE states they were read in. Any
167
+ // path that moved during the wait — rewritten, deleted, or created by
168
+ // something else — voids the whole patch: nothing is applied, so the model
169
+ // re-reads and resubmits rather than landing a half-stale patch (P1).
170
+ // Every drifted path is named, not just the first: a retry that knows one
171
+ // of two moved re-reads one file and trips over the other.
172
+ const drifted = [];
173
+ for (const p of planned) {
174
+ const moved = await changedSince(p.abs, p.approved, p.rel);
175
+ if (moved)
176
+ drifted.push(moved);
177
+ }
178
+ if (drifted.length > 0)
179
+ return { ok: false, error: drifted.join("\n") };
165
180
  // Validation passed and the user approved; apply everything. A mid-apply I/O
166
181
  // failure is rare but reported with what already landed.
167
182
  const applied = [];
@@ -195,28 +210,44 @@ async function openTrack(i, op, abs, ctx) {
195
210
  // messages), consistent with every other path tool — see {@link toPosix}.
196
211
  const rel = toPosix(path.relative(ctx.cwd, abs));
197
212
  const base = { abs, rel, firstOp: i, hunks: [] };
198
- if (op.type === "create") {
199
- if (await exists(abs)) {
200
- return { ok: false, error: opError(i, op, "file already exists") };
213
+ if (op.type === "create" || op.type === "delete") {
214
+ let approved;
215
+ try {
216
+ approved = await snapshotFile(abs);
201
217
  }
202
- const content = op.content ?? "";
203
- return {
204
- ok: true,
205
- track: { ...base, kind: "create", content, eol: detectEol(content) },
206
- };
207
- }
208
- if (op.type === "delete") {
209
- if (!(await exists(abs))) {
218
+ catch (err) {
219
+ return { ok: false, error: opError(i, op, err.message) };
220
+ }
221
+ if (op.type === "create") {
222
+ if (approved.kind === "present") {
223
+ return { ok: false, error: opError(i, op, "file already exists") };
224
+ }
225
+ const content = op.content ?? "";
226
+ return {
227
+ ok: true,
228
+ track: {
229
+ ...base,
230
+ kind: "create",
231
+ content,
232
+ eol: detectEol(content),
233
+ approved,
234
+ },
235
+ };
236
+ }
237
+ if (approved.kind === "absent") {
210
238
  return { ok: false, error: opError(i, op, "file not found") };
211
239
  }
212
240
  return {
213
241
  ok: true,
214
- track: { ...base, kind: "delete", content: "", eol: "\n" },
242
+ track: { ...base, kind: "delete", content: "", eol: "\n", approved },
215
243
  };
216
244
  }
217
245
  let content;
246
+ let approved;
218
247
  try {
219
- content = await fs.readFile(abs, "utf8");
248
+ const bytes = await fs.readFile(abs);
249
+ approved = snapshotOf(bytes);
250
+ content = bytes.toString("utf8");
220
251
  }
221
252
  catch (err) {
222
253
  if (err.code === "ENOENT") {
@@ -229,6 +260,7 @@ async function openTrack(i, op, abs, ctx) {
229
260
  kind: "update",
230
261
  content,
231
262
  eol: detectEol(content),
263
+ approved,
232
264
  };
233
265
  const failure = applyHunk(i, op, track);
234
266
  return failure ? { ok: false, error: failure } : { ok: true, track };
@@ -261,12 +293,12 @@ function applyHunk(i, op, track) {
261
293
  }
262
294
  /** Collapse a finished track into the single write it represents. */
263
295
  function toPlanned(track) {
264
- const { kind, abs, rel, content, hunks } = track;
296
+ const { kind, abs, rel, content, hunks, approved } = track;
265
297
  if (kind === "delete")
266
- return { op: "delete", abs, rel };
298
+ return { op: "delete", abs, rel, approved };
267
299
  if (kind === "create")
268
- return { op: "create", abs, rel, content };
269
- return { op: "update", abs, rel, content, hunks };
300
+ return { op: "create", abs, rel, content, approved };
301
+ return { op: "update", abs, rel, content, hunks, approved };
270
302
  }
271
303
  /** Shape a planned op into its approval-preview form. */
272
304
  function toPreview(p) {
@@ -287,9 +319,3 @@ function toPreview(p) {
287
319
  function opError(i, op, reason) {
288
320
  return `operation ${i + 1} (${op.type} ${op.path}): ${reason}`;
289
321
  }
290
- async function exists(abs) {
291
- return fs
292
- .access(abs)
293
- .then(() => true)
294
- .catch(() => false);
295
- }
@@ -2,10 +2,15 @@ import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { resolveToolPath } from "./paths.js";
4
4
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
5
+ import { changedSince, snapshotOf } from "./snapshot.js";
5
6
  /**
6
7
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
7
8
  * The uniqueness requirement is checked before approval so the model can fix an
8
9
  * ambiguous match without burning a prompt; gated on `ctx.approve` before writing.
10
+ *
11
+ * The bytes read before approval are the state the approval is granted
12
+ * against; the write is refused if the file is no longer in that state when
13
+ * the write is about to happen (P1 — see `snapshot.ts`).
9
14
  */
10
15
  export const editFileTool = {
11
16
  name: "edit_file",
@@ -29,8 +34,11 @@ export const editFileTool = {
29
34
  return { ok: false, error: err.message };
30
35
  }
31
36
  let content;
37
+ let approvedState;
32
38
  try {
33
- content = await fs.readFile(abs, "utf8");
39
+ const bytes = await fs.readFile(abs);
40
+ approvedState = snapshotOf(bytes);
41
+ content = bytes.toString("utf8");
34
42
  }
35
43
  catch (err) {
36
44
  if (err.code === "ENOENT") {
@@ -64,6 +72,12 @@ export const editFileTool = {
64
72
  const updated = content.slice(0, match.start) +
65
73
  applyEol(input.new_str, detectEol(content)) +
66
74
  content.slice(match.end);
75
+ // The approval covered a diff against the bytes read above. Anything that
76
+ // changed the file during the wait makes `updated` a splice of stale
77
+ // content — refuse rather than overwrite what is there now (P1).
78
+ const moved = await changedSince(abs, approvedState, input.path);
79
+ if (moved)
80
+ return { ok: false, error: moved };
67
81
  try {
68
82
  await fs.writeFile(abs, updated, "utf8");
69
83
  return { ok: true, output: `edited ${input.path}` };
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { ErrorCode } from "../../errors/index.js";
4
+ /** Snapshot the bytes a tool has ALREADY read (no second read). */
5
+ export function snapshotOf(bytes) {
6
+ return {
7
+ kind: "present",
8
+ digest: createHash("sha256").update(bytes).digest("hex"),
9
+ };
10
+ }
11
+ /**
12
+ * Read `abs` and snapshot it. A missing path is a legitimate state (`absent`)
13
+ * — it is what `write_file` and a patch `create` are approved against. Every
14
+ * other failure (a directory at the path, EACCES) propagates: the caller
15
+ * cannot build a truthful preview of a file it cannot read.
16
+ */
17
+ export async function snapshotFile(abs) {
18
+ try {
19
+ return snapshotOf(await fs.readFile(abs));
20
+ }
21
+ catch (err) {
22
+ if (err.code === "ENOENT") {
23
+ return { kind: "absent" };
24
+ }
25
+ throw err;
26
+ }
27
+ }
28
+ /**
29
+ * Re-read `abs` and compare it with the state the approval was granted
30
+ * against. Returns `null` when the file is exactly as it was, otherwise the
31
+ * refusal to hand back as the tool's error — one sentence on what moved, and
32
+ * the same next step every time: read it again and retry.
33
+ *
34
+ * Call this AFTER approval and IMMEDIATELY before the write, with nothing
35
+ * awaited in between — the point is to make the gap as small as the platform
36
+ * allows, not to check early and then wait.
37
+ */
38
+ export async function changedSince(abs, approved, rel) {
39
+ let now;
40
+ try {
41
+ now = await snapshotFile(abs);
42
+ }
43
+ catch (err) {
44
+ return refusal(rel, `could not be re-read before the approved write (${err.message})`);
45
+ }
46
+ if (approved.kind === "absent") {
47
+ return now.kind === "absent"
48
+ ? null
49
+ : refusal(rel, "was created by something else after it was approved as a new file");
50
+ }
51
+ if (now.kind === "absent") {
52
+ return refusal(rel, "was deleted after it was read");
53
+ }
54
+ if (now.digest !== approved.digest) {
55
+ return refusal(rel, "changed on disk after it was read");
56
+ }
57
+ return null;
58
+ }
59
+ /** The one refusal shape: what moved, that nothing was written, what to do. */
60
+ function refusal(rel, what) {
61
+ return (`${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
62
+ `covers this write; nothing was written — read the file again and retry`);
63
+ }
@@ -2,11 +2,18 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { resolveToolPath } from "./paths.js";
5
+ import { changedSince, snapshotFile } from "./snapshot.js";
5
6
  /** How many leading lines of new content the approval preview shows. */
6
7
  const PREVIEW_LINES = 20;
7
8
  /**
8
9
  * Create or overwrite a file within the project root. Gated on `ctx.approve`
9
10
  * before anything is written.
11
+ *
12
+ * The target is snapshotted before approval — its current bytes, or its
13
+ * absence — and the write is refused if that state has moved by the time the
14
+ * write is about to happen (P1 — see `snapshot.ts`). This tool used to have
15
+ * no read at all: an "overwrite" approved against one version of a file would
16
+ * land on whatever version was there by the time the user pressed `y`.
10
17
  */
11
18
  export const writeFileTool = {
12
19
  name: "write_file",
@@ -25,11 +32,19 @@ export const writeFileTool = {
25
32
  catch (err) {
26
33
  return { ok: false, error: err.message };
27
34
  }
28
- // Does the target already exist? Drives create-vs-overwrite in the preview.
29
- const exists = await fs
30
- .access(abs)
31
- .then(() => true)
32
- .catch(() => false);
35
+ // Does the target already exist? Drives create-vs-overwrite in the preview,
36
+ // and its bytes (or absence) are the state the approval is granted against.
37
+ let approvedState;
38
+ try {
39
+ approvedState = await snapshotFile(abs);
40
+ }
41
+ catch (err) {
42
+ return {
43
+ ok: false,
44
+ error: `cannot read ${input.path}: ${err.message}`,
45
+ };
46
+ }
47
+ const exists = approvedState.kind === "present";
33
48
  // First N lines of the new content, with a count of what's omitted.
34
49
  const allLines = input.content.split("\n");
35
50
  const lines = allLines.slice(0, PREVIEW_LINES);
@@ -46,6 +61,12 @@ export const writeFileTool = {
46
61
  error: decision.feedback ?? `write to ${input.path} denied`,
47
62
  };
48
63
  }
64
+ // A create approved against "no file here" must not overwrite a file that
65
+ // appeared during the wait; an overwrite approved against one version must
66
+ // not land on another (P1).
67
+ const moved = await changedSince(abs, approvedState, input.path);
68
+ if (moved)
69
+ return { ok: false, error: moved };
49
70
  try {
50
71
  await fs.mkdir(path.dirname(abs), { recursive: true });
51
72
  await fs.writeFile(abs, input.content, "utf8");
package/dist/tui/app.js CHANGED
@@ -3,13 +3,14 @@ import { completeLine } from "../components/autocomplete.js";
3
3
  import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
4
4
  import { TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
5
5
  import { selectList } from "../components/select.js";
6
- import { viewLabel, viewOrder } from "./views.js";
6
+ import { CONVERSATION_VIEW, viewLabel, viewOrder } from "./views.js";
7
7
  import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
8
8
  import { ModeRing } from "./mode-ring.js";
9
9
  import { openPalette } from "./palette.js";
10
10
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
11
11
  import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
12
12
  import { COLUMN_LABELS, PANEL_LABELS } from "./panels.js";
13
+ import { WHEEL_LINES } from "./renderer.js";
13
14
  /**
14
15
  * The TUI's input loop (P1) — the piece that replaces `repl.ts`'s readline
15
16
  * loop. It owns exactly two things: the edit buffer and command dispatch.
@@ -59,7 +60,10 @@ const HELP = [
59
60
  " Ctrl+K open the command palette",
60
61
  " Ctrl+B focus the sidebar nav (arrows switch view, Esc leaves)",
61
62
  " Shift+Tab cycle mode (manual · auto-approve · plan · full-auto)",
62
- " PgUp / PgDn scroll the pane; Esc returns to the live view",
63
+ " PgUp / PgDn scroll the pane (the mouse wheel does too; to select",
64
+ " text hold Shift — Option in Terminal.app — or set",
65
+ " CRUXY_NO_MOUSE=1 to give the wheel back to the terminal)",
66
+ " Esc back to the live view, then back to the conversation",
63
67
  " Ctrl+D leave cruxy",
64
68
  ];
65
69
  /**
@@ -177,6 +181,10 @@ async function readLine(keys, renderer, hooks) {
177
181
  // answering into a pane they cannot see is the one case where holding
178
182
  // still is wrong.
179
183
  renderer.scrollToLive();
184
+ // And clears the last command's output from under a view (cli#7b):
185
+ // what this line produces must not stack beneath what the last one
186
+ // did. It is all still in the conversation.
187
+ renderer.dismissNotices();
180
188
  paint();
181
189
  return text;
182
190
  }
@@ -282,12 +290,23 @@ async function readLine(keys, renderer, hooks) {
282
290
  // schedules its own repaint for the rows that did.
283
291
  renderer.scrollPage(key.kind === "page-up" ? 1 : -1);
284
292
  break;
293
+ case "wheel-up":
294
+ case "wheel-down":
295
+ // The mouse wheel (cli#1) is the same scroll at a finer grain. It
296
+ // only arrives because the renderer turned mouse reporting on; the
297
+ // terminal would otherwise have scrolled its own window (Terminal.app)
298
+ // or sent arrows this loop ignores (iTerm2).
299
+ renderer.scrollBy(key.kind === "wheel-up" ? WHEEL_LINES : -WHEEL_LINES);
300
+ break;
285
301
  case "escape":
286
- // Esc leaves the scrolled view a mode needs a visible exit, and the
287
- // notice names this key. It stays inert at the live tail rather than
288
- // being claimed unconditionally, so the binding remains free for
289
- // whatever a later track wants Esc to mean when nothing is scrolled.
290
- renderer.scrollToLive();
302
+ // Esc peels one layer (cli#7b): out of scrollback first a mode
303
+ // needs a visible exit, and the notice names this key and, at the
304
+ // live tail, out of a view and back to the conversation. Two presses
305
+ // from anywhere reach the home position; each one is a step the
306
+ // screen can show. Inert at the conversation's live tail, so the
307
+ // binding stays free for whatever a later track wants it to mean.
308
+ if (!renderer.scrollToLive())
309
+ renderer.setView(CONVERSATION_VIEW);
291
310
  break;
292
311
  case "ctrl-b":
293
312
  // Take the keyboard to the sidebar nav (P7 track 2). Refused when the
@@ -485,6 +504,10 @@ export async function runTui(session, renderer, opts = {}) {
485
504
  print: (line = "") => renderer.println(line),
486
505
  theme: renderer.theme,
487
506
  fit: (line) => line,
507
+ // `/clear` (cli#2): the TUI owns its scrollback, so a cleared history is
508
+ // also a cleared screen. The REPL has no equivalent — its transcript is the
509
+ // terminal's own scrollback, which is not the CLI's to erase.
510
+ clear: () => renderer.clearScrollback(),
488
511
  };
489
512
  // The mode ring (Q5). The chip follows every press; the session is told once,
490
513
  // when the presses stop — see `mode-ring.ts` for why passing through a mode is
@@ -1,3 +1,4 @@
1
+ import { readAnswerKey } from "../components/input.js";
1
2
  import { themeForColor } from "../theme/index.js";
2
3
  /**
3
4
  * The approval prompt as an in-viewport modal (P5 track 2).
@@ -88,7 +89,9 @@ export function createOverlayPromptIO(surface, lease, color) {
88
89
  paint();
89
90
  },
90
91
  async readKey() {
91
- const key = await read(keys);
92
+ // A wheel notch scrolls the conversation behind the drawer; it is not an
93
+ // answer, and mapping it to "" would deny the action being asked about.
94
+ const key = keys === null ? await read(keys) : await readAnswerKey(keys);
92
95
  return keyToChar(key);
93
96
  },
94
97
  /**
@@ -124,6 +124,28 @@ export function fitBlock(lines, rows, width) {
124
124
  out.push(" ".repeat(Math.max(0, width)));
125
125
  return out;
126
126
  }
127
+ /**
128
+ * Take the FIRST `rows` lines of a block and pad it to exactly that many rows —
129
+ * the mirror of {@link fitBlock}, for a column whose top is the part that must
130
+ * survive.
131
+ *
132
+ * The sidebar is that column (cli#7a). It opens with the view nav: a heading
133
+ * and one row per view, fixed-height, and NAVIGABLE — Ctrl+B puts the keyboard
134
+ * on it, and the arrows move a pointer down its rows. `fitBlock`'s tail rule
135
+ * applied to that column ate the heading and the first four view rows at 30
136
+ * rows, which left Ctrl+B moving a pointer the screen never showed. Nothing
137
+ * at the bottom of the sidebar is worth that: the session list below the nav
138
+ * is composed to its own budget by the renderer (see `sidebarLines`), so what
139
+ * reaches here already fits, and when it does not — a terminal too short for
140
+ * the nav itself — the rows to lose are the last ones, not the first.
141
+ */
142
+ export function fitHead(lines, rows, width) {
143
+ const head = lines.length > rows ? lines.slice(0, Math.max(0, rows)) : lines;
144
+ const out = head.map((l) => padTo(l, width));
145
+ while (out.length < rows)
146
+ out.push(" ".repeat(Math.max(0, width)));
147
+ return out;
148
+ }
127
149
  /**
128
150
  * Window `rows` lines out of a block, `offset` display lines up from the end
129
151
  * (P7 track 1). `offset === 0` is the tail view {@link fitBlock} gives, and the
@@ -293,8 +315,10 @@ export function composeScreen(vm, width, height, open, theme) {
293
315
  const drawer = fitOverlay(vm.overlay ?? [], overlayRows(height));
294
316
  const columnRows = Math.max(1, rows - drawer.length);
295
317
  const columns = [];
318
+ // HEAD-fitted, not tail: the sidebar is a nav over a list, and the nav is the
319
+ // part that has to be on screen — see `fitHead`. `main` stays a tail view.
296
320
  if (budget.sidebar > 0)
297
- columns.push(fitBlock(vm.sidebar, columnRows, budget.sidebar));
321
+ columns.push(fitHead(vm.sidebar, columnRows, budget.sidebar));
298
322
  columns.push(fitBlock(vm.main, columnRows, budget.main));
299
323
  if (budget.rail > 0)
300
324
  columns.push(fitBlock(vm.rail, columnRows, budget.rail));
@@ -50,22 +50,44 @@ function title(text, theme) {
50
50
  * The column is narrow (18 columns), so each session takes two lines: its short
51
51
  * id and age, then its title. The layout truncates per line, which keeps the id
52
52
  * — the part you would type into `--resume` — always fully visible.
53
+ *
54
+ * `rows` is the HEIGHT budget (cli#7a), and the list is fitted to it here the
55
+ * way `stackPanels` fits the rail: whole sessions, newest first, and a count of
56
+ * what did not fit rather than a session cut in half or silently absent. The
57
+ * list sits under the view nav, which is fixed-height and keyboard-driven, so
58
+ * it is the list that yields — and because it is newest-first, the rows to
59
+ * yield are at the END. The old tail-fit lost the nav heading and the newest
60
+ * sessions at once, which is the wrong end of both blocks.
53
61
  */
54
- export function sidebarLines(theme, sessions = [], activeSessionId, now = Date.now()) {
62
+ export function sidebarLines(theme, sessions = [], activeSessionId, now = Date.now(), rows = Infinity) {
63
+ if (rows <= 0)
64
+ return [];
55
65
  const lines = title("sessions", theme);
56
66
  if (sessions.length === 0) {
57
67
  lines.push(theme.muted("no saved sessions"));
58
68
  lines.push(theme.muted("for this project yet."));
59
- return lines;
69
+ return lines.slice(0, rows);
60
70
  }
61
- for (const s of sessions) {
71
+ // Rows left for sessions after the title. Each session costs two, and when
72
+ // not all fit, one row is charged for the notice BEFORE deciding how many do
73
+ // — reserving it afterwards could evict a session just counted as shown.
74
+ const room = Math.max(0, rows - lines.length);
75
+ const fitsWhole = sessions.length * 2 <= room;
76
+ const shown = fitsWhole
77
+ ? sessions.length
78
+ : Math.max(0, Math.floor((room - 1) / 2));
79
+ for (const s of sessions.slice(0, shown)) {
62
80
  const active = s.sessionId === activeSessionId;
63
81
  const mark = active ? theme.accent(theme.glyph.pointer) : " ";
64
82
  const head = `${mark} ${shortId(s.sessionId)} ${relativeAge(s.updatedAt, now)}`;
65
83
  lines.push(active ? theme.strong(head) : head);
66
84
  lines.push(theme.muted(` ${s.title}`));
67
85
  }
68
- return lines;
86
+ if (!fitsWhole && room > 0) {
87
+ const hidden = sessions.length - shown;
88
+ lines.push(theme.muted(`${theme.glyph.ellipsis}${hidden} more session${hidden === 1 ? "" : "s"}`));
89
+ }
90
+ return lines.slice(0, rows);
69
91
  }
70
92
  /** A panel with no state yet: says so, rather than inventing a plausible value. */
71
93
  function pending(theme) {
@@ -236,19 +258,35 @@ function shortTokens(n) {
236
258
  *
237
259
  * The compaction threshold is shown because it is the only actionable thing
238
260
  * here: it says when the CLI will start folding history away.
261
+ *
262
+ * THE ALLOWANCE IS NAMED (cli#4). `used` includes `context.reserveTokens` — a
263
+ * fixed 4,500 by default — because the compaction seam adds it, and the panel
264
+ * must measure what the seam measures. But shown as a bare figure it read as
265
+ * consumption: an empty session opened at "~5k / 100k", as if something had
266
+ * already been spent. Nothing had. The constant is a fair size for what it
267
+ * stands in for (the system prompt plus the default tool catalogue measure
268
+ * about 3.7k), and it is an ALLOWANCE, not a reading: it does not move when
269
+ * CRUXY.md, memory recall, LSP, web or MCP schemas make the real request
270
+ * larger. The second row says so, in the room a 24-column strip has.
239
271
  */
240
272
  export function contextPanelLines(theme, reading) {
241
273
  if (reading === undefined) {
242
274
  return [theme.muted(`measuring${theme.glyph.ellipsis}`)];
243
275
  }
244
- const { used, total, compactAt } = reading;
276
+ const { used, total, compactAt, reserve } = reading;
245
277
  const figure = `~${shortTokens(used)} / ${shortTokens(total)} budget`;
246
278
  // Past the threshold the next turn compacts, which is worth flagging — but as
247
279
  // a statement of what happens next, not as an alarm about a guessed number.
248
280
  // Compared on `used`, not the clamped fraction: the clamp is for display, and
249
281
  // a history that has overrun the budget must not compare as merely "at" it.
250
282
  const style = used >= compactAt ? theme.warning : theme.strong;
251
- return [style(figure), theme.muted(`compacts at ${shortTokens(compactAt)}`)];
283
+ return [
284
+ style(figure),
285
+ ...(reserve === undefined || reserve <= 0
286
+ ? []
287
+ : [theme.muted(`incl. ~${shortTokens(reserve)} allowance`)]),
288
+ theme.muted(`compacts at ${shortTokens(compactAt)}`),
289
+ ];
252
290
  }
253
291
  /** The opening lines of the main column, before any turn has run. */
254
292
  export function mainWelcome(theme, hint) {