@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,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
+ }
@@ -0,0 +1,49 @@
1
+ import { buildSessionStatus } from "../agent/status.js";
2
+ import { sessionStatusLines } from "../render/status-view.js";
3
+ /**
4
+ * The Overview view (P7 track 3) — the first concrete {@link ViewSource}.
5
+ *
6
+ * There is almost nothing here, and that is the result rather than an accident.
7
+ * `sessionStatusLines` already composed this screen for `/status`, and
8
+ * `buildSessionStatus` already assembles what it renders; the only thing that
9
+ * was missing was somewhere for it to LIVE. As a command it printed once into
10
+ * the scrollback and was gone by the next turn — a standing description of the
11
+ * session that could not stand.
12
+ *
13
+ * LIVE VS PER-PAINT, decided per fact rather than per view:
14
+ *
15
+ * - **Per-paint** — turns, mode, model, served tier, context occupancy,
16
+ * sandbox, checkpoints, job counts, tool count. Every one is a field read or
17
+ * a filter over an in-memory array, so recomputing them at 30fps costs
18
+ * nothing measurable and they are never a frame stale.
19
+ * - **Cached, off the paint path** — branch and change count, per root. These
20
+ * cost two subprocesses each, ~45ms warm and worse on Windows, and the pane
21
+ * repaints on every frame and every resize. {@link WorkspaceGitCache} does
22
+ * the probing after a turn; {@link lines} only ever reads the last settled
23
+ * value.
24
+ *
25
+ * The rule the split enforces: nothing in `lines` may spawn, block, or touch
26
+ * the disk. Anything that needs to is a cache read here and a `refresh` there.
27
+ */
28
+ export function createOverviewView(session, git,
29
+ /**
30
+ * The tier the gateway last said served a request — a getter, not a value,
31
+ * because it changes per turn and the view is registered once. Supplied by
32
+ * the renderer, the only object that sees the stream's routing frame.
33
+ */
34
+ servedTier = () => undefined) {
35
+ return {
36
+ id: "overview",
37
+ label: "overview",
38
+ lines: (theme, cols) => sessionStatusLines(buildSessionStatus(session, (absPath) => git.current(absPath), servedTier()), theme, cols),
39
+ /**
40
+ * Re-probe every root after a turn. Invalidate-then-refresh because a turn
41
+ * is exactly when the tree may have moved; the cache coalesces, so the ten
42
+ * writes a turn makes still cost one probe per root.
43
+ */
44
+ refresh: async () => {
45
+ git.invalidate();
46
+ await git.refresh();
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,73 @@
1
+ import { fuzzyFind } from "../components/fuzzy.js";
2
+ import { COMMAND_CATALOG } from "../cli/session-commands.js";
3
+ import { canOverlay, createOverlayIO } from "./overlay.js";
4
+ /** The TUI's own commands, which the shared catalogue deliberately excludes. */
5
+ const PANEL_COMMANDS = [
6
+ {
7
+ name: "/close",
8
+ summary: "hide a panel or the whole rail",
9
+ args: "<sidebar | context | model | git | tools | rail>",
10
+ },
11
+ {
12
+ name: "/open",
13
+ summary: "show a hidden panel",
14
+ args: "<sidebar | context | model | git | tools | rail>",
15
+ },
16
+ ];
17
+ /**
18
+ * Everything the palette offers: the shared catalogue, this shell's panel
19
+ * commands, and the project's own slash commands.
20
+ *
21
+ * Custom commands come LAST and are labelled. `resolveSlash` consults builtins
22
+ * first, so a custom command named `clear` can never shadow `/clear` — listing
23
+ * it above the builtin would show an order the dispatcher does not honour.
24
+ */
25
+ export function paletteItems(slashCommands = []) {
26
+ const builtins = [...COMMAND_CATALOG, ...PANEL_COMMANDS].map((c) => ({
27
+ name: c.name,
28
+ summary: c.summary,
29
+ ...(c.args === undefined ? {} : { args: c.args }),
30
+ }));
31
+ const custom = slashCommands.map((c) => ({
32
+ name: `/${c.name}`,
33
+ summary: c.description,
34
+ custom: true,
35
+ }));
36
+ return [...builtins, ...custom];
37
+ }
38
+ /**
39
+ * The text a picked item puts in the input buffer.
40
+ *
41
+ * A command that takes arguments gets a trailing space, so the cursor lands
42
+ * where the user has to type next rather than flush against the name.
43
+ */
44
+ export function paletteInsertion(item) {
45
+ return item.args === undefined ? item.name : `${item.name} `;
46
+ }
47
+ /** The row a picked item is matched and displayed as. */
48
+ export function paletteLabel(item) {
49
+ const left = item.args === undefined ? item.name : `${item.name} ${item.args}`;
50
+ return `${left} — ${item.summary}${item.custom ? " (project)" : ""}`;
51
+ }
52
+ /**
53
+ * Open the palette. Resolves the text to insert into the input buffer, or null
54
+ * when the user cancelled (Esc / Ctrl-C / EOF) — which must leave the buffer
55
+ * exactly as it was.
56
+ *
57
+ * Returns null immediately on a terminal with no room for a drawer, rather than
58
+ * painting into zero rows: a modal that renders nothing while still consuming
59
+ * every keystroke is indistinguishable from a hang.
60
+ */
61
+ export async function openPalette(renderer, lease, slashCommands = []) {
62
+ if (!canOverlay(renderer))
63
+ return null;
64
+ const items = paletteItems(slashCommands);
65
+ const result = await fuzzyFind(items, {
66
+ toLabel: paletteLabel,
67
+ title: "commands",
68
+ // The drawer's own budget, minus the query row, the title and the key
69
+ // hint `fuzzyFind` draws around the list.
70
+ maxVisible: Math.max(1, renderer.overlayRows() - 3),
71
+ }, createOverlayIO(renderer, lease));
72
+ return result.kind === "selected" ? paletteInsertion(result.value) : null;
73
+ }
@@ -0,0 +1,235 @@
1
+ import { relativeAge, shortId } from "../session/index.js";
2
+ import { RAIL_PANELS, } from "./layout.js";
3
+ /**
4
+ * Panel content.
5
+ *
6
+ * Each builder is a pure `(theme, state) => string[]`, so swapping a
7
+ * placeholder for the real thing is a body change with no reach into layout or
8
+ * paint. Every builder takes its own state explicitly — the shape `sidebarLines`
9
+ * established — rather than reading a shared blob, so a panel can only render
10
+ * what it was actually handed.
11
+ *
12
+ * A panel whose state is absent says so rather than showing invented data: a
13
+ * fabricated token count or branch name is indistinguishable from a broken real
14
+ * one, and the whole rail is a claim about live state.
15
+ */
16
+ /** Human labels for the panels, used by `/close`, `/open`, and `/help`. */
17
+ export const PANEL_LABELS = {
18
+ sidebar: "sidebar",
19
+ context: "context",
20
+ model: "model",
21
+ git: "git",
22
+ tools: "tools",
23
+ };
24
+ /** Human labels for the columns, for messages that are about width. */
25
+ export const COLUMN_LABELS = {
26
+ sidebar: "sidebar",
27
+ main: "main",
28
+ rail: "rail",
29
+ };
30
+ /** Titles drawn at the top of each rail panel. */
31
+ const RAIL_TITLES = {
32
+ context: "context",
33
+ model: "model",
34
+ git: "git",
35
+ tools: "tools",
36
+ };
37
+ /** A panel's title row, styled as a heading. */
38
+ function title(text, theme) {
39
+ return [theme.strong(text), ""];
40
+ }
41
+ /**
42
+ * Left column — the project's saved sessions (P2), newest first.
43
+ *
44
+ * Reads the same `listSessions` the `--resume` picker does, so the sidebar and
45
+ * the picker can never disagree about what exists or in what order. The active
46
+ * session is marked, so "which of these am I in" is answerable at a glance.
47
+ *
48
+ * The column is narrow (18 columns), so each session takes two lines: its short
49
+ * id and age, then its title. The layout truncates per line, which keeps the id
50
+ * — the part you would type into `--resume` — always fully visible.
51
+ */
52
+ export function sidebarLines(theme, sessions = [], activeSessionId, now = Date.now()) {
53
+ const lines = title("sessions", theme);
54
+ if (sessions.length === 0) {
55
+ lines.push(theme.muted("no saved sessions"));
56
+ lines.push(theme.muted("for this project yet."));
57
+ return lines;
58
+ }
59
+ for (const s of sessions) {
60
+ const active = s.sessionId === activeSessionId;
61
+ const mark = active ? theme.accent(theme.glyph.pointer) : " ";
62
+ const head = `${mark} ${shortId(s.sessionId)} ${relativeAge(s.updatedAt, now)}`;
63
+ lines.push(active ? theme.strong(head) : head);
64
+ lines.push(theme.muted(` ${s.title}`));
65
+ }
66
+ return lines;
67
+ }
68
+ /** A panel with no state yet: says so, rather than inventing a plausible value. */
69
+ function pending(theme) {
70
+ return [theme.muted("not wired yet")];
71
+ }
72
+ /** One rail panel: its title, then whatever its own track supplied. */
73
+ function railPanel(id, theme, lines) {
74
+ const body = lines !== undefined && lines.length > 0 ? [...lines] : pending(theme);
75
+ return { id, lines: [theme.strong(RAIL_TITLES[id]), ...body] };
76
+ }
77
+ /**
78
+ * Right column — the four panels, in draw order, for the ones the user has open.
79
+ *
80
+ * Returns BLOCKS rather than a flat line array: the rail is a stack of fixed
81
+ * panels, and the caller has to be able to drop whole panels (and say how many)
82
+ * when the terminal is too short. Flattening here would throw away exactly the
83
+ * boundary that makes honest overflow reporting possible — see `stackPanels`.
84
+ */
85
+ export function railBlocks(theme, state = {}, open = new Set(RAIL_PANELS)) {
86
+ return RAIL_PANELS.filter((id) => open.has(id)).map((id) => railPanel(id, theme, state[id]));
87
+ }
88
+ /**
89
+ * The git panel's body (P4 track 2): branch, then clean-or-changed.
90
+ *
91
+ * Two lines rather than one because they cannot share a row honestly at
92
+ * {@link RAIL_COLS}: a branch alone can use the full width, and pairing it with
93
+ * a count would truncate whichever came second. Branch first — it says WHERE the
94
+ * work is happening, which is the question the panel exists to answer.
95
+ *
96
+ * The dirty state is a WORD, not a colour or a glyph: colour is unavailable
97
+ * under NO_COLOR and a bare marker is meaningless to a screen reader, so
98
+ * "clean" / "3 changed" carries the meaning and the styling only reinforces it.
99
+ *
100
+ * `undefined` (not probed yet) and `null` (not a repo) are different facts and
101
+ * are said differently — a repo whose probe has not landed must not read as
102
+ * "not a repo".
103
+ */
104
+ export function gitPanelLines(theme, state) {
105
+ if (state === undefined)
106
+ return [theme.muted(`checking${theme.glyph.ellipsis}`)];
107
+ if (state === null)
108
+ return [theme.muted("not a git repo")];
109
+ const { branch, dirty, changed } = state;
110
+ return [
111
+ theme.strong(branch),
112
+ dirty ? theme.warning(`${changed} changed`) : theme.success(`clean`),
113
+ ];
114
+ }
115
+ /**
116
+ * How the served tier was chosen, in words. The wire values (`explicit` /
117
+ * `auto` / `auto_degraded`) are not shown raw — `auto_degraded` in particular
118
+ * is the one line here a user must be able to act on, and it has to say that a
119
+ * budget downgrade happened rather than leave them to decode an enum.
120
+ */
121
+ function describeRoutingMode(mode) {
122
+ switch (mode) {
123
+ case "explicit":
124
+ return "as configured";
125
+ case "auto":
126
+ return "auto-routed";
127
+ case "auto_degraded":
128
+ return "downgraded (budget)";
129
+ default:
130
+ // A mode the gateway did not send, or one added after this build. The
131
+ // tier is still real; only the explanation is missing.
132
+ return "served";
133
+ }
134
+ }
135
+ /**
136
+ * The model panel's body (P4 track 4): which tier is actually running, and why.
137
+ *
138
+ * BEFORE THE FIRST TURN there is no served tier — the gateway has not answered
139
+ * — so the panel shows the CONFIGURED value and says it is unresolved. Blank
140
+ * would be worse than useless here: `auto` is the default, and a user looking
141
+ * at an empty model panel cannot tell configuration from breakage.
142
+ */
143
+ export function modelPanelLines(theme, state) {
144
+ const { configured, served } = state;
145
+ if (served === undefined) {
146
+ return [theme.strong(configured), theme.muted("not resolved yet")];
147
+ }
148
+ return [
149
+ theme.strong(served.tier),
150
+ // A budget downgrade is the one case the user may need to act on, so it
151
+ // reads as a warning; everything else is ordinary reporting.
152
+ served.mode === "auto_degraded"
153
+ ? theme.warning(describeRoutingMode(served.mode))
154
+ : theme.muted(describeRoutingMode(served.mode)),
155
+ ];
156
+ }
157
+ /**
158
+ * The TUI header's right side: provider, what was configured, and — once the
159
+ * gateway has answered with something different — what actually ran.
160
+ *
161
+ * `cruxy/auto` is what P1 shipped and it never changed for the life of the
162
+ * process, so a run routed to `kavi` still read `auto` forever. Showing both
163
+ * sides of an `auto → kavi` resolution keeps the configured value visible
164
+ * (it is what the user set) while making the real one legible.
165
+ */
166
+ export function headerModel(theme, provider, state) {
167
+ const { configured, served } = state;
168
+ if (served === undefined || served.tier === configured) {
169
+ return `${provider}/${served?.tier ?? configured}`;
170
+ }
171
+ return `${provider}/${configured} ${theme.glyph.arrow} ${served.tier}`;
172
+ }
173
+ /**
174
+ * The tools panel's body (P4 track 5): one row per tool, each independent.
175
+ *
176
+ * A row is one of three things and they are three different claims: a version
177
+ * (probed and found), "…" (still probing), or "—" (probed and absent). Merging
178
+ * the last two would tell a user their toolchain is missing while the probe is
179
+ * still running, which is the one wrong answer this panel can give.
180
+ *
181
+ * Rows fill in as their own probes land, so a slow pnpm never holds back git.
182
+ */
183
+ export function toolsPanelLines(theme, rows) {
184
+ return rows.map((row) => {
185
+ if (row.version === undefined) {
186
+ return theme.muted(`${row.name} ${theme.glyph.ellipsis}`);
187
+ }
188
+ if (row.version === null) {
189
+ // Words, not a dash. "git —" is ambiguous to anyone — it could as easily
190
+ // read as "no version reported" as "absent" — and it carries nothing at
191
+ // all to a screen reader. "not found" fits the 24-column rail anyway.
192
+ return theme.muted(`${row.name} not found`);
193
+ }
194
+ return `${theme.strong(row.name)} ${theme.muted(row.version)}`;
195
+ });
196
+ }
197
+ /** Compact token count for the narrow rail: 34_512 → "34k", 900 → "900". */
198
+ function shortTokens(n) {
199
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`;
200
+ }
201
+ /**
202
+ * The context panel's body (P4 track 3).
203
+ *
204
+ * THE WORDING IS THE FEATURE. Both numbers are estimates — a chars/4 heuristic
205
+ * over a budget that is a local config default, not the served model's real
206
+ * window — so the panel is written to claim exactly that much and no more:
207
+ *
208
+ * - "~" on the figure, because the numerator is a heuristic;
209
+ * - "budget", never "window", because the denominator is a setting in this
210
+ * CLI's config. Under `model: "auto"` the served tier varies per request and
211
+ * the real window varies with it; calling 100k "the window" would assert
212
+ * something no request has to honour;
213
+ * - no progress bar. A filled bar reads as a measurement, and this is not one.
214
+ * Two plain numbers can be wrong without also looking precise.
215
+ *
216
+ * The compaction threshold is shown because it is the only actionable thing
217
+ * here: it says when the CLI will start folding history away.
218
+ */
219
+ export function contextPanelLines(theme, reading) {
220
+ if (reading === undefined) {
221
+ return [theme.muted(`measuring${theme.glyph.ellipsis}`)];
222
+ }
223
+ const { used, total, compactAt } = reading;
224
+ const figure = `~${shortTokens(used)} / ${shortTokens(total)} budget`;
225
+ // Past the threshold the next turn compacts, which is worth flagging — but as
226
+ // a statement of what happens next, not as an alarm about a guessed number.
227
+ // Compared on `used`, not the clamped fraction: the clamp is for display, and
228
+ // a history that has overrun the budget must not compare as merely "at" it.
229
+ const style = used >= compactAt ? theme.warning : theme.strong;
230
+ return [style(figure), theme.muted(`compacts at ${shortTokens(compactAt)}`)];
231
+ }
232
+ /** The opening lines of the main column, before any turn has run. */
233
+ export function mainWelcome(theme, hint) {
234
+ return [theme.muted(hint), ""];
235
+ }