@cruxy/cli 1.10.0 → 1.11.1

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.
@@ -2,16 +2,11 @@ import { MODEL_TIERS } from "../brand/voice.js";
2
2
  import { routingTierUnavailable } from "../errors/index.js";
3
3
  import { resolveModelId } from "./resolve.js";
4
4
  import { AUTO_MODEL, } from "./types.js";
5
- /**
6
- * The tier a config resolves to when nothing else pins one down — mirrors the
7
- * gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
8
- * cruxy session lands on exactly the tier it does today.
9
- */
10
- export const DEFAULT_TIER = "vaani";
11
5
  /**
12
6
  * The config-driven {@link Router}: maps a declared task class to a tier from
13
7
  * `{ default, map }`, and fails loud when the resolved tier is not offered. It
14
- * NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
8
+ * NEVER inspects prompt content — selection is purely
9
+ * `map[taskClass] ?? default ?? null`.
15
10
  */
16
11
  export class ConfigRouter {
17
12
  cfg;
@@ -28,9 +23,17 @@ export class ConfigRouter {
28
23
  this.offered = new Set(offered);
29
24
  }
30
25
  select(taskClass) {
31
- // Explicit override, else the default an unmapped/unknown class is not an
32
- // error, it just takes the default (never a crash, never the cheapest).
33
- const tier = this.cfg.map[taskClass] ?? this.cfg.default;
26
+ // Explicit override, else the table's default, else DECLINE. An unmapped
27
+ // class is not an error and never a crash but with no `default` it is not
28
+ // a tier either. Declining means `auto`: the gateway routes that request
29
+ // (see `Router.select`), which is what a table saying nothing about a class
30
+ // actually asked for. Picking some tier here instead would be the same
31
+ // silent substitution the throw below refuses, just quieter — a user who
32
+ // wrote `map: { summarize: kavi }` and no default said nothing whatsoever
33
+ // about the other four classes, and "nothing" is not a vote for a tier.
34
+ const tier = this.cfg.map[taskClass] ?? this.cfg.default ?? null;
35
+ if (tier === null)
36
+ return null;
34
37
  // Fail loud: a configured tier the gateway does not offer is a usage error
35
38
  // to fix, NOT a silent substitution to some other tier (a user who asked for
36
39
  // mira reasoning must never be quietly handed kavi).
@@ -42,14 +45,16 @@ export class ConfigRouter {
42
45
  }
43
46
  /**
44
47
  * The base tier implied by the session's `model.model`: a real tier passes
45
- * through; `auto` (and any non-tier value) falls back to {@link DEFAULT_TIER}.
46
- * Used so that when a user has pinned a single tier, an opt-in routing table
47
- * that omits `routing.default` still defaults to THEIR tier, not a fixed one.
48
+ * through, and `auto` (or any non-tier value) implies none `undefined`, not a
49
+ * stand-in. Used so that when a user has pinned a single tier, an opt-in routing
50
+ * table that omits `routing.default` still defaults to THEIR tier, not a fixed
51
+ * one; and so that when they pinned nothing, the table does not acquire a
52
+ * default they never wrote.
48
53
  */
49
54
  function baseTierFromModel(model) {
50
55
  return MODEL_TIERS.includes(model)
51
56
  ? model
52
- : DEFAULT_TIER;
57
+ : undefined;
53
58
  }
54
59
  /**
55
60
  * Build a router from resolved config, or `null` when routing should stay
@@ -68,6 +73,21 @@ export function routerForConfig(config) {
68
73
  const configured = def !== undefined || Object.keys(map).length > 0;
69
74
  if (!configured)
70
75
  return null;
76
+ // The fill is deliberate in ONE direction. When `model.model` names a real
77
+ // tier the user declared a session-wide model, so a table without a `default`
78
+ // inherits it — dropping those classes to `auto` would substitute the
79
+ // gateway's judgement for a choice they made, which is the same wrong as
80
+ // substituting vaani for `auto`, from the other side. When it names no tier
81
+ // (`auto`, the schema default) `baseTierFromModel` returns undefined and the
82
+ // table keeps its silence.
83
+ //
84
+ // NOT DEAD CODE, despite looking it from the session path: `session-factory`
85
+ // hands this router to a `SessionModel` that consults it only while the choice
86
+ // is `auto` (session-factory.ts:341), and a `model.model` naming a tier makes
87
+ // the choice that tier — so the tier branch never fires there. `cruxy pr`
88
+ // calls `routerForConfig` directly (cli/commands/pr.ts:69) with no
89
+ // `SessionModel` in front of it, and that is where a pinned `model.model`
90
+ // reaches this line.
71
91
  return new ConfigRouter({
72
92
  default: def ?? baseTierFromModel(config.model.model),
73
93
  map,
@@ -20,8 +20,6 @@ export const TASK_CLASSES = [
20
20
  "plan",
21
21
  /** Commit / pull-request text generation. */
22
22
  "commit-msg",
23
- /** A cheap one-shot classification. */
24
- "classify",
25
23
  /** Context compaction / summarization. */
26
24
  "summarize",
27
25
  ];
@@ -1,5 +1,6 @@
1
1
  import { unlinkSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKeysPresent, } from "../jobs/log-store.js";
3
4
  import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
4
5
  import { sessionFilesByRecency } from "./list.js";
5
6
  import { SESSION_FILE_EXT } from "./paths.js";
@@ -30,6 +31,8 @@ export function pruneSessions(cwd, opts) {
30
31
  kept: 0,
31
32
  bytesFreed: 0,
32
33
  failed: 0,
34
+ jobLogsRemoved: 0,
35
+ jobLogBytesFreed: 0,
33
36
  };
34
37
  if (!opts.sessions.enabled)
35
38
  return result;
@@ -72,35 +75,92 @@ export function pruneSessions(cwd, opts) {
72
75
  result.kept++;
73
76
  }
74
77
  }
