@cruxy/cli 1.11.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.
package/README.md CHANGED
@@ -219,6 +219,7 @@ once. Reduced motion is also implied by screen-reader mode.
219
219
  | Variable | Effect |
220
220
  | --------------------- | ------------------------------------------------------------------------------ |
221
221
  | `CRUXY_NO_ALT_SCREEN` | Keep the full-screen TUI in the normal buffer instead of the alternate screen. |
222
+ | `CRUXY_NO_MOUSE` | Leave the mouse to the terminal — the wheel scrolls the window, not the pane. |
222
223
 
223
224
  By default the TUI runs on the terminal's alternate screen — the second buffer
224
225
  `less` and `vim` use. Leaving it restores the normal buffer byte for byte, so
@@ -237,6 +238,17 @@ prefer your shell to keep the frame. It changes nothing else about the TUI, and
237
238
  it is ignored where the TUI does not run at all (a pipe, a screen reader,
238
239
  `TERM=dumb`).
239
240
 
241
+ ### The mouse
242
+
243
+ While the TUI is active it turns mouse reporting on, so the wheel scrolls the
244
+ conversation (or the selected view) the way Page Up and Page Down do, instead
245
+ of scrolling the terminal window over a buffer that has no scrollback. The
246
+ trade is the one `less` and `vim` make: **native text selection needs Shift
247
+ (Option in Terminal.app) while the TUI is active.** If you select text
248
+ constantly, set `CRUXY_NO_MOUSE=1` — the wheel goes back to the terminal, and
249
+ nothing else about the TUI changes. It is implied off wherever the alternate
250
+ screen is off, because the two are turned on and released as one.
251
+
240
252
  However cruxy exits — quit, `kill -TERM`, a hangup when the window closes, an
241
253
  uncaught error — the terminal is handed back: the frame erased, the cursor
242
254
  shown, the alternate screen left.
