@cruxy/cli 1.2.1 → 1.4.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 (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -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,108 @@
1
+ import { getGitInfoAsync, getGitTreeAsync, } 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
+ }
64
+ /**
65
+ * The same discipline across every declared root (P7 track 3).
66
+ *
67
+ * The rail names ONE branch because it has one line to do it in; the Overview
68
+ * view reports every root, because writes fan across all of them when
69
+ * checkpoints are on and a primary-only answer would under-report what a turn
70
+ * touched. That is more subprocesses, not a different rule — so this is a fan
71
+ * over {@link GitStatusCache} rather than a second implementation of it, and
72
+ * `current` stays the pure field read the paint path is allowed to call.
73
+ *
74
+ * Probes run CONCURRENTLY. They are independent processes against independent
75
+ * working trees, and serialising them would make a four-root workspace four
76
+ * times slower to settle for no gain.
77
+ */
78
+ export class WorkspaceGitCache {
79
+ caches = new Map();
80
+ /**
81
+ * Probes with {@link getGitTreeAsync} by default, not the rail's cheaper
82
+ * {@link getGitInfoAsync} (P7 track 4): its consumers are the Overview and
83
+ * Git views, and the Git view needs the per-file list. The rail keeps the two
84
+ * -subprocess probe, because one line of branch-and-count has no use for a
85
+ * third.
86
+ */
87
+ constructor(paths, probe = getGitTreeAsync) {
88
+ for (const p of paths)
89
+ this.caches.set(p, new GitStatusCache(p, probe));
90
+ }
91
+ /**
92
+ * Last known state for one root — a field read, safe from the paint path.
93
+ * An unknown path reads as unprobed rather than throwing: the workspace can
94
+ * gain a root (`/add-root`) between this cache being built and a paint.
95
+ */
96
+ current(absPath) {
97
+ return this.caches.get(absPath)?.current();
98
+ }
99
+ /** Mark every root as possibly changed. Cheap and idempotent. */
100
+ invalidate() {
101
+ for (const c of this.caches.values())
102
+ c.invalidate();
103
+ }
104
+ /** Re-probe every stale root, in parallel, and resolve when all have settled. */
105
+ async refresh() {
106
+ await Promise.all([...this.caches.values()].map((c) => c.refresh()));
107
+ }
108
+ }
@@ -0,0 +1,121 @@
1
+ import { formatStat } from "../render/diff.js";
2
+ import { fitMiddle } from "../render/layout.js";
3
+ /** Verb column, fixed width so the paths align into a scannable column. */
4
+ const VERBS = {
5
+ added: "added",
6
+ modified: "modified",
7
+ deleted: "deleted",
8
+ renamed: "renamed",
9
+ untracked: "new",
10
+ conflicted: "conflict",
11
+ };
12
+ const VERB_COLS = 8;
13
+ /**
14
+ * How many files to list per root before summarising the rest.
15
+ *
16
+ * A cap rather than the whole list because a `node_modules` accident produces
17
+ * thousands of untracked paths, and a pane that has to be scrolled past them to
18
+ * reach the next root is worse than one that says how many it held back. The
19
+ * remainder is COUNTED, never silently dropped.
20
+ */
21
+ export const GIT_VIEW_MAX_FILES = 200;
22
+ /** Colour by what happened, so the shape of a change is legible before the path. */
23
+ function styleVerb(status, theme) {
24
+ const verb = VERBS[status].padEnd(VERB_COLS);
25
+ switch (status) {
26
+ case "deleted":
27
+ return theme.danger(verb);
28
+ case "added":
29
+ case "untracked":
30
+ return theme.success(verb);
31
+ case "conflicted":
32
+ return theme.warning(verb);
33
+ default:
34
+ return theme.muted(verb);
35
+ }
36
+ }
37
+ /** `modified src/x.ts +12/-3` — verb, path, then the counts when known. */
38
+ function fileRow(file, theme, cols) {
39
+ const stat = formatStat(file, theme);
40
+ // The path is middle-truncated so the basename — the part that identifies the
41
+ // file — survives a narrow pane, the same rule `render/diff.ts` applies.
42
+ const room = Math.max(8, cols - VERB_COLS - 2 - (stat === "" ? 0 : 9));
43
+ const path = fitMiddle(file.path, room, theme.glyph.ellipsis);
44
+ return `${styleVerb(file.status, theme)} ${path}${stat === "" ? "" : ` ${stat}`}`;
45
+ }
46
+ /** One root's block: its heading, then its files or why there are none. */
47
+ function rootLines(root, theme, cols, multiRoot) {
48
+ const lines = [];
49
+ if (multiRoot) {
50
+ const mark = root.primary ? theme.accent(theme.glyph.pointer) : " ";
51
+ lines.push(`${mark} ${theme.strong(root.name)}`);
52
+ }
53
+ // The tri-state, drawn as three different sentences. "Not probed" and "not a
54
+ // repo" are not the same claim, and neither is "clean" — see `status-view.ts`.
55
+ if (root.state === undefined) {
56
+ lines.push(theme.muted(` checking${theme.glyph.ellipsis}`));
57
+ return lines;
58
+ }
59
+ if (root.state === null) {
60
+ lines.push(theme.muted(" not a git repo"));
61
+ return lines;
62
+ }
63
+ const { branch, files } = root.state;
64
+ lines.push(` ${theme.strong(branch)}`);
65
+ if (files === undefined) {
66
+ // The cheap probe ran, so dirtiness is known but the list was never asked
67
+ // for. Say that rather than render an empty list as "nothing changed".
68
+ lines.push(theme.muted(root.state.changed > 0
69
+ ? ` ${root.state.changed} changed — file list not loaded`
70
+ : " clean"));
71
+ return lines;
72
+ }
73
+ if (files.length === 0) {
74
+ lines.push(theme.success(" clean"));
75
+ return lines;
76
+ }
77
+ for (const file of files.slice(0, GIT_VIEW_MAX_FILES)) {
78
+ lines.push(` ${fileRow(file, theme, Math.max(8, cols - 2))}`);
79
+ }
80
+ const hidden = files.length - GIT_VIEW_MAX_FILES;
81
+ if (hidden > 0) {
82
+ lines.push(theme.muted(` ${theme.glyph.ellipsis}${hidden} more`));
83
+ }
84
+ return lines;
85
+ }
86
+ /** The whole Git view, as lines. Pure: no probe, no disk, no clock. */
87
+ export function gitViewLines(theme, cols, roots) {
88
+ const lines = [theme.heading("git")];
89
+ if (roots.length === 0) {
90
+ lines.push(theme.muted("no declared roots"));
91
+ return lines;
92
+ }
93
+ const multiRoot = roots.length > 1;
94
+ for (const root of roots) {
95
+ lines.push("");
96
+ lines.push(...rootLines(root, theme, cols, multiRoot));
97
+ }
98
+ return lines;
99
+ }
100
+ /**
101
+ * The Git view as a registered source.
102
+ *
103
+ * `roots` is a callback rather than an array so `/add-root` mid-session is
104
+ * reflected without re-registering the view — the workspace is the owner of
105
+ * that list, and copying it here would make this the second place it lives.
106
+ */
107
+ export function createGitView(roots, git) {
108
+ return {
109
+ id: "git",
110
+ label: "git",
111
+ lines: (theme, cols) => gitViewLines(theme, cols, roots().map((r) => ({
112
+ name: r.name,
113
+ primary: r.primary,
114
+ state: git.current(r.absPath),
115
+ }))),
116
+ refresh: async () => {
117
+ git.invalidate();
118
+ await git.refresh();
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,15 @@
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, WorkspaceGitCache, } from "./git-status.js";
6
+ export { createOverviewView } from "./overview.js";
7
+ export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
8
+ export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
9
+ export { createSettingsView, settingsViewLines, formatSettingValue, } from "./settings-view.js";
10
+ export { TuiRenderer, PAINT_INTERVAL_MS, VIEW_PULSE_MS, SCROLL_PAGE_OVERLAP, SCROLLBACK_LINES, } from "./renderer.js";
11
+ export { CONVERSATION_VIEW, cycleView, navLines, viewLabel, viewOrder, } from "./views.js";
12
+ export { runTui, renderInput, TUI_COMMANDS } from "./app.js";
13
+ export { openPalette, paletteItems, paletteInsertion, paletteLabel, } from "./palette.js";
14
+ export { canOverlay, createKeyLease, createOverlayFrame, createOverlayIO, } from "./overlay.js";
15
+ export { supportsTui } from "./supports.js";
@@ -0,0 +1,314 @@
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
+ /**
127
+ * Window `rows` lines out of a block, `offset` display lines up from the end
128
+ * (P7 track 1). `offset === 0` is the tail view {@link fitBlock} gives, and the
129
+ * two agree line-for-line there.
130
+ *
131
+ * The offset is measured from the BOTTOM because that is the edge the user is
132
+ * anchored to: the live tail is the home position, and "how far back am I" is
133
+ * the question both the clamp and the notice need answered. Measuring from the
134
+ * top would make every append renumber the position.
135
+ *
136
+ * Clamping belongs here rather than at the key handler because only this
137
+ * function knows the content: the handler would have to re-derive the wrapped
138
+ * line count to bound a keypress, which is the calculation this already did.
139
+ */
140
+ export function scrollWindow(lines, rows, offset) {
141
+ const visible = Math.max(0, rows);
142
+ // Everything above the last `visible` lines is reachable, and no further: the
143
+ // top of the document is the top of the window, never a screen of blank rows
144
+ // above it.
145
+ const maxOffset = Math.max(0, lines.length - visible);
146
+ const clamped = Math.min(Math.max(0, Math.floor(offset)), maxOffset);
147
+ const end = lines.length - clamped;
148
+ const start = Math.max(0, end - visible);
149
+ return {
150
+ lines: lines.slice(start, end),
151
+ offset: clamped,
152
+ hiddenBelow: clamped,
153
+ hiddenAbove: start,
154
+ };
155
+ }
156
+ /**
157
+ * The scrollback notice: how far back the view is, and how to get out of it.
158
+ *
159
+ * Shown only while scrolled, and it costs a row from the window rather than
160
+ * overlaying one — the same trade {@link stackPanels} makes for its overflow
161
+ * notice, for the same reason: silently hiding a line to report hidden lines
162
+ * would be the one dishonest way to draw this.
163
+ *
164
+ * It names the key because a scrolled view is a mode, and a mode the user
165
+ * cannot see the exit from is a trap — there is no scrollbar here to drag.
166
+ */
167
+ export function scrollNotice(hiddenBelow, theme) {
168
+ const glyph = theme.unicode ? "↓" : "v";
169
+ return theme.warning(`${glyph} ${hiddenBelow} more line${hiddenBelow === 1 ? "" : "s"} below · PgDn / Esc to return`);
170
+ }
171
+ /** Rows the overflow notice costs when at least one panel is dropped. */
172
+ const OVERFLOW_ROWS = 1;
173
+ /**
174
+ * The overflow notice: how many panels the height could not hold. Kept short
175
+ * enough to survive {@link RAIL_COLS} after truncation.
176
+ */
177
+ function overflowNotice(count, theme) {
178
+ return theme.muted(`${theme.glyph.ellipsis}${count} more panel${count === 1 ? "" : "s"}`);
179
+ }
180
+ /**
181
+ * Stack fixed panels TOP-DOWN into a row budget, reporting what did not fit.
182
+ *
183
+ * This is the vertical mirror of {@link droppedForWidth}, and it exists because
184
+ * {@link fitBlock} is actively wrong here. `fitBlock` keeps the LAST N rows —
185
+ * correct for a feed, but applied to a stack of fixed panels it eats the TOP of
186
+ * the rail first. Below roughly 19 body rows that silently deleted the context
187
+ * panel: no marker, no notice, a panel the user had open simply absent. A panel
188
+ * must never vanish without saying so — the same rule `droppedForWidth` enforces
189
+ * across the other axis.
190
+ *
191
+ * Panels are placed whole or not at all. A half-drawn gauge or a title with its
192
+ * body cut off reads as a rendering bug, and worse, reads as DATA — a context
193
+ * meter clipped to its first row looks like a real (wrong) number. Dropping it
194
+ * and counting it is the honest failure.
195
+ *
196
+ * One blank separator row sits between adjacent panels, and is charged for here
197
+ * rather than baked into each block, so a panel's own height stays its own
198
+ * business.
199
+ */
200
+ export function stackPanels(blocks, rows, theme) {
201
+ if (rows <= 0) {
202
+ return { lines: [], dropped: blocks.map((b) => b.id) };
203
+ }
204
+ // Cost of placing the first `n` blocks: their heights plus the separators
205
+ // between them. Computed against the same rule the placement loop uses, so
206
+ // the "does everything fit" test below cannot drift from the loop.
207
+ const costOf = (n) => blocks
208
+ .slice(0, n)
209
+ .reduce((sum, b, i) => sum + b.lines.length + (i > 0 ? 1 : 0), 0);
210
+ // Everything fits: no notice row, nothing dropped, no budget games.
211
+ const fitsWhole = costOf(blocks.length) <= rows;
212
+ // Otherwise the notice itself needs a row, and it must be paid for BEFORE
213
+ // deciding what fits — reserving it afterwards could push out a panel that
214
+ // the user was just told fit.
215
+ const budget = fitsWhole ? rows : rows - OVERFLOW_ROWS;
216
+ // The longest PREFIX that fits. Not a best-fit pack: skipping a tall panel to
217
+ // squeeze in a shorter one below it would reorder the rail, and the order is
218
+ // meaningful — it is the order the user reads, and the order `/help` lists.
219
+ let placed = 0;
220
+ while (placed < blocks.length && costOf(placed + 1) <= budget)
221
+ placed++;
222
+ const lines = [];
223
+ for (let i = 0; i < placed; i++) {
224
+ if (i > 0)
225
+ lines.push("");
226
+ lines.push(...blocks[i].lines);
227
+ }
228
+ const dropped = blocks.slice(placed).map((b) => b.id);
229
+ if (dropped.length > 0)
230
+ lines.push(overflowNotice(dropped.length, theme));
231
+ return { lines, dropped };
232
+ }
233
+ /**
234
+ * Body rows an overlay may occupy at a given height — its budget, so a caller
235
+ * can fit its own content to the drawer instead of discovering the truncation.
236
+ * Always leaves at least one row to the columns: an overlay never blanks the
237
+ * conversation the way the old full-frame clear did.
238
+ */
239
+ export function overlayRows(height) {
240
+ return Math.max(0, bodyRows(height) - 1);
241
+ }
242
+ /**
243
+ * Fit overlay content to `rows`, keeping the FIRST row and the tail.
244
+ *
245
+ * A safety net, not the fitting strategy: {@link overlayRows} publishes the
246
+ * budget and callers are expected to compose within it. When one does not, this
247
+ * keeps the two rows a modal cannot lose — row 0 (its identity: what is being
248
+ * asked, and at what risk) and the last rows (what the user can press) — rather
249
+ * than the plain tail every feed-shaped block here uses. A drawer is not a feed.
250
+ */
251
+ export function fitOverlay(lines, rows) {
252
+ if (rows <= 0)
253
+ return [];
254
+ if (lines.length <= rows)
255
+ return lines;
256
+ if (rows === 1)
257
+ return [lines[0]];
258
+ return [lines[0], ...lines.slice(lines.length - (rows - 1))];
259
+ }
260
+ /** Compose the header row: left flush, right flush, dropped when it won't fit. */
261
+ function headerRow(vm, width, theme) {
262
+ const left = theme.strong(theme.accent(fit(vm.headerLeft, width)));
263
+ const leftW = visibleWidth(fit(vm.headerLeft, width));
264
+ const room = width - leftW - 2;
265
+ if (room < 8 || vm.headerRight === "")
266
+ return padTo(left, width);
267
+ const right = fit(vm.headerRight, room);
268
+ const gap = width - leftW - visibleWidth(right);
269
+ return left + " ".repeat(Math.max(1, gap)) + theme.muted(right);
270
+ }
271
+ /**
272
+ * Compose the full screen: exactly `height` rows, each at most `width` visible
273
+ * columns. The caller hands these straight to the transient frame.
274
+ *
275
+ * Every row is padded to the full width so a repaint fully overwrites the row
276
+ * beneath it — a short row would leave the previous paint's tail on screen.
277
+ */
278
+ export function composeScreen(vm, width, height, open, theme) {
279
+ const budget = budgetColumns(width, open);
280
+ const rows = bodyRows(height);
281
+ // The rule follows the theme's unicode axis (U.1) like every other glyph —
282
+ // a box-drawing dash would mojibake on a CRUXY_ASCII / dumb terminal.
283
+ const rule = theme.muted((theme.unicode ? "─" : "-").repeat(Math.max(0, width)));
284
+ // An overlay is a drawer: it takes rows from the BOTTOM of the body and the
285
+ // columns render into what is left. The columns keep at least one row, so a
286
+ // modal never blanks the conversation behind it.
287
+ const drawer = fitOverlay(vm.overlay ?? [], overlayRows(height));
288
+ const columnRows = Math.max(1, rows - drawer.length);
289
+ const columns = [];
290
+ if (budget.sidebar > 0)
291
+ columns.push(fitBlock(vm.sidebar, columnRows, budget.sidebar));
292
+ columns.push(fitBlock(vm.main, columnRows, budget.main));
293
+ if (budget.rail > 0)
294
+ columns.push(fitBlock(vm.rail, columnRows, budget.rail));
295
+ const body = [];
296
+ for (let r = 0; r < columnRows; r++) {
297
+ body.push(columns.map((c) => c[r]).join(" ".repeat(GUTTER)));
298
+ }
299
+ // Padded to the full width like every other row: the grid shears otherwise,
300
+ // and a short overlay row would leave stale column bytes to its right.
301
+ for (const line of drawer)
302
+ body.push(padTo(line, width));
303
+ const screen = [
304
+ headerRow(vm, width, theme),
305
+ rule,
306
+ ...body,
307
+ rule,
308
+ padTo(vm.status === "" ? "" : theme.muted(vm.status), width),
309
+ padTo(vm.input, width),
310
+ ];
311
+ // A terminal shorter than the chrome itself: keep the last `height` rows, so
312
+ // the input line — the only row the user can act on — always survives.
313
+ return screen.length > height ? screen.slice(screen.length - height) : screen;
314
+ }