75
- sweepOrphanedJobLogs();
78
+ sweepOrphanedJobLogs(cwd, result, opts);
76
79
  return result;
77
80
  }
78
81
  /**
79
- * Drop retained job logs whose owning session has just been pruned.
82
+ * Drop the job logs this project no longer has a reason to keep (#172 item 1).
80
83
  *
81
- * DELIBERATELY EMPTY, and reserved rather than forgotten. #172 item 1 will
82
- * write background-job output to the `subagents/` subtree `paths.ts` reserves
83
- * under this same project directory, and #257 exists because that issue asked
84
- * "whether the retained-on-disk log is pruned with the session or independently"
85
- * and found no answer. The answer is HERE, and it is: with the session, keyed on
86
- * the session.
84
+ * This is the second phase of the same mark-and-sweep `CheckpointService.prune`
85
+ * runs: the doomed sessions are already gone above, and what follows is the
86
+ * content that belonged to them. A job log IS content owned by its session,
87
+ * keyed on the session, exactly as a checkpoint's shadow objects are content
88
+ * owned by its manifest which is the answer #172 asked for in passing and
89
+ * #257 was filed to settle: pruned WITH the session, not independently.
87
90
  *
88
- * That makes a job log content owned by its session in exactly the way a
89
- * checkpoint's shadow objects are content owned by its manifest — so this is
90
- * `CheckpointService.prune`'s second phase, the mark-and-sweep that runs after
91
- * the doomed manifests are gone. Filling it in needs one thing the writer does
92
- * not have yet: a job log has to record which session owns it, in its path or
93
- * its first line.
91
+ * ## The three rules, and why each one is where the line falls
94
92
  *
95
- * The hook is here so item 1 fills it rather than inventing a parallel
96
- * retention policy alongside this one. Whatever lands must not `rm -r` this
97
- * directory and must not assume every entry is a session's — `subagents/` is a
98
- * sibling of the session files, not one of them.
93
+ * **A finished job's log goes at the next start.** A `done` job's OUTCOME is
94
+ * already durable somewhere better: the dispatch tool's result is a
95
+ * `tool_result` in the session's own transcript, its file mutations are a
96
+ * checkpoint set `cruxy rollback <id>` still finds, and its spend is a usage
97
+ * record under the same id. What only this file holds is the intermediate
98
+ * chatter of a run that went fine — the least interesting artefact in the set.
99
+ * Dropping it eagerly is what keeps the steady-state cost of this subtree near
100
+ * zero, so the logs that survive are the ones somebody would actually open: the
101
+ * failed and the interrupted.
99
102
  *
100
- * It takes nothing today because it does nothing; item 1 will want the `cwd`
101
- * and the {@link PruneResult} whose `removed` names the sessions whose logs are
102
- * now orphaned.
103
+ * **An orphan goes when its session does.** Not only the sessions THIS prune
104
+ * removed: the rule is "no session file with this key is present", which also
105
+ * covers `cruxy sessions rm`, a prune from another process, and a file the user
106
+ * deleted by hand. Keying it on what the directory holds rather than on
107
+ * `result.removed` means there is no path by which a log outlives its session
108
+ * unnoticed.
109
+ *
110
+ * **Nothing without a terminal record is deleted while it is young.** A log
111
+ * with no `end` record is one of two things, and this sweep cannot tell them
112
+ * apart: a job that was interrupted (the whole reason to keep logs at all) or a
113
+ * job that is RUNNING RIGHT NOW in another terminal, whose session may not have
114
+ * flushed its own file yet. Treating it as garbage would delete a live job's
115
+ * output from under it. Both cases are handled by leaving it alone until it is
116
+ * older than `maxAgeDays`, at which point it is certainly not live and its
117
+ * session is certainly gone.
118
+ *
119
+ * The active session is skipped outright, for the reason `pruneSessions` spares
120
+ * its own file: "almost always survives" is not a policy to hand a user's
121
+ * running work.
122
+ *
123
+ * ## What it must not do
124
+ *
125
+ * No `rm -r`, and no assumption that every entry here is a session's — the
126
+ * scan behind {@link jobLogFilesByRecency} filters to regular `.jsonl` files
127
+ * whose name splits into exactly two ids, so anything else in `subagents/`
128
+ * (including the subtree's own subdirectories, should P3 add any) is never even
129
+ * considered. Never throws: a log that cannot be unlinked is left, exactly as a
130
+ * session that cannot be is.
103
131
  */