@@ -41,6 +41,11 @@ export function readContext(messages, budget) {
41
41
  // reserve covers the system prompt and tool schemas that `estimateTokens`
42
42
  // never sees, and omitting it here would under-report by ~4.5k tokens and let
43
43
  // the panel read "comfortable" while the seam was about to compact.
44
+ //
45
+ // It is an ALLOWANCE, not a measurement, and every renderer of `used` has
46
+ // to be able to say so — hence `reserve` travels with the reading. It is
47
+ // fixed by config: it does not grow when CRUXY.md, recalled memory, LSP,
48
+ // web or MCP tool schemas grow the actual request.
44
49
  const used = estimateTokens(messages) + budget.reserveTokens;
45
50
  const total = budget.maxTokens;
46
51
  return {
@@ -48,6 +53,7 @@ export function readContext(messages, budget) {
48
53
  total,
49
54
  fraction: total <= 0 ? 1 : Math.min(1, Math.max(0, used / total)),
50
55
  compactAt: Math.round(budget.compactThreshold * total),
56
+ reserve: budget.reserveTokens,
51
57
  };
52
58
  }
53
59
  /**
@@ -63,6 +63,10 @@ export async function dispatchCommand(input, ctx) {
63
63
  return { kind: "exit" };
64
64
  if (trimmed === "/clear") {
65
65
  session.clear();
66
+ // The screen too, where the shell owns one. A history reset that left the
67
+ // old transcript on screen above its own confirmation read as a reset that
68
+ // had not happened — the context WAS empty; only the screen said otherwise.
69
+ out.clear?.();
66
70
  out.print(t.muted("history cleared"));
67
71
  return { kind: "handled" };
68
72
  }
@@ -75,7 +75,7 @@ export async function readSingleKey(stdin = process.stdin) {
75
75
  const keys = createKeyReader(stdin);
76
76
  keys.begin();
77
77
  try {
78
- const key = await keys.read();
78
+ const key = await readAnswerKey(keys);
79
79
  switch (key.kind) {
80
80
  case "char":
81
81
  return key.char;
@@ -89,6 +89,23 @@ export async function readSingleKey(stdin = process.stdin) {
89
89
  keys.restore();
90
90
  }
91
91
  }
92
+ /**
93
+ * The next key that can be an ANSWER — a wheel notch is not one.
94
+ *
95
+ * Every single-key prompt maps "anything unmapped" to the safe default (deny,
96
+ * cancel, no). That is right for an arrow or a function key, and wrong for the
97
+ * mouse wheel: with mouse reporting on (the TUI turns it on), a reader who
98
+ * scrolls back to check what a prompt is about would answer it "no" by doing
99
+ * so. The wheel is a scroll, never a reply, so it is skipped here and the
100
+ * prompt keeps waiting.
101
+ */
102
+ export async function readAnswerKey(keys) {
103
+ for (;;) {
104
+ const key = await keys.read();
105
+ if (key.kind !== "wheel-up" && key.kind !== "wheel-down")
106
+ return key;
107
+ }
108
+ }
92
109
  /** The real environment: frames to stderr, keys from stdin, caps from stderr. */
93
110
  export function defaultComponentIO() {
94
111
  // Detection resolves both axes (stderr for output, stdin for input) and their
@@ -26,6 +26,22 @@ const CBT = 0x5a;
26
26
  * not rejected, so these are matched on the leading number instead.
27
27
  */
28
28
  const TILDE = 0x7e;
29
+ /**
30
+ * The two mouse encodings a terminal can answer `?1000h` with.
31
+ *
32
+ * SGR (`?1006h`, the one the TUI asks for) sends `ESC [ < Cb ; Cx ; Cy M` for
33
+ * a press and `… m` for a release, all in printable ASCII. A terminal that does
34
+ * not know SGR falls back to X10: `ESC [ M` followed by THREE RAW BYTES
35
+ * (button+32, column+32, row+32). The X10 form has to be recognised even though
36
+ * it is never requested, because the alternative is worse than an unmapped key:
37
+ * the generic CSI rule below would stop at the `M`, and the three bytes after
38
+ * it — printable, by construction — would land in the input line as text.
39
+ */
40
+ const SGR_MOUSE_INTRO = 0x3c; // <
41
+ const X10_MOUSE_FINAL = 0x4d; // M
42
+ /** Bit 6 of the button code marks a wheel event; bit 0 says which way. */
43
+ const MOUSE_WHEEL_FLAG = 64;
44
+ const X10_MOUSE_PAYLOAD = 3;
29
45
  /** Leading `~`-sequence parameter → key. `ESC [ 5 ~` / `ESC [ 6 ~`. */
30
46
  const TILDES = {
31
47
  5: "page-up",
@@ -43,9 +59,10 @@ const ARROWS = {
43
59
  * keys per chunk; all are returned in order.
44
60
  *
45
61
  * Escape handling is deliberately simple: `ESC [ A..D` decodes to an arrow,
46
- * `ESC [ … Z` decodes to Shift+Tab, any other CSI sequence (`ESC [ …final`) is
47
- * swallowed whole (unmapped keys must not leak garbage chars into a query), and
48
- * a lone ESC decodes to `escape`. Terminals send arrow sequences atomically in practice; a sequence
62
+ * `ESC [ … Z` decodes to Shift+Tab, a mouse report (SGR `ESC [ < M`, or X10
63
+ * `ESC [ M` + 3 bytes) decodes to a wheel notch or to nothing, any other CSI
64
+ * sequence (`ESC [ …final`) is swallowed whole (unmapped keys must not leak
65
+ * garbage chars into a query), and a lone ESC decodes to `escape`. Terminals send arrow sequences atomically in practice; a sequence
49
66
  * split across chunks degrades to `escape` + literal chars, which is safe
50
67
  * (escape cancels).
51
68
  */
@@ -56,12 +73,44 @@ export function decodeKeys(chunk) {
56
73
  const byte = buf[i];
57
74
  if (byte === ESC) {
58
75
  if (buf[i + 1] === 0x5b /* [ */) {
76
+ // X10 mouse: `ESC [ M` plus three raw payload bytes. Consumed as one
77
+ // unit BEFORE the generic CSI walk, which would otherwise treat `M` as
78
+ // the final byte and hand the payload to the printable branch.
79
+ if (buf[i + 2] === X10_MOUSE_FINAL) {
80
+ const end = i + 3 + X10_MOUSE_PAYLOAD;
81
+ if (end <= buf.length) {
82
+ const wheel = mouseWheel(buf[i + 3] - 32);
83
+ if (wheel)
84
+ keys.push({ kind: wheel });
85
+ i = end;
86
+ continue;
87
+ }
88
+ // Truncated at the chunk boundary: the same degradation as any other
89
+ // partial CSI — escape, and drop the tail.
90
+ keys.push({ kind: "escape" });
91
+ i = buf.length;
92
+ continue;
93
+ }
59
94
  // CSI: consume parameter/intermediate bytes (0x20–0x3f) up to the
60
95
  // final byte (0x40–0x7e); map arrows, swallow everything else.
61
96
  let j = i + 2;
62
97
  while (j < buf.length && buf[j] >= 0x20 && buf[j] <= 0x3f)
63
98
  j++;
64
99
  if (j < buf.length) {
100
+ if (buf[i + 2] === SGR_MOUSE_INTRO) {
101
+ // SGR mouse: `ESC [ < Cb ; Cx ; Cy M|m`. Only the button code is
102
+ // read — the wheel has no position — and a press is the only edge
103
+ // a wheel has, so `m` (release) never maps to anything.
104
+ let cb = 0;
105
+ for (let k = i + 3; k < j && buf[k] >= 0x30 && buf[k] <= 0x39; k++) {
106
+ cb = cb * 10 + (buf[k] - 0x30);
107
+ }
108
+ const wheel = buf[j] === X10_MOUSE_FINAL ? mouseWheel(cb) : null;
109
+ if (wheel)
110
+ keys.push({ kind: wheel });
111
+ i = j + 1;
112
+ continue;
113
+ }
65
114
  if (buf[j] === CBT) {
66
115
  // Shift+Tab, in BOTH its forms: bare `ESC [ Z`, and the
67
116
  // parameterized `ESC [ 1 ; 2 Z` some terminals send when a modifier
@@ -165,3 +214,17 @@ export function decodeKeys(chunk) {
165
214
  }
166
215
  return keys;
167
216
  }
217
+ /**
218
+ * A mouse button code → the wheel direction it encodes, or null for any other
219
+ * mouse event. Modifier bits (shift 4, meta 8, ctrl 16) are masked off so a
220
+ * wheel with a modifier held still scrolls; the low two bits pick the direction.
221
+ */
222
+ function mouseWheel(button) {
223
+ if ((button & MOUSE_WHEEL_FLAG) === 0)
224
+ return null;
225
+ return (button & 3) === 0
226
+ ? "wheel-up"
227
+ : (button & 3) === 1
228
+ ? "wheel-down"
229
+ : null;
230
+ }
@@ -133,7 +133,14 @@ export const ContextConfigSchema = z
133
133
  * sees — the system prompt and every tool's JSON schema — added to the
134
134
  * measured history before the threshold test so the trigger reflects the
135
135
  * real request size, not just the visible messages. Roughly the size of
136
- * the built system prompt plus the default tool catalogue today.
136
+ * the built system prompt plus the default tool catalogue today (about
137
+ * 3.7k measured for the prompt and 14 tool schemas).
138
+ *
139
+ * An ALLOWANCE, not a measurement — the context panel and `/context` say
140
+ * so. It is the same number on every turn, so it does not track the parts
141
+ * of a request that vary: CRUXY.md, recalled memory, LSP context, web
142
+ * results and MCP tool schemas all make the real request larger without
143
+ * moving it. Raise it if a project carries a lot of those.
137
144
  */
138
145
  reserveTokens: z.number().int().nonnegative().default(4500),
139
146
  /** Most-recent messages always kept verbatim (a floor; the cut rounds up to
@@ -26,7 +26,11 @@ import { fit } from "./layout.js";
26
26
  *
27
27
  * The reserve is shown as its own row rather than folded into a total. It is
28
28
  * part of `used`, it is present in no message, and a breakdown that omitted it
29
- * would leave several thousand tokens looking unexplained.
29
+ * would leave several thousand tokens looking unexplained. It is labelled an
30
+ * ALLOWANCE (cli#4): a fixed config value standing in for the system prompt and
31
+ * tool schemas, not a measurement of them — and the row under it names the two
32
+ * ways the figure is actually inaccurate, so nobody has to discover them by
33
+ * being compacted early.
30
34
  */
31
35
  /** `~34k`, `~900` — the leading tilde is not decoration. See the module note. */
32
36
  function approx(n) {
@@ -68,8 +72,13 @@ export function contextReportLines(report, t, width = Infinity) {
68
72
  }
69
73
  // Named separately because it is real, unavoidable, and in no message — the
70
74
  // one part of the figure a user cannot shrink by pruning the conversation.
71
- lines.push(` ${"reserve".padEnd(13)} ${approx(report.reserveTokens).padStart(7)} ` +
72
- t.muted(" system prompt + tool schemas"));
75
+ // And named as what it is: a fixed allowance, not a count. The two lines
76
+ // after it are the honest footnote — what the allowance does not track, and
77
+ // what the denominator is not.
78
+ lines.push(` ${"allowance".padEnd(13)} ${approx(report.reserveTokens).padStart(7)} ` +
79
+ t.muted(" system prompt + tool schemas (a fixed setting, not measured)"));
80
+ lines.push(t.muted(" the allowance does not grow with CRUXY.md, memory, LSP, web or MCP schemas,"));
81
+ lines.push(t.muted(" and the budget is context.maxTokens — a config heuristic, not a limit read from the served tier"));
73
82
  // ── the biggest single messages ───────────────────────────────────────────
74
83
  if (report.largest.length > 0) {
75
84
  lines.push("");
@@ -8,7 +8,7 @@ import { TtyRenderer } from "./tty-renderer.js";
8
8
  import { TuiRenderer } from "../tui/renderer.js";
9
9
  import { GitStatusCache } from "../tui/git-status.js";
10
10
  import { ToolVersions } from "../tui/tool-versions.js";
11
- import { supportsTui, usesAltScreen } from "../tui/supports.js";
11
+ import { supportsTui, usesAltScreen, usesMouse } from "../tui/supports.js";
12
12
  export { detectCapabilities, detectReducedMotion, resolveColumns, resolveRows, DEFAULT_COLUMNS, DEFAULT_ROWS, } from "./capabilities.js";
13
13
  export { attachResize, processResizeSignal, } from "./resize.js";
14
14
  export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
@@ -58,6 +58,7 @@ export function createRenderer(out = process.stdout, err = process.stderr, env =
58
58
  // for a direct construction and wrong here: an injected env exists
59
59
  // precisely so a caller can describe a terminal that is not this one.
60
60
  altScreen: usesAltScreen(caps, env),
61
+ mouse: usesMouse(caps, env),
61
62
  });
62
63
  }
63
64
  return caps.cursor
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) {
@@ -13,7 +13,8 @@ import { CONVERSATION_VIEW, cycleView, navLines, } from "./views.js";
13
13
  import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
14
14
  import { limitsPanelLines } from "./limits-panel.js";
15
15
  import { installScreenGuard, } from "./restore.js";
16
- import { usesAltScreen } from "./supports.js";
16
+ import { usesAltScreen, usesMouse } from "./supports.js";
17
+ import { logger } from "../utils/logger.js";
17
18
  /**
18
19
  * The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
19
20
  * only one that owns the whole screen rather than a single managed line.
@@ -46,8 +47,31 @@ import { usesAltScreen } from "./supports.js";
46
47
  * `renderInput`): the real cursor parks wherever the last painted row ended,
47
48
  * which is a second, wrong caret blinking somewhere in the frame.
48
49
  */
49
- const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[?25l";
50
- const LEAVE_ALT_SCREEN = "\x1b[?25h\x1b[?1049l";
50
+ export const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[?25l";
51
+ export const LEAVE_ALT_SCREEN = "\x1b[?25h\x1b[?1049l";
52
+ /**
53
+ * Mouse reporting on and off (cli#1): `?1000h` (button events) in the
54
+ * `?1006h` (SGR) encoding, and the inverse in reverse order.
55
+ *
56
+ * Without it the terminal handles the wheel itself, and on the alternate
57
+ * screen that means one of two wrong things: Terminal.app scrolls the WINDOW
58
+ * over a buffer that has no scrollback, and iTerm2 translates each notch into
59
+ * arrow keys, which `app.ts` deliberately leaves inert. With it, a notch
60
+ * arrives as a key the loop can map onto the scroll Page Up already has.
61
+ *
62
+ * WRITTEN IN THE SAME BYTES as the alternate-screen pair — appended to the
63
+ * enter, prepended to the leave — and nowhere else, because of what it costs
64
+ * when left on: a terminal in mouse-reporting mode after the process is gone
65
+ * turns every click into escape bytes at the shell prompt. That pair is what
66
+ * the restore guard replays on a signal, so the mouse is released on exactly
67
+ * the paths the screen is.
68
+ *
69
+ * The known trade is that native text selection needs the terminal's modifier
70
+ * (Shift, or Option in Terminal.app) while the TUI is up — the trade `less` and
71
+ * `vim` make. `CRUXY_NO_MOUSE` opts out of just this (see `usesMouse`).
72
+ */
73
+ export const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
74
+ export const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l";
51
75
  /** Coalescing window for repaints (~30fps). */
52
76
  export const PAINT_INTERVAL_MS = 33;
53
77
  /**
@@ -82,6 +106,19 @@ export const VIEW_PULSE_MS = 500;
82
106
  export const SCROLLBACK_LINES = 1_000;
83
107
  /** Lines a Page Up/Down keeps in common across the jump, so context survives. */
84
108
  export const SCROLL_PAGE_OVERLAP = 2;
109
+ /**
110
+ * Display lines one wheel notch scrolls (cli#1). Three is what most terminals
111
+ * and pagers move per notch; a whole page per notch would make the wheel a
112
+ * coarser Page Up rather than the fine control it is everywhere else.
113
+ */
114
+ export const WHEEL_LINES = 3;
115
+ /**
116
+ * The most rows a view gives up to command output pinned beneath it (cli#7b),
117
+ * as a fraction of the pane. Half: the view stays readable, and `/help` — the
118
+ * longest thing this carries — still shows most of itself, with the notice row
119
+ * saying where the rest is.
120
+ */
121
+ export const NOTICE_ROWS_FRACTION = 0.5;
85
122
  export class TuiRenderer {
86
123
  caps;
87
124
  theme;
@@ -95,6 +132,8 @@ export class TuiRenderer {
95
132
  guard;
96
133
  /** Whether this renderer took the alternate screen and owes the inverse. */
97
134
  altScreen;
135
+ /** Whether it turned mouse reporting on with it, and owes that inverse too. */
136
+ mouse;
98
137
  /** Lines the opening banner occupies — the buffer's contents at construction. */
99
138
  openingLines;
100
139
  /** Logical lines committed since construction, INCLUDING ones rolled off. */
@@ -130,6 +169,15 @@ export class TuiRenderer {
130
169
  views = [];
131
170
  /** Which view owns the main column. Always a real id — see {@link setView}. */
132
171
  selectedView = CONVERSATION_VIEW;
172
+ /**
173
+ * App-authored lines printed while a view other than the conversation owned
174
+ * the main column (cli#7b), pinned under that view until the user moves on.
175
+ * Every one of them is ALSO in the scrollback: this is a second showing, not
176
+ * a second home. Empty whenever the conversation is selected.
177
+ */
178
+ notices = [];
179
+ /** Hands the console back to the logger; see the constructor. */
180
+ releaseLogger = null;
133
181
  /** Whether the sidebar nav holds the keyboard rather than the input line. */
134
182
  sidebarFocused = false;
135
183
  /** Working-tree state for the git panel (P4 track 2); absent → panel unwired. */
@@ -205,8 +253,9 @@ export class TuiRenderer {
205
253
  // The inverse is owed from this line onward, which is why track 1 landed
206
254
  // first: `close()` is not the only way out of this constructor's reach.
207
255
  this.altScreen = opts.altScreen ?? usesAltScreen(caps);
256
+ this.mouse = this.altScreen && (opts.mouse ?? usesMouse(caps));
208
257
  if (this.altScreen)
209
- out.write(ENTER_ALT_SCREEN);
258
+ out.write(ENTER_ALT_SCREEN + (this.mouse ? MOUSE_ON : ""));
210
259
  // ABSOLUTE rows (Q4 track 2). This renderer owns the viewport outright, so
211
260
  // it has no reason to infer where its rows are from where the cursor was
212
261
  // left — and every reason not to. A frame this tall repaints thirty times a
@@ -216,6 +265,16 @@ export class TuiRenderer {
216
265
  this.frame = createFrame((text) => this.out.write(text), caps, {
217
266
  absolute: true,
218
267
  });
268
+ // THE LOGGER WRITES INTO THIS FRAME from here on (cli#1, the second
269
+ // contributor). Both standard streams are this terminal, and a diagnostic
270
+ // written to stderr while the frame owns every row lands inside it: the
271
+ // retention notice at boot did, and a hook's or an MCP server's warning
272
+ // mid-turn does. Routed through `println` they are conversation lines —
273
+ // visible, scrollable, and pinned under a view like any other command
274
+ // output — instead of bytes the next repaint erases. Released with the
275
+ // screen, in `releaseScreen`, so the exit path's own prints reach the
276
+ // normal buffer.
277
+ this.releaseLogger = logger.capture((_channel, text) => this.println(text));
219
278
  // Installed here, not in `close()`'s vicinity, because the window it covers
220
279
  // opens with the first paint: from this line on there is a shell on screen
221
280
  // that only this object knows how to take down.
@@ -414,9 +473,24 @@ export class TuiRenderer {
414
473
  return true;
415
474
  this.selectedView = id;
416
475
  this.scrollOffset = 0;
476
+ // Notices belong to the view they were pinned under. Leaving it — for the
477
+ // conversation, which holds them all anyway, or for another view — drops
478
+ // them, so a "showing git" cannot follow the user onto the tasks pane.
479
+ this.notices = [];
417
480
  this.schedulePaint();
418
481
  return true;
419
482
  }
483
+ /**
484
+ * Drop the command output pinned under the selected view (cli#7b). The app
485
+ * calls this when a line is submitted: the user has moved on, and the next
486
+ * command's output must not stack under the last one's.
487
+ */
488
+ dismissNotices() {
489
+ if (this.closed || this.notices.length === 0)
490
+ return;
491
+ this.notices = [];
492
+ this.schedulePaint();
493
+ }
420
494
  /** Move `steps` around the view ring and select what lands. */
421
495
  cycleView(steps) {
422
496
  const next = cycleView(this.selectedView, this.views, steps);
@@ -561,11 +635,42 @@ export class TuiRenderer {
561
635
  overlayWidth() {
562
636
  return this.viewportWidth();
563
637
  }
564
- /** Append an app-authored line to the conversation (help text, command replies). */
638
+ /**
639
+ * Append an app-authored line to the conversation (help text, command
640
+ * replies, a routed diagnostic).
641
+ *
642
+ * WHILE A VIEW HIDES THE CONVERSATION the line is also pinned under that view
643
+ * (cli#7b). Before this, `/help`, `/view`'s listing, a Tab completion's
644
+ * suggestions and the "showing …" confirmation all went into a buffer the
645
+ * screen was not showing, and the command read as having done nothing. The
646
+ * conversation stays the record; the view keeps the column; the line is
647
+ * simply shown where the user is looking.
648
+ */
565
649
  println(line = "") {
566
650
  if (this.closed)
567
651
  return;
568
652
  this.pushLines([line]);
653
+ if (this.selectedView !== CONVERSATION_VIEW)
654
+ this.notices.push(line);
655
+ this.schedulePaint();
656
+ }
657
+ /**
658
+ * Empty the scrollback (cli#2) — the visible half of `/clear`.
659
+ *
660
+ * `Session.clear` empties the history the model sees and the log records the
661
+ * event, and both were always right. What stayed wrong was the screen: the
662
+ * whole transcript remained above a muted "history cleared", so a reset that
663
+ * had worked read as one that had not. The buffer goes, the partial line
664
+ * goes, the scroll position goes; `committed` stays, because the exit tail's
665
+ * "N earlier lines" counts what was ever shown, and these were.
666
+ */
667
+ clearScrollback() {
668
+ if (this.closed)
669
+ return;
670
+ this.buffer = [];
671
+ this.partial = "";
672
+ this.notices = [];
673
+ this.scrollOffset = 0;
569
674
  this.schedulePaint();
570
675
  }
571
676
  // ── StreamRenderer ────────────────────────────────────────────────────────
@@ -880,12 +985,16 @@ export class TuiRenderer {
880
985
  * handed over.
881
986
  */
882
987
  releaseScreen() {
988
+ // The console comes back first, so anything the exit path logs after this
989
+ // line reaches the terminal rather than a renderer that is closing.
990
+ this.releaseLogger?.();
991
+ this.releaseLogger = null;
883
992
  this.closed = true;
884
993
  this.frame.clear();
885
994
  // The exact inverse of the constructor's pair, and the last bytes this
886
995
  // renderer ever writes to the managed screen.
887
996
  if (this.altScreen)
888
- this.out.write(LEAVE_ALT_SCREEN);
997
+ this.out.write((this.mouse ? MOUSE_OFF : "") + LEAVE_ALT_SCREEN);
889
998
  }
890
999
  /**
891
1000
  * Echo the tail of the conversation into the normal buffer (Q4 track 3).
@@ -1306,10 +1415,21 @@ export class TuiRenderer {
1306
1415
  // A view's lines are reflowed here rather than trusted at `mainCols`: the
1307
1416
  // contract asks a view to lay out to the width it is given, and reflow makes
1308
1417
  // that a courtesy rather than a rule it can break the grid by ignoring.
1418
+ //
1419
+ // Command output pinned under a view (cli#7b) rides the same rule the plan
1420
+ // checklist does under the conversation: appended, so the column's tail
1421
+ // rule keeps it on screen, and capped so it cannot evict the view. When the
1422
+ // cap cuts it, the first pinned row says how much is above and where it
1423
+ // all is — the conversation, one Esc away. Absent while scrolled, like the
1424
+ // plan, so a reader holding history still is not moved by it.
1425
+ const notices = active === undefined || scrolled ? [] : this.noticeBlock(rows, mainCols);
1309
1426
  const body = active !== undefined
1310
- ? active
1311
- .lines(this.theme, mainCols)
1312
- .flatMap((line) => (line === "" ? [""] : reflow(line, mainCols)))
1427
+ ? [
1428
+ ...active
1429
+ .lines(this.theme, mainCols)
1430
+ .flatMap((line) => (line === "" ? [""] : reflow(line, mainCols))),
1431
+ ...(notices.length === 0 ? [] : ["", ...notices]),
1432
+ ]
1313
1433
  : plan.length === 0
1314
1434
  ? wrapped
1315
1435
  : [...wrapped, "", ...plan];
@@ -1337,11 +1457,13 @@ export class TuiRenderer {
1337
1457
  // one because they answer different questions — "where am I" and "what
1338
1458
  // else have I run" — and because the sessions list is destined to become
1339
1459
  // a view of its own, at which point this reduces to the nav.
1340
- sidebar: [
1341
- ...navLines(this.theme, this.views, this.selectedView, this.sidebarFocused),
1342
- "",
1343
- ...sidebarLines(this.theme, this.sessions, this.activeSessionId),
1344
- ],
1460
+ //
1461
+ // Composed to the row budget HERE (cli#7a), the way the rail is: the nav
1462
+ // takes what it needs, the list gets what is left. Handing the layout an
1463
+ // over-long column and letting its tail rule cut it was what removed the
1464
+ // nav heading and the first view rows at 30 rows — and with them the
1465
+ // pointer Ctrl+B was moving.
1466
+ sidebar: this.sidebarBlock(rows),
1345
1467
  main,
1346
1468
  rail: stacked.lines,
1347
1469
  status: this.statusLine(width),
@@ -1349,6 +1471,57 @@ export class TuiRenderer {
1349
1471
  overlay: drawer,
1350
1472
  };
1351
1473
  }
1474
+ /**
1475
+ * The sidebar column, fitted to `rows` (cli#7a): the nav whole, then the
1476
+ * session list in whatever remains, with one blank row between them when
1477
+ * there is room for both. The nav is never cut here — `fitHead` in the
1478
+ * layout trims it only on a terminal too short to hold it at all.
1479
+ */
1480
+ sidebarBlock(rows) {
1481
+ const nav = navLines(this.theme, this.views, this.selectedView, this.sidebarFocused);
1482
+ const remaining = rows - nav.length - 1;
1483
+ if (remaining <= 0)
1484
+ return nav;
1485
+ return [
1486
+ ...nav,
1487
+ "",
1488
+ ...sidebarLines(this.theme, this.sessions, this.activeSessionId, Date.now(), remaining),
1489
+ ];
1490
+ }
1491
+ /**
1492
+ * The pinned command output for the selected view, wrapped at the column
1493
+ * width and capped at {@link NOTICE_ROWS_FRACTION} of the pane. The cap
1494
+ * keeps the NEWEST rows — the most recent command's output is the one just
1495
+ * asked for — and charges its first row to say what was cut.
1496
+ */
1497
+ noticeBlock(rows, cols) {
1498
+ if (this.notices.length === 0)
1499
+ return [];
1500
+ const wrapped = [];
1501
+ for (const line of this.notices) {
1502
+ if (line === "")
1503
+ wrapped.push("");
1504
+ else
1505
+ wrapped.push(...reflow(line, cols));
1506
+ }
1507
+ const cap = Math.max(1, Math.floor(rows * NOTICE_ROWS_FRACTION));
1508
+ if (wrapped.length <= cap)
1509
+ return wrapped;
1510
+ const shown = Math.max(0, cap - 1);
1511
+ const hidden = wrapped.length - shown;
1512
+ return [
1513
+ noticeOverflow(hidden, this.theme),
1514
+ ...wrapped.slice(wrapped.length - shown),
1515
+ ];
1516
+ }
1517
+ }
1518
+ /**
1519
+ * The heading row a pinned-notice block spends when it cannot show every line.
1520
+ * Short enough to survive the narrowest main column (36 at 80 wide with both
1521
+ * side columns up): a marker truncated mid-word would not name the way out.
1522
+ */
1523
+ function noticeOverflow(hidden, theme) {
1524
+ return theme.muted(`${theme.glyph.ellipsis}${hidden} more above${theme.sep}Esc to see all`);
1352
1525
  }
1353
1526
  /** Row-wise equality for overlay content — `null` and `[]` both mean "no drawer". */
1354
1527
  function sameLines(a, b) {
@@ -40,3 +40,18 @@ export function supportsTui(caps) {
40
40
  export function usesAltScreen(caps, env = process.env) {
41
41
  return supportsTui(caps) && !isSet(env.CRUXY_NO_ALT_SCREEN);
42
42
  }
43
+ /**
44
+ * Whether the TUI turns MOUSE REPORTING on (cli#1), so the wheel scrolls the
45
+ * pane instead of the terminal's window.
46
+ *
47
+ * Gated on {@link usesAltScreen}: the mouse is enabled and released in the
48
+ * same byte pair as the alternate screen, and there is no pair to ride without
49
+ * it. `CRUXY_NO_MOUSE` opts out on its own, same set-and-non-empty rule,
50
+ * because reporting has a cost the alternate screen does not: while it is on,
51
+ * native text selection needs the terminal's modifier (Shift, or Option in
52
+ * Terminal.app). Someone who selects text constantly can turn the wheel off
53
+ * without giving up the TUI.
54
+ */
55
+ export function usesMouse(caps, env = process.env) {
56
+ return usesAltScreen(caps, env) && !isSet(env.CRUXY_NO_MOUSE);
57
+ }
@@ -1,3 +1,4 @@
1
+ import { format } from "node:util";
1
2
  import { shouldUseColor } from "../errors/format.js";
2
3
  import { themeForColor } from "../theme/index.js";
3
4
  export const LOG_LEVELS = ["debug", "info", "warn", "error", "silent"];
@@ -12,6 +13,48 @@ class Logger {
12
13
  level = "info";
13
14
  /** Diagnostics go to stderr, so the theme resolves against stderr's color. */
14
15
  theme = themeForColor(shouldUseColor(process.stderr));
16
+ /** The surface that currently owns the terminal, or null for the console. */
17
+ sink = null;
18
+ /**
19
+ * Route every line through `sink` instead of the console, until the returned
20
+ * release is called.
21
+ *
22
+ * This exists for the full-screen TUI. While it is up, the frame owns every
23
+ * row of the terminal and BOTH standard streams point at that same terminal —
24
+ * so a `warn` written to stderr does not go "to the side", it lands inside
25
+ * the frame at wherever the cursor was parked, and the next repaint erases
26
+ * it. The retention notice at boot, a hook's announcement and an MCP server's
27
+ * failure mid-turn all did exactly that. The renderer installs itself here
28
+ * for as long as it holds the screen and hands the console back when it
29
+ * releases it, on the clean path and the signal path alike.
30
+ *
31
+ * The level filter still applies: a captured `debug` is dropped at the
32
+ * default level just as an uncaptured one is. Only the destination changes.
33
+ *
34
+ * The release is a no-op once a later capture has replaced this one, so two
35
+ * surfaces releasing out of order cannot strand the console.
36
+ */
37
+ capture(sink) {
38
+ this.sink = sink;
39
+ return () => {
40
+ if (this.sink === sink)
41
+ this.sink = null;
42
+ };
43
+ }
44
+ /** Whether a surface has captured the streams. */
45
+ captured() {
46
+ return this.sink !== null;
47
+ }
48
+ emit(channel, args) {
49
+ if (this.sink !== null) {
50
+ this.sink(channel, format(...args));
51
+ return;
52
+ }
53
+ if (channel === "print")
54
+ console.log(...args);
55
+ else
56
+ console.error(...args);
57
+ }
15
58
  setLevel(level) {
16
59
  this.level = level;
17
60
  }
@@ -23,23 +66,26 @@ class Logger {
23
66
  }
24
67
  debug(...args) {
25
68
  if (this.enabled("debug"))
26
- console.error(this.theme.muted("debug"), ...args);
69
+ this.emit("debug", [this.theme.muted("debug"), ...args]);
27
70
  }
28
71
  info(...args) {
29
72
  if (this.enabled("info"))
30
- console.error(...args);
73
+ this.emit("info", args);
31
74
  }
32
75
  warn(...args) {
33
76
  if (this.enabled("warn"))
34
- console.error(this.theme.warning("warn"), ...args);
77
+ this.emit("warn", [this.theme.warning("warn"), ...args]);
35
78
  }
36
79
  error(...args) {
37
80
  if (this.enabled("error"))
38
- console.error(this.theme.danger("error"), ...args);
81
+ this.emit("error", [this.theme.danger("error"), ...args]);
39
82
  }
40
- /** Primary user-facing output — always written to stdout. */
83
+ /**
84
+ * Primary user-facing output — stdout, or the capturing surface while one
85
+ * holds the screen (stdout IS that screen).
86
+ */
41
87
  print(...args) {
42
- console.log(...args);
88
+ this.emit("print", args);
43
89
  }
44
90
  }
45
91
  export const logger = new Logger();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {