@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 +12 -0
- package/dist/agent/context.js +6 -0
- package/dist/cli/session-commands.js +4 -0
- package/dist/components/input.js +18 -1
- package/dist/components/keys.js +66 -3
- package/dist/config/schema.js +8 -1
- package/dist/render/context-view.js +12 -3
- package/dist/render/index.js +2 -1
- package/dist/tui/app.js +30 -7
- package/dist/tui/approval-overlay.js +4 -1
- package/dist/tui/layout.js +25 -1
- package/dist/tui/panels.js +44 -6
- package/dist/tui/renderer.js +187 -14
- package/dist/tui/supports.js +15 -0
- package/dist/utils/logger.js +52 -6
- package/package.json +1 -1
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.
|
package/dist/agent/context.js
CHANGED
|
@@ -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
|
}
|
package/dist/components/input.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/components/keys.js
CHANGED
|
@@ -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,
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
+
}
|
package/dist/config/schema.js
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
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("");
|
package/dist/render/index.js
CHANGED
|
@@ -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
|
|
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
|
|
287
|
-
// notice names this key
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/tui/layout.js
CHANGED
|
@@ -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(
|
|
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));
|
package/dist/tui/panels.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 [
|
|
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) {
|
package/dist/tui/renderer.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
?
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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) {
|
package/dist/tui/supports.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -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
|
-
|
|
69
|
+
this.emit("debug", [this.theme.muted("debug"), ...args]);
|
|
27
70
|
}
|
|
28
71
|
info(...args) {
|
|
29
72
|
if (this.enabled("info"))
|
|
30
|
-
|
|
73
|
+
this.emit("info", args);
|
|
31
74
|
}
|
|
32
75
|
warn(...args) {
|
|
33
76
|
if (this.enabled("warn"))
|
|
34
|
-
|
|
77
|
+
this.emit("warn", [this.theme.warning("warn"), ...args]);
|
|
35
78
|
}
|
|
36
79
|
error(...args) {
|
|
37
80
|
if (this.enabled("error"))
|
|
38
|
-
|
|
81
|
+
this.emit("error", [this.theme.danger("error"), ...args]);
|
|
39
82
|
}
|
|
40
|
-
/**
|
|
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
|
-
|
|
88
|
+
this.emit("print", args);
|
|
43
89
|
}
|
|
44
90
|
}
|
|
45
91
|
export const logger = new Logger();
|