104
- function sweepOrphanedJobLogs() {
105
- // #172 item 1.
132
+ function sweepOrphanedJobLogs(cwd, result, opts) {
133
+ const refs = jobLogFilesByRecency(cwd);
134
+ if (refs.length === 0)
135
+ return;
136
+ // Derived from the directory as it stands NOW — after the deletes above — so
137
+ // the sessions this prune just removed are absent from it by construction and
138
+ // need no separate bookkeeping.
139
+ const present = sessionKeysPresent(sessionFilesByRecency(cwd));
140
+ const activeKey = opts.activeSessionId === undefined
141
+ ? undefined
142
+ : idKey(opts.activeSessionId);
143
+ const now = opts.now ?? Date.now();
144
+ const cutoff = now - opts.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
145
+ for (const ref of refs) {
146
+ if (activeKey !== undefined && ref.sessionKey === activeKey)
147
+ continue;
148
+ // One bounded tail read per file, never a full one: see `TAIL_BYTES`.
149
+ const status = readJobLogTerminal(ref.file);
150
+ const orphaned = !present.has(ref.sessionKey);
151
+ const doomed = status === "done" ||
152
+ (orphaned && (status !== INTERRUPTED || ref.mtimeMs < cutoff));
153
+ if (!doomed)
154
+ continue;
155
+ try {
156
+ unlinkSync(ref.file);
157
+ result.jobLogsRemoved++;
158
+ result.jobLogBytesFreed += ref.size;
159
+ }
160
+ catch {
161
+ // Left in place, and deliberately NOT counted in `failed` — that field
162
+ // counts sessions, and inflating it here would report a conversation that
163
+ // could not be deleted when none was even tried.
164
+ }
165
+ }
106
166
  }
@@ -147,7 +147,6 @@ export class SubagentOrchestrator {
147
147
  const startedAt = new Date().toISOString();
148
148
  try {
149
149
  return await this.runChild({
150
- spec,
151
150
  opts,
152
151
  messages,
153
152
  registry,
@@ -193,7 +192,7 @@ export class SubagentOrchestrator {
193
192
  * usage fold above can be a `finally` over every path this can leave by. */
194
193
  async runChild(args) {
195
194
  const { deps } = this;
196
- const { spec, opts, messages, registry, budget, ctx, artifacts } = args;
195
+ const { opts, messages, registry, budget, ctx, artifacts } = args;
197
196
  const { label, noun, tag, usage } = args;
198
197
  let run;
199
198
  try {
@@ -211,7 +210,13 @@ export class SubagentOrchestrator {
211
210
  subagent: true,
212
211
  budget,
213
212
  router: deps.router,
214
- taskClass: spec.taskClass ?? "subagent",
213
+ // A LITERAL, not a per-spawn override (cli#281). Every child is a
214
+ // `subagent` by construction; letting the spec name its own class would
215
+ // hand the model a lever to route itself out of `routing.map.subagent`
216
+ // by relabelling, and would put the difficulty guess back in a call site
217
+ // whose whole contract (`routing/types.ts`) is that callers declare and
218
+ // nobody sniffs.
219
+ taskClass: "subagent",
215
220
  signal: opts.signal,
216
221
  // Both, and in this order (cli#244). The child's OWN collector is what
217
222
  // the budget fold below reads — it needs a per-child record — while the
@@ -439,6 +444,14 @@ export class SubagentOrchestrator {
439
444
  return undefined; // no cruxy routing — not a weighted-pool request
440
445
  // No tier means the router chose `auto` and the gateway decides — a request
441
446
  // that still draws on the pool, so it is weighed at the worst case.
447
+ //
448
+ // A routing table that maps some classes and declares no `default` now
449
+ // lands here for `subagent` (it used to be filled with vaani), so such a
450
+ // fan-out is weighed at MAX_TIER_MULTIPLIER 3.8 rather than vaani's 3.25 —
451
+ // about 17% heavier against the pool. That is the intended answer, not
452
+ // drift: the tier genuinely is not known before the request, and a
453
+ // fan-out the gateway sends to kavi must not have been admitted on
454
+ // vaani's arithmetic.
442
455
  return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
443
456
  }
444
457
  /**
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) {