@cruxy/cli 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The approval prompt as an in-viewport modal (P5 track 2).
3
+ *
4
+ * Before this, the prompt was the one thing in the TUI that could not be drawn:
5
+ * it wrote to stderr, so the renderer's only way to keep those bytes legible was
6
+ * to clear the entire shell for the duration. You answered "may I overwrite
7
+ * this file?" with the diff, the conversation, and the file's own name gone from
8
+ * the screen. The question survived; everything that made it answerable did not.
9
+ *
10
+ * Nothing about the prompt's LOGIC changes here — `promptForApproval` still
11
+ * renders the same bytes, reads the same 4-way choice, and default-denies on
12
+ * anything unrecognized. Only the surface moves: `write` composes overlay rows
13
+ * instead of terminal bytes, and the reads come from the session's key lease
14
+ * rather than a reader of their own.
15
+ *
16
+ * The displaced-state latch is untouched and still lives in the renderer:
17
+ * `beginPrompt` sets `awaiting-approval` (stashing the phase and its clock) and
18
+ * `endPrompt` calls `promptResolved` to put them back. That part was already
19
+ * correct — this only stops the phase from meaning "blank the screen".
20
+ */
21
+ /**
22
+ * Build a {@link PromptIO} that draws into the TUI's overlay drawer.
23
+ *
24
+ * The write model is append-and-recompose, which is what lets the existing
25
+ * prompt code run unmodified: `promptForApproval` emits its block, then a
26
+ * newline, then possibly a follow-up label, treating `write` as a terminal that
27
+ * remembers. Buffering the whole transcript and re-splitting it on every write
28
+ * gives exactly that behaviour without the prompt knowing it is in a modal.
29
+ */
30
+ export function createOverlayPromptIO(surface, lease, color) {
31
+ /** Everything the prompt has written this interaction, verbatim. */
32
+ let transcript = "";
33
+ /** The line being typed into a `readLine` follow-up, if one is open. */
34
+ let typing = null;
35
+ /** This interaction's claim on the shared reader; null while none is open. */
36
+ let keys = null;
37
+ const paint = () => {
38
+ // A trailing "\n" from the prompt means "that line is done", not "add a
39
+ // blank row" — `split` would produce an empty last element and the drawer
40
+ // would grow a dead row under the choices.
41
+ const body = transcript.replace(/\n$/, "");
42
+ const rows = body === "" ? [] : body.split("\n");
43
+ // The caret is drawn, not moved: the frame parks the real cursor after the
44
+ // last painted row so a repaint can erase upward, exactly as the input row
45
+ // does. An invisible cursor in a text field reads as a hang, so the field
46
+ // always shows where the next character lands.
47
+ if (typing !== null) {
48
+ rows[Math.max(0, rows.length - 1)] =
49
+ `${rows[rows.length - 1] ?? ""}${typing}▏`;
50
+ }
51
+ surface.setOverlay(rows);
52
+ };
53
+ return {
54
+ color,
55
+ // The drawer spans the viewport, so the prompt reflows against the width it
56
+ // is actually painted at — not stderr's, which is what it measured when it
57
+ // was writing to stderr.
58
+ get columns() {
59
+ return surface.overlayWidth();
60
+ },
61
+ beginPrompt() {
62
+ transcript = "";
63
+ typing = null;
64
+ // Borrow the session's reader. When the input loop is between lines this
65
+ // is the first claim and enters raw mode; when a modal is opened from
66
+ // inside a read it is a no-op on a terminal already in raw mode.
67
+ keys = lease.handle();
68
+ keys.begin();
69
+ },
70
+ endPrompt() {
71
+ // Take the drawer down and hand the reader back on EVERY path, including
72
+ // a default-deny on EOF. A modal that yields nothing back leaves the
73
+ // conversation permanently short a few rows.
74
+ surface.setOverlay(null);
75
+ transcript = "";
76
+ typing = null;
77
+ keys?.restore();
78
+ keys = null;
79
+ },
80
+ write(text) {
81
+ transcript += text;
82
+ paint();
83
+ },
84
+ async readKey() {
85
+ const key = await read(keys);
86
+ return keyToChar(key);
87
+ },
88
+ /**
89
+ * The follow-up line for `n` (reason) and `t` (instruction), edited in the
90
+ * drawer. Append-only plus backspace: this is a one-line reason field, not
91
+ * the input row, and cursor movement inside it would be the first thing to
92
+ * need a second editor implementation for no user-visible gain.
93
+ *
94
+ * Escape and Ctrl-C resolve "" — which `promptForApproval` reads as "no
95
+ * reason given", collapsing `instruct` to a plain reject. Backing out of the
96
+ * reason field can only ever make the decision MORE conservative.
97
+ */
98
+ async readLine() {
99
+ typing = "";
100
+ paint();
101
+ try {
102
+ for (;;) {
103
+ const key = await read(keys);
104
+ switch (key.kind) {
105
+ case "enter":
106
+ return typing;
107
+ case "eof":
108
+ case "ctrl-c":
109
+ case "escape":
110
+ return "";
111
+ case "backspace":
112
+ if (typing.length > 0)
113
+ typing = typing.slice(0, -1);
114
+ break;
115
+ case "char":
116
+ typing += key.char;
117
+ break;
118
+ default:
119
+ // Arrows and tab: inert rather than leaking a control char into
120
+ // text that is sent verbatim to the agent.
121
+ break;
122
+ }
123
+ paint();
124
+ }
125
+ }
126
+ finally {
127
+ typing = null;
128
+ paint();
129
+ }
130
+ },
131
+ };
132
+ }
133
+ /**
134
+ * Read one key, or EOF when no reader is open.
135
+ *
136
+ * A read outside a `beginPrompt`/`endPrompt` bracket is a wiring bug, and EOF is
137
+ * the only safe answer to it: the prompt maps EOF to default-deny, so the
138
+ * failure mode is a refused action rather than a hang or an approval nobody gave.
139
+ */
140
+ async function read(keys) {
141
+ if (keys === null)
142
+ return { kind: "eof" };
143
+ return keys.read();
144
+ }
145
+ /**
146
+ * The {@link PromptIO.readKey} contract, matching `readSingleKey` exactly: the
147
+ * printable character, `"\n"` for enter, and `""` for everything that means "no
148
+ * answer" — so the prompt's default-deny mapping is identical in the drawer and
149
+ * on a bare terminal.
150
+ */
151
+ function keyToChar(key) {
152
+ switch (key.kind) {
153
+ case "char":
154
+ return key.char;
155
+ case "enter":
156
+ return "\n";
157
+ default:
158
+ return "";
159
+ }
160
+ }
@@ -0,0 +1,48 @@
1
+ import { readContext } from "../agent/context.js";
2
+ /**
3
+ * The rail's context-window reading (P4 track 3) — the memoized, TUI-side half.
4
+ *
5
+ * The measurement itself moved to `agent/context.ts` in P6 track 3, so the
6
+ * panel, `/context` and the compaction seam all read one implementation. What
7
+ * stays here is the only thing that was ever TUI-specific: the memo.
8
+ *
9
+ * `estimateTokens` walks every block of every message, so it is O(history) — a
10
+ * full-window conversation is ~400k characters to scan. The rail repaints at
11
+ * ~30fps, so sampling on the paint path would scan that history thirty times a
12
+ * second to produce a number that only changes when a message is added.
13
+ *
14
+ * So the read and the walk are separated, the same shape as `GitStatusCache`:
15
+ * {@link current} is a field read the paint path may call freely, and
16
+ * {@link sample} does the walk at the seams where the history can actually have
17
+ * changed.
18
+ *
19
+ * WHERE THE NUMBER COMES FROM, and where it deliberately does NOT: see
20
+ * `agent/context.ts`. The short version is that `Session.usage` is the wrong
21
+ * source — it sums input+output across every `send` and never decreases, not
22
+ * even when compaction frees the window it is supposed to describe.
23
+ */
24
+ export { readContext, } from "../agent/context.js";
25
+ /** A memoized context reading. */
26
+ export class ContextGauge {
27
+ source;
28
+ budget;
29
+ value;
30
+ constructor(source, budget) {
31
+ this.source = source;
32
+ this.budget = budget;
33
+ }
34
+ /** The last sampled reading; `undefined` before the first sample. */
35
+ current() {
36
+ return this.value;
37
+ }
38
+ /** Re-measure the history. Cheap enough at a turn or tool boundary, not per frame. */
39
+ sample() {
40
+ try {
41
+ this.value = readContext(this.source(), this.budget);
42
+ }
43
+ catch {
44
+ // A source that throws (a session torn down mid-turn) keeps the previous
45
+ // reading rather than blanking a panel that was correct a moment ago.
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,63 @@
1
+ import { getGitInfoAsync } from "../utils/git.js";
2
+ export class GitStatusCache {
3
+ cwd;
4
+ probe;
5
+ value = undefined;
6
+ /** Set by {@link invalidate}; cleared once a refresh has actually settled. */
7
+ stale = true;
8
+ /** The refresh in flight, so concurrent triggers share one probe. */
9
+ inFlight = null;
10
+ constructor(cwd, probe = getGitInfoAsync) {
11
+ this.cwd = cwd;
12
+ this.probe = probe;
13
+ }
14
+ /**
15
+ * The last known state — a plain field read, safe from the paint path. Never
16
+ * probes, never blocks, and never lies about freshness: a stale value is the
17
+ * previous truth, which is a better answer than a stalled frame.
18
+ */
19
+ current() {
20
+ return this.value;
21
+ }
22
+ /**
23
+ * Mark the working tree as possibly changed. Cheap and idempotent — call it
24
+ * from every seam that could have written a file; the coalescing in
25
+ * {@link refresh} makes over-calling free.
26
+ */
27
+ invalidate() {
28
+ this.stale = true;
29
+ }
30
+ /**
31
+ * Re-probe if stale, and resolve once the value has settled.
32
+ *
33
+ * Concurrent callers share the in-flight probe rather than starting their own:
34
+ * a turn that writes ten files fires ten invalidations, and those must not
35
+ * become ten `git status` runs.
36
+ */
37
+ async refresh() {
38
+ if (!this.stale)
39
+ return;
40
+ if (this.inFlight)
41
+ return this.inFlight;
42
+ // Cleared BEFORE awaiting, so a write landing mid-probe re-marks it and the
43
+ // next refresh runs again. Clearing after would swallow that invalidation
44
+ // and leave the panel showing a tree that has already moved on.
45
+ this.stale = false;
46
+ this.inFlight = this.run();
47
+ try {
48
+ await this.inFlight;
49
+ }
50
+ finally {
51
+ this.inFlight = null;
52
+ }
53
+ }
54
+ async run() {
55
+ try {
56
+ this.value = await this.probe(this.cwd);
57
+ }
58
+ catch {
59
+ // A probe that throws is unknown, not "not a repo" — keep the last known
60
+ // value rather than downgrading the panel on a transient failure.
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,10 @@
1
+ export { budgetColumns, bodyRows, columnOf, composeScreen, droppedForWidth, fitBlock, padTo, railOpen, stackPanels, CHROME_ROWS, CLOSABLE_PANELS, GUTTER, MIN_MAIN_COLS, RAIL_COLS, RAIL_PANELS, SIDEBAR_COLS, } from "./layout.js";
2
+ export { COLUMN_LABELS, PANEL_LABELS, contextPanelLines, gitPanelLines, headerModel, modelPanelLines, mainWelcome, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
3
+ export { ContextGauge, readContext, } from "./context-gauge.js";
4
+ export { ToolVersions, parseVersion, } from "./tool-versions.js";
5
+ export { GitStatusCache, } from "./git-status.js";
6
+ export { TuiRenderer, PAINT_INTERVAL_MS, SCROLLBACK_LINES, } from "./renderer.js";
7
+ export { runTui, renderInput, TUI_COMMANDS } from "./app.js";
8
+ export { openPalette, paletteItems, paletteInsertion, paletteLabel, } from "./palette.js";
9
+ export { canOverlay, createKeyLease, createOverlayFrame, createOverlayIO, } from "./overlay.js";
10
+ export { supportsTui } from "./supports.js";
@@ -0,0 +1,269 @@
1
+ import { fit, visibleWidth } from "../render/layout.js";
2
+ export const RAIL_PANELS = [
3
+ "context",
4
+ "model",
5
+ "git",
6
+ "tools",
7
+ ];
8
+ export const CLOSABLE_PANELS = ["sidebar", ...RAIL_PANELS];
9
+ /** Which column a panel is drawn in — the bridge between the two identities. */
10
+ export function columnOf(panel) {
11
+ return panel === "sidebar" ? "sidebar" : "rail";
12
+ }
13
+ /** True when any rail panel is open, i.e. the rail column should be budgeted. */
14
+ export function railOpen(open) {
15
+ return RAIL_PANELS.some((p) => open.has(p));
16
+ }
17
+ /** Fixed column widths; `main` takes whatever is left. */
18
+ export const SIDEBAR_COLS = 18;
19
+ /**
20
+ * 24, not the 20 P1 shipped.
21
+ *
22
+ * The rail's widest real content is a git branch name, and 20 could not hold
23
+ * one: `feat/p3-test-results` is exactly 20 columns before the marker glyph and
24
+ * change count the git panel has to put beside it. Every line here is hard
25
+ * truncated by {@link padTo}, so the branch — the part that identifies WHERE the
26
+ * work is happening — was the first thing to be cut.
27
+ *
28
+ * The cost is 4 columns off `main`, and it is affordable at the size that
29
+ * matters: at an 80-column terminal `main` goes 40 → 36, still comfortably over
30
+ * {@link MIN_MAIN_COLS}. Below that the rail was already being dropped whole.
31
+ */
32
+ export const RAIL_COLS = 24;
33
+ /** One blank column between adjacent panels. */
34
+ export const GUTTER = 1;
35
+ /** Below this, `main` is too cramped to read and a side column is dropped. */
36
+ export const MIN_MAIN_COLS = 24;
37
+ /** Rows the chrome costs: header + rule + (body) + rule + status + input. */
38
+ export const CHROME_ROWS = 5;
39
+ /** Below this height there is no room for a body; the shell collapses. */
40
+ export const MIN_BODY_ROWS = 1;
41
+ /**
42
+ * Resolve column widths for an inner width, honoring what the user has open.
43
+ * Degrades the same way the status line does (U.12) — shed context, keep the
44
+ * thing that carries meaning:
45
+ *
46
+ * - wide: sidebar + main + rail
47
+ * - medium: drop the rail, keep sidebar + main
48
+ * - narrow: main only
49
+ *
50
+ * A dropped column returns 0 — the caller distinguishes "closed by the user"
51
+ * from "dropped for width" via {@link droppedForWidth}, so the TUI can say so
52
+ * rather than silently losing a panel the user opened.
53
+ */
54
+ export function budgetColumns(width, open) {
55
+ const wantSidebar = open.has("sidebar");
56
+ const wantRail = railOpen(open);
57
+ const gutters = (n) => Math.max(0, n - 1) * GUTTER;
58
+ if (wantSidebar && wantRail) {
59
+ const fixed = SIDEBAR_COLS + RAIL_COLS + gutters(3);
60
+ if (width - fixed >= MIN_MAIN_COLS) {
61
+ return { sidebar: SIDEBAR_COLS, main: width - fixed, rail: RAIL_COLS };
62
+ }
63
+ }
64
+ const single = wantSidebar ? SIDEBAR_COLS : wantRail ? RAIL_COLS : 0;
65
+ if (single > 0) {
66
+ const fixed = single + gutters(2);
67
+ if (width - fixed >= MIN_MAIN_COLS) {
68
+ return {
69
+ sidebar: wantSidebar ? SIDEBAR_COLS : 0,
70
+ main: width - fixed,
71
+ rail: wantSidebar ? 0 : RAIL_COLS,
72
+ };
73
+ }
74
+ }
75
+ return { sidebar: 0, main: Math.max(1, width), rail: 0 };
76
+ }
77
+ /**
78
+ * COLUMNS the user has open but the current width cannot fit — the honest
79
+ * complement to {@link budgetColumns}. The TUI surfaces these rather than
80
+ * letting a column vanish with no explanation.
81
+ *
82
+ * Reported per column, not per panel: width is a column-level budget, so four
83
+ * open rail panels losing their column is one fact, not four. Callers map a
84
+ * panel to its column with {@link columnOf}. The vertical mirror of this is
85
+ * {@link stackPanels}, which reports what a column's HEIGHT could not fit.
86
+ */
87
+ export function droppedForWidth(budget, open) {
88
+ const dropped = [];
89
+ if (open.has("sidebar") && budget.sidebar === 0)
90
+ dropped.push("sidebar");
91
+ if (railOpen(open) && budget.rail === 0)
92
+ dropped.push("rail");
93
+ return dropped;
94
+ }
95
+ /** Rows available to the body at a given terminal height (at least 1). */
96
+ export function bodyRows(height) {
97
+ return Math.max(MIN_BODY_ROWS, height - CHROME_ROWS);
98
+ }
99
+ /**
100
+ * Truncate to exactly `width` visible columns and pad with spaces to fill it.
101
+ * Column joining needs every cell to be the SAME visible width or the grid
102
+ * shears — `fit` alone only guarantees the upper bound.
103
+ */
104
+ export function padTo(text, width) {
105
+ if (width <= 0)
106
+ return "";
107
+ const clipped = fit(text, width);
108
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
109
+ }
110
+ /**
111
+ * Take the LAST `rows` lines of a panel's content and pad the block to exactly
112
+ * that many rows. Last-N (not first-N) because a SCROLLING panel is a tail view:
113
+ * the newest conversation line, the newest session, matter most.
114
+ *
115
+ * That rule is right for `main` and the sidebar and WRONG for the rail, which is
116
+ * a stack of fixed panels rather than a feed — see {@link stackPanels}. Do not
117
+ * reach for this to fit the rail.
118
+ */
119
+ export function fitBlock(lines, rows, width) {
120
+ const tail = lines.length > rows ? lines.slice(lines.length - rows) : lines;
121
+ const out = tail.map((l) => padTo(l, width));
122
+ while (out.length < rows)
123
+ out.push(" ".repeat(Math.max(0, width)));
124
+ return out;
125
+ }
126
+ /** Rows the overflow notice costs when at least one panel is dropped. */
127
+ const OVERFLOW_ROWS = 1;
128
+ /**
129
+ * The overflow notice: how many panels the height could not hold. Kept short
130
+ * enough to survive {@link RAIL_COLS} after truncation.
131
+ */
132
+ function overflowNotice(count, theme) {
133
+ return theme.muted(`${theme.glyph.ellipsis}${count} more panel${count === 1 ? "" : "s"}`);
134
+ }
135
+ /**
136
+ * Stack fixed panels TOP-DOWN into a row budget, reporting what did not fit.
137
+ *
138
+ * This is the vertical mirror of {@link droppedForWidth}, and it exists because
139
+ * {@link fitBlock} is actively wrong here. `fitBlock` keeps the LAST N rows —
140
+ * correct for a feed, but applied to a stack of fixed panels it eats the TOP of
141
+ * the rail first. Below roughly 19 body rows that silently deleted the context
142
+ * panel: no marker, no notice, a panel the user had open simply absent. A panel
143
+ * must never vanish without saying so — the same rule `droppedForWidth` enforces
144
+ * across the other axis.
145
+ *
146
+ * Panels are placed whole or not at all. A half-drawn gauge or a title with its
147
+ * body cut off reads as a rendering bug, and worse, reads as DATA — a context
148
+ * meter clipped to its first row looks like a real (wrong) number. Dropping it
149
+ * and counting it is the honest failure.
150
+ *
151
+ * One blank separator row sits between adjacent panels, and is charged for here
152
+ * rather than baked into each block, so a panel's own height stays its own
153
+ * business.
154
+ */
155
+ export function stackPanels(blocks, rows, theme) {
156
+ if (rows <= 0) {
157
+ return { lines: [], dropped: blocks.map((b) => b.id) };
158
+ }
159
+ // Cost of placing the first `n` blocks: their heights plus the separators
160
+ // between them. Computed against the same rule the placement loop uses, so
161
+ // the "does everything fit" test below cannot drift from the loop.
162
+ const costOf = (n) => blocks
163
+ .slice(0, n)
164
+ .reduce((sum, b, i) => sum + b.lines.length + (i > 0 ? 1 : 0), 0);
165
+ // Everything fits: no notice row, nothing dropped, no budget games.
166
+ const fitsWhole = costOf(blocks.length) <= rows;
167
+ // Otherwise the notice itself needs a row, and it must be paid for BEFORE
168
+ // deciding what fits — reserving it afterwards could push out a panel that
169
+ // the user was just told fit.
170
+ const budget = fitsWhole ? rows : rows - OVERFLOW_ROWS;
171
+ // The longest PREFIX that fits. Not a best-fit pack: skipping a tall panel to
172
+ // squeeze in a shorter one below it would reorder the rail, and the order is
173
+ // meaningful — it is the order the user reads, and the order `/help` lists.
174
+ let placed = 0;
175
+ while (placed < blocks.length && costOf(placed + 1) <= budget)
176
+ placed++;
177
+ const lines = [];
178
+ for (let i = 0; i < placed; i++) {
179
+ if (i > 0)
180
+ lines.push("");
181
+ lines.push(...blocks[i].lines);
182
+ }
183
+ const dropped = blocks.slice(placed).map((b) => b.id);
184
+ if (dropped.length > 0)
185
+ lines.push(overflowNotice(dropped.length, theme));
186
+ return { lines, dropped };
187
+ }
188
+ /**
189
+ * Body rows an overlay may occupy at a given height — its budget, so a caller
190
+ * can fit its own content to the drawer instead of discovering the truncation.
191
+ * Always leaves at least one row to the columns: an overlay never blanks the
192
+ * conversation the way the old full-frame clear did.
193
+ */
194
+ export function overlayRows(height) {
195
+ return Math.max(0, bodyRows(height) - 1);
196
+ }
197
+ /**
198
+ * Fit overlay content to `rows`, keeping the FIRST row and the tail.
199
+ *
200
+ * A safety net, not the fitting strategy: {@link overlayRows} publishes the
201
+ * budget and callers are expected to compose within it. When one does not, this
202
+ * keeps the two rows a modal cannot lose — row 0 (its identity: what is being
203
+ * asked, and at what risk) and the last rows (what the user can press) — rather
204
+ * than the plain tail every feed-shaped block here uses. A drawer is not a feed.
205
+ */
206
+ export function fitOverlay(lines, rows) {
207
+ if (rows <= 0)
208
+ return [];
209
+ if (lines.length <= rows)
210
+ return lines;
211
+ if (rows === 1)
212
+ return [lines[0]];
213
+ return [lines[0], ...lines.slice(lines.length - (rows - 1))];
214
+ }
215
+ /** Compose the header row: left flush, right flush, dropped when it won't fit. */
216
+ function headerRow(vm, width, theme) {
217
+ const left = theme.strong(theme.accent(fit(vm.headerLeft, width)));
218
+ const leftW = visibleWidth(fit(vm.headerLeft, width));
219
+ const room = width - leftW - 2;
220
+ if (room < 8 || vm.headerRight === "")
221
+ return padTo(left, width);
222
+ const right = fit(vm.headerRight, room);
223
+ const gap = width - leftW - visibleWidth(right);
224
+ return left + " ".repeat(Math.max(1, gap)) + theme.muted(right);
225
+ }
226
+ /**
227
+ * Compose the full screen: exactly `height` rows, each at most `width` visible
228
+ * columns. The caller hands these straight to the transient frame.
229
+ *
230
+ * Every row is padded to the full width so a repaint fully overwrites the row
231
+ * beneath it — a short row would leave the previous paint's tail on screen.
232
+ */
233
+ export function composeScreen(vm, width, height, open, theme) {
234
+ const budget = budgetColumns(width, open);
235
+ const rows = bodyRows(height);
236
+ // The rule follows the theme's unicode axis (U.1) like every other glyph —
237
+ // a box-drawing dash would mojibake on a CRUXY_ASCII / dumb terminal.
238
+ const rule = theme.muted((theme.unicode ? "─" : "-").repeat(Math.max(0, width)));
239
+ // An overlay is a drawer: it takes rows from the BOTTOM of the body and the
240
+ // columns render into what is left. The columns keep at least one row, so a
241
+ // modal never blanks the conversation behind it.
242
+ const drawer = fitOverlay(vm.overlay ?? [], overlayRows(height));
243
+ const columnRows = Math.max(1, rows - drawer.length);
244
+ const columns = [];
245
+ if (budget.sidebar > 0)
246
+ columns.push(fitBlock(vm.sidebar, columnRows, budget.sidebar));
247
+ columns.push(fitBlock(vm.main, columnRows, budget.main));
248
+ if (budget.rail > 0)
249
+ columns.push(fitBlock(vm.rail, columnRows, budget.rail));
250
+ const body = [];
251
+ for (let r = 0; r < columnRows; r++) {
252
+ body.push(columns.map((c) => c[r]).join(" ".repeat(GUTTER)));
253
+ }
254
+ // Padded to the full width like every other row: the grid shears otherwise,
255
+ // and a short overlay row would leave stale column bytes to its right.
256
+ for (const line of drawer)
257
+ body.push(padTo(line, width));
258
+ const screen = [
259
+ headerRow(vm, width, theme),
260
+ rule,
261
+ ...body,
262
+ rule,
263
+ padTo(vm.status === "" ? "" : theme.muted(vm.status), width),
264
+ padTo(vm.input, width),
265
+ ];
266
+ // A terminal shorter than the chrome itself: keep the last `height` rows, so
267
+ // the input line — the only row the user can act on — always survives.
268
+ return screen.length > height ? screen.slice(screen.length - height) : screen;
269
+ }
@@ -0,0 +1,105 @@
1
+ import { createKeyReader, } from "../components/input.js";
2
+ /**
3
+ * Build the lease over one real reader.
4
+ *
5
+ * `restore()` is idempotent PER HANDLE, not just in aggregate: a component that
6
+ * calls it twice in a `finally` must not decrement the count twice and yank the
7
+ * terminal out from under the loop that lent it.
8
+ */
9
+ export function createKeyLease(stdin) {
10
+ const reader = createKeyReader(stdin);
11
+ let holders = 0;
12
+ return {
13
+ handle() {
14
+ let mine = false;
15
+ return {
16
+ begin() {
17
+ if (mine)
18
+ return;
19
+ mine = true;
20
+ if (holders++ === 0)
21
+ reader.begin();
22
+ },
23
+ read() {
24
+ return reader.read();
25
+ },
26
+ restore() {
27
+ if (!mine)
28
+ return;
29
+ mine = false;
30
+ if (--holders === 0)
31
+ reader.restore();
32
+ },
33
+ };
34
+ },
35
+ held: () => holders > 0,
36
+ };
37
+ }
38
+ /**
39
+ * A {@link Frame} that paints into the renderer's overlay drawer instead of the
40
+ * terminal. Same contract as `components/frame.ts` — render replaces the
41
+ * region, `clear()` removes it entirely and is idempotent — so a component
42
+ * cannot tell the difference, and the `finally { frame.clear() }` every one of
43
+ * them already has is what takes the drawer back down.
44
+ */
45
+ export function createOverlayFrame(surface) {
46
+ let cleared = false;
47
+ return {
48
+ render(lines) {
49
+ if (cleared)
50
+ return;
51
+ surface.setOverlay(lines);
52
+ },
53
+ clear() {
54
+ if (cleared)
55
+ return;
56
+ cleared = true;
57
+ surface.setOverlay(null);
58
+ },
59
+ };
60
+ }
61
+ /**
62
+ * A {@link ComponentIO} that runs a component inside the TUI viewport: frames
63
+ * become overlay rows, keys come from the shared lease.
64
+ *
65
+ * `caps` is a LIVE view, not a snapshot. The real capabilities object is mutated
66
+ * in place on SIGWINCH, and the drawer's usable width and height are both
67
+ * derived from it, so these have to be getters — a spread would freeze the
68
+ * overlay at the width it was opened with and every row would be fitted to a
69
+ * terminal that no longer exists. (Content composed before a resize is not
70
+ * re-flowed until the component's next repaint; the renderer pads and truncates
71
+ * every row it paints, so the intermediate frame is stale, never corrupt.)
72
+ *
73
+ * `write` is deliberately a sink. Components emit frame bytes through the frame
74
+ * and nothing else; anything that did reach here would be raw ANSI landing in
75
+ * the middle of a managed screen, so dropping it is the honest behaviour rather
76
+ * than forwarding corruption to the terminal.
77
+ */
78
+ export function createOverlayIO(surface, lease) {
79
+ const host = surface.caps;
80
+ const caps = {
81
+ ...host,
82
+ get width() {
83
+ return surface.overlayWidth();
84
+ },
85
+ get height() {
86
+ return surface.overlayRows();
87
+ },
88
+ };
89
+ return {
90
+ caps,
91
+ interactive: host.interactive,
92
+ write: () => { },
93
+ keys: lease.handle(),
94
+ makeFrame: () => createOverlayFrame(surface),
95
+ };
96
+ }
97
+ /**
98
+ * Whether the screen can host a modal at all. A terminal too short for a drawer
99
+ * leaves {@link OverlaySurface.overlayRows} at 0, and a component painting into
100
+ * zero rows would read as a hang — the prompt never appears, the key loop still
101
+ * blocks. Callers check this and fall back to a non-overlay path instead.
102
+ */
103
+ export function canOverlay(surface) {
104
+ return surface.caps.interactive && surface.overlayRows() > 0;
105
+ }