@cruxy/cli 1.3.0 → 1.5.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.
@@ -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
+ }
package/dist/tui/index.js CHANGED
@@ -2,8 +2,14 @@ export { budgetColumns, bodyRows, columnOf, composeScreen, droppedForWidth, fitB
2
2
  export { COLUMN_LABELS, PANEL_LABELS, contextPanelLines, gitPanelLines, headerModel, modelPanelLines, mainWelcome, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
3
3
  export { ContextGauge, readContext, } from "./context-gauge.js";
4
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";
5
+ export { GitStatusCache, WorkspaceGitCache, } from "./git-status.js";
6
+ export { WorkspaceDiskCache } from "./disk-status.js";
7
+ export { createOverviewView } from "./overview.js";
8
+ export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
9
+ export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
10
+ export { createSettingsView, settingsViewLines, formatSettingValue, } from "./settings-view.js";
11
+ export { TuiRenderer, PAINT_INTERVAL_MS, VIEW_PULSE_MS, SCROLL_PAGE_OVERLAP, SCROLLBACK_LINES, } from "./renderer.js";
12
+ export { CONVERSATION_VIEW, cycleView, navLines, viewLabel, viewOrder, } from "./views.js";
7
13
  export { runTui, renderInput, TUI_COMMANDS } from "./app.js";
8
14
  export { openPalette, paletteItems, paletteInsertion, paletteLabel, } from "./palette.js";
9
15
  export { canOverlay, createKeyLease, createOverlayFrame, createOverlayIO, } from "./overlay.js";
@@ -1,6 +1,7 @@
1
1
  import { fit, visibleWidth } from "../render/layout.js";
2
2
  export const RAIL_PANELS = [
3
3
  "context",
4
+ "limits",
4
5
  "model",
5
6
  "git",
6
7
  "tools",
@@ -123,6 +124,51 @@ export function fitBlock(lines, rows, width) {
123
124
  out.push(" ".repeat(Math.max(0, width)));
124
125
  return out;
125
126
  }
127
+ /**
128
+ * Window `rows` lines out of a block, `offset` display lines up from the end
129
+ * (P7 track 1). `offset === 0` is the tail view {@link fitBlock} gives, and the
130
+ * two agree line-for-line there.
131
+ *
132
+ * The offset is measured from the BOTTOM because that is the edge the user is
133
+ * anchored to: the live tail is the home position, and "how far back am I" is
134
+ * the question both the clamp and the notice need answered. Measuring from the
135
+ * top would make every append renumber the position.
136
+ *
137
+ * Clamping belongs here rather than at the key handler because only this
138
+ * function knows the content: the handler would have to re-derive the wrapped
139
+ * line count to bound a keypress, which is the calculation this already did.
140
+ */
141
+ export function scrollWindow(lines, rows, offset) {
142
+ const visible = Math.max(0, rows);
143
+ // Everything above the last `visible` lines is reachable, and no further: the
144
+ // top of the document is the top of the window, never a screen of blank rows
145
+ // above it.
146
+ const maxOffset = Math.max(0, lines.length - visible);
147
+ const clamped = Math.min(Math.max(0, Math.floor(offset)), maxOffset);
148
+ const end = lines.length - clamped;
149
+ const start = Math.max(0, end - visible);
150
+ return {
151
+ lines: lines.slice(start, end),
152
+ offset: clamped,
153
+ hiddenBelow: clamped,
154
+ hiddenAbove: start,
155
+ };
156
+ }
157
+ /**
158
+ * The scrollback notice: how far back the view is, and how to get out of it.
159
+ *
160
+ * Shown only while scrolled, and it costs a row from the window rather than
161
+ * overlaying one — the same trade {@link stackPanels} makes for its overflow
162
+ * notice, for the same reason: silently hiding a line to report hidden lines
163
+ * would be the one dishonest way to draw this.
164
+ *
165
+ * It names the key because a scrolled view is a mode, and a mode the user
166
+ * cannot see the exit from is a trap — there is no scrollbar here to drag.
167
+ */
168
+ export function scrollNotice(hiddenBelow, theme) {
169
+ const glyph = theme.unicode ? "↓" : "v";
170
+ return theme.warning(`${glyph} ${hiddenBelow} more line${hiddenBelow === 1 ? "" : "s"} below · PgDn / Esc to return`);
171
+ }
126
172
  /** Rows the overflow notice costs when at least one panel is dropped. */
127
173
  const OVERFLOW_ROWS = 1;
128
174
  /**
@@ -0,0 +1,255 @@
1
+ import { bindingWindow, usedFraction } from "../limits/index.js";
2
+ /**
3
+ * The limits panel (P9): what this credential may spend, and how much is left.
4
+ *
5
+ * THE PANEL IS DRIVEN BY THE BUCKET, NEVER BY THE LOGIN. Which numbers exist —
6
+ * and whether a fraction exists at all — is decided by the billing bucket the
7
+ * gateway reports, because that is the only thing that actually decides it. The
8
+ * inference this replaces (a CLI login is an apikey, so it is metered and
9
+ * ungated) was true when it was written and silently stopped being true when the
10
+ * gateway changed what a login mints (cruxy-ai/api#174), with nothing locally
11
+ * observable changing. So there are four bodies here, one per shape, and the
12
+ * type system hands each of them only the numbers that shape may state.
13
+ *
14
+ * AND THIS ONE GETS A BAR. The context panel refuses one under a locking test,
15
+ * for reasons that are exactly right there: its numerator is a chars/4 estimate
16
+ * and its denominator a local config default, so a filled bar would render a
17
+ * guess as a measurement. Every number here is the opposite — server-authored,
18
+ * in the meter's own unit, peeked from the same buckets the gate enforces on, so
19
+ * the fraction the bar fills IS the fraction the next request is judged against.
20
+ * A bar is the right claim for a real measurement, and only for one.
21
+ *
22
+ * The corollary is the rule the rest of this file exists to keep: NO CAP, NO
23
+ * FRACTION. An enterprise pool reports `{enforced: false}` and a developer key
24
+ * reports `metered: true`; neither has an allowance, so neither gets a bar, a
25
+ * percentage, or an X-of-Y — not even a reassuring full one. Spend caps are not
26
+ * a substitute, because they appear only when someone set one.
27
+ */
28
+ /** Cells in the bar. Sized so `bar + " " + "100%"` clears {@link RAIL_COLS}. */
29
+ const BAR_CELLS = 12;
30
+ /**
31
+ * A proportion bar. Empty under the screen-reader glyph table (both cells are
32
+ * ""), which is deliberate — a run of blocks announces as nothing, and the
33
+ * percentage that follows it carries the entire meaning.
34
+ */
35
+ export function bar(theme, fraction, cells = BAR_CELLS) {
36
+ const filled = Math.round(Math.min(1, Math.max(0, fraction)) * cells);
37
+ return (theme.glyph.barFilled.repeat(filled) +
38
+ theme.glyph.barEmpty.repeat(cells - filled));
39
+ }
40
+ /**
41
+ * The styler for a window's state. `low` and `blocked` are the two the user can
42
+ * act on, and they are different acts — one is "wrap up", the other is "you are
43
+ * already being refused" — so they are not merged into one warning.
44
+ */
45
+ function stateStyle(theme, state) {
46
+ if (state === "blocked")
47
+ return theme.danger;
48
+ if (state === "low")
49
+ return theme.warning;
50
+ return theme.strong;
51
+ }
52
+ /** 14_000_000 → "14M", 8_700_000 → "8.7M", 125_000 → "125k", 900 → "900". */
53
+ export function compactTokens(n) {
54
+ const abs = Math.abs(n);
55
+ if (abs >= 1_000_000)
56
+ return `${trimZero(n / 1_000_000)}M`;
57
+ if (abs >= 1_000)
58
+ return `${trimZero(n / 1_000)}k`;
59
+ return `${Math.round(n)}`;
60
+ }
61
+ /** One decimal, but only when it says something: 8.7 stays, 14.0 becomes 14. */
62
+ function trimZero(n) {
63
+ const one = n.toFixed(1);
64
+ return one.endsWith(".0") ? one.slice(0, -2) : one;
65
+ }
66
+ /** 27.77 → "$27.77", 29 → "$29", 0.5 → "$0.50". */
67
+ export function usd(n) {
68
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
69
+ }
70
+ /**
71
+ * How long until an ISO instant, compactly: "18d", "4h", "12m", "now".
72
+ * `undefined` for an absent or unparseable one — a reset we cannot state is
73
+ * simply not stated, never guessed at.
74
+ */
75
+ export function untilLabel(iso, now) {
76
+ if (!iso)
77
+ return undefined;
78
+ const at = Date.parse(iso);
79
+ if (Number.isNaN(at))
80
+ return undefined;
81
+ const ms = at - now;
82
+ if (ms <= 0)
83
+ return "now";
84
+ const minutes = Math.floor(ms / 60_000);
85
+ if (minutes < 60)
86
+ return `${Math.max(1, minutes)}m`;
87
+ const hours = Math.floor(minutes / 60);
88
+ if (hours < 24)
89
+ return `${hours}h`;
90
+ return `${Math.floor(hours / 24)}d`;
91
+ }
92
+ /** A percentage for display: 0.618 → "62%". */
93
+ function pct(fraction) {
94
+ return `${Math.round(fraction * 100)}%`;
95
+ }
96
+ /**
97
+ * Beyond this, the reading is called out as old rather than presented as
98
+ * current. The pool is shared with every other surface on the account, so an
99
+ * idle CLI's figures can go stale without this process doing anything at all —
100
+ * which is precisely the case a timestamp has to cover.
101
+ */
102
+ const STALE_AFTER_MS = 5 * 60_000;
103
+ /**
104
+ * The subscription pool: a bar on the BINDING window, the same one the gate
105
+ * decides against.
106
+ *
107
+ * Drawing the month instead — the bigger, friendlier number — would routinely
108
+ * show a comfortable 6% while the trailing-12h burst, a quarter of the month's
109
+ * cap on every self-serve tier, is the window actually about to refuse the next
110
+ * request. The bar is the thing that stops you, and the line under it names
111
+ * which window that is so the figure is never ambiguous.
112
+ */
113
+ function poolLines(theme, pool, now) {
114
+ const binding = bindingWindow(pool.monthly, pool.burst);
115
+ // Unreachable via `reduceLimits` (a pool with no readable window reduces to
116
+ // `unknown`), but the type permits it and inventing a bar is the one thing
117
+ // this panel must never do on the way to being defensive.
118
+ if (!binding)
119
+ return [theme.muted("figures not reported")];
120
+ const { window: w, name } = binding;
121
+ const fraction = usedFraction(w) ?? 0;
122
+ const style = stateStyle(theme, w.state ?? pool.state);
123
+ const lines = [
124
+ `${style(bar(theme, fraction))} ${style(pct(fraction))}`,
125
+ `${compactTokens(w.used)} / ${compactTokens(w.cap)} ${name}`,
126
+ ];
127
+ // The window that is NOT binding, so both dimensions are visible — a user
128
+ // whose burst is tight still needs to know the month is nearly gone too.
129
+ const other = name === "burst" ? pool.monthly : pool.burst;
130
+ const otherName = name === "burst" ? "month" : "burst";
131
+ const until = untilLabel(w.resetsAt, now);
132
+ if (other) {
133
+ const otherPct = pct(usedFraction(other) ?? 0);
134
+ lines.push(theme.muted(until
135
+ ? `${otherName} ${otherPct} ${theme.sep}${until}`
136
+ : `${otherName} ${otherPct}`));
137
+ }
138
+ else if (until) {
139
+ lines.push(theme.muted(`resets ${until}`));
140
+ }
141
+ // Only when it is actionable. A blocked model list is the answer to "why did
142
+ // that request fail"; mira's remaining requests are the answer to "what can I
143
+ // still run" — and neither question is being asked while the pool is healthy.
144
+ if (pool.blockedModels.length > 0) {
145
+ lines.push(theme.danger(`no ${pool.blockedModels.join(" ")}`));
146
+ }
147
+ else if (pool.state === "low" && pool.mira) {
148
+ lines.push(theme.muted(`mira ${compactTokens(pool.mira.remaining)} left`));
149
+ }
150
+ return lines;
151
+ }
152
+ /** A spend cap line. Text, never a bar: the bar belongs to the allowance. */
153
+ function spendCapLine(theme, label, cap) {
154
+ return theme.muted(`${label} ${usd(cap.remaining)} of ${usd(cap.cap)}`);
155
+ }
156
+ /** How long ago, compactly: the mirror of {@link untilLabel}. */
157
+ function agoLabel(ms) {
158
+ const minutes = Math.floor(ms / 60_000);
159
+ if (minutes < 60)
160
+ return `${Math.max(1, minutes)}m`;
161
+ const hours = Math.floor(minutes / 60);
162
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
163
+ }
164
+ /** The body for a ready reading, chosen by the budget shape. */
165
+ function readingLines(theme, reading, now) {
166
+ const lines = [];
167
+ const budget = reading.budget;
168
+ switch (budget.kind) {
169
+ case "pool":
170
+ lines.push(...poolLines(theme, budget, now));
171
+ break;
172
+ // Enterprise. NO fraction, and no consolation bar drawn at 0% either: a full
173
+ // green bar and "no limit" are read the same way at a glance, and only one
174
+ // of them is true.
175
+ case "unenforced":
176
+ lines.push(theme.strong(reading.tier));
177
+ lines.push(theme.muted("pool not enforced"));
178
+ break;
179
+ // A developer key: billed per token, gated by nothing it draws down. Saying
180
+ // "no pool" explicitly matters here — an empty budget section would read as
181
+ // a figure that failed to load rather than as a figure that does not exist.
182
+ case "metered":
183
+ lines.push(`${theme.strong(reading.tier)}${theme.muted(" metered")}`);
184
+ lines.push(theme.muted("no pool cap"));
185
+ break;
186
+ case "credits": {
187
+ const fraction = budget.granted > 0
188
+ ? Math.min(1, Math.max(0, budget.used / budget.granted))
189
+ : undefined;
190
+ if (fraction !== undefined) {
191
+ const style = fraction >= 0.9 ? theme.warning : theme.strong;
192
+ lines.push(`${style(bar(theme, fraction))} ${style(pct(fraction))}`);
193
+ }
194
+ lines.push(`${usd(budget.remaining)} of ${usd(budget.granted)}`);
195
+ const until = untilLabel(budget.resetsAt, now);
196
+ if (until)
197
+ lines.push(theme.muted(`resets ${until}`));
198
+ break;
199
+ }
200
+ // The bucket, or the pool inside it, is one this build cannot read. Both
201
+ // facts it DOES have are real and server-resolved, so both are shown; what
202
+ // it does not have, it declines to imply.
203
+ case "unknown":
204
+ lines.push(`${theme.strong(reading.tier)}${theme.muted(` ${theme.glyph.sep} ${reading.bucket}`)}`);
205
+ lines.push(theme.muted("budget not reported"));
206
+ break;
207
+ }
208
+ // Rate and spend caps hang off every bucket, so they are appended once here
209
+ // rather than repeated per branch. They are the ONLY ceilings a metered key
210
+ // has — and they appear only when they exist, which is why they can never
211
+ // stand in for the pool a metered key does not have.
212
+ if (budget.kind === "metered" && reading.chat) {
213
+ const { remaining, limit } = reading.chat.perKey;
214
+ lines.push(theme.muted(`${remaining} / ${limit} rpm`));
215
+ }
216
+ if (reading.keySpendCap) {
217
+ lines.push(spendCapLine(theme, "key", reading.keySpendCap));
218
+ }
219
+ if (reading.workspaceSpendCap) {
220
+ lines.push(spendCapLine(theme, "ws", reading.workspaceSpendCap));
221
+ }
222
+ // The cache keeps the last good reading through a failed refresh, so the panel
223
+ // owes the user the age of what it is showing rather than the impression that
224
+ // it is live.
225
+ const age = now - reading.readAt;
226
+ if (age > STALE_AFTER_MS) {
227
+ lines.push(theme.muted(`as of ${agoLabel(age)} ago`));
228
+ }
229
+ return lines;
230
+ }
231
+ /**
232
+ * The limits panel's body.
233
+ *
234
+ * `pending` and each error reason are said differently, on the tri-state
235
+ * discipline the git panel established: "still asking" must not read as "no
236
+ * limits", and neither may read as "your key is bad".
237
+ */
238
+ export function limitsPanelLines(theme, state, now = Date.now()) {
239
+ if (state === undefined || state.status === "pending") {
240
+ return [theme.muted(`checking${theme.glyph.ellipsis}`)];
241
+ }
242
+ if (state.status === "error") {
243
+ switch (state.reason) {
244
+ case "unauthenticated":
245
+ return [theme.muted("not signed in"), theme.muted("run cruxy login")];
246
+ case "unsupported":
247
+ // The gateway answered — it simply has no limits to report. Sending this
248
+ // user to debug their network would be the wrong errand entirely.
249
+ return [theme.muted("gateway reports"), theme.muted("no limits")];
250
+ case "unreachable":
251
+ return [theme.muted("gateway unreachable")];
252
+ }
253
+ }
254
+ return readingLines(theme, state.reading, now);
255
+ }
@@ -0,0 +1,61 @@
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. Free space joins them: microseconds on a local disk, and an
24
+ * unbounded kernel block on a network mount that has stopped answering.
25
+ *
26
+ * The rule the split enforces: nothing in `lines` may spawn, block, or touch
27
+ * the disk. Anything that needs to is a cache read here and a `refresh` there.
28
+ */
29
+ export function createOverviewView(session, git,
30
+ /**
31
+ * The tier the gateway last said served a request — a getter, not a value,
32
+ * because it changes per turn and the view is registered once. Supplied by
33
+ * the renderer, the only object that sees the stream's routing frame.
34
+ */
35
+ servedTier = () => undefined,
36
+ /**
37
+ * Free space where cruxy writes. Optional so a caller that has no disk cache
38
+ * gets a status screen without disk rows rather than a probe it didn't ask
39
+ * for — the same shape `servedTier` uses for a fact only some callers have.
40
+ */
41
+ disk) {
42
+ return {
43
+ id: "overview",
44
+ label: "overview",
45
+ lines: (theme, cols) => sessionStatusLines(buildSessionStatus(session, (absPath) => git.current(absPath), servedTier(), disk ? (path) => disk.current(path) : undefined), theme, cols),
46
+ /**
47
+ * Re-probe every root after a turn. Invalidate-then-refresh because a turn
48
+ * is exactly when the tree may have moved; the cache coalesces, so the ten
49
+ * writes a turn makes still cost one probe per root.
50
+ *
51
+ * Disk refreshes alongside, and unconditionally — it has no invalidate,
52
+ * because free space moves for reasons that have nothing to do with this
53
+ * session (see `disk-status.ts`). Concurrently, not in sequence: they are
54
+ * independent reads and neither should wait on the other's slowest mount.
55
+ */
56
+ refresh: async () => {
57
+ git.invalidate();
58
+ await Promise.all([git.refresh(), disk?.refresh()]);
59
+ },
60
+ };
61
+ }
@@ -17,6 +17,7 @@ import { RAIL_PANELS, } from "./layout.js";
17
17
  export const PANEL_LABELS = {
18
18
  sidebar: "sidebar",
19
19
  context: "context",
20
+ limits: "limits",
20
21
  model: "model",
21
22
  git: "git",
22
23
  tools: "tools",
@@ -30,6 +31,7 @@ export const COLUMN_LABELS = {
30
31
  /** Titles drawn at the top of each rail panel. */
31
32
  const RAIL_TITLES = {
32
33
  context: "context",
34
+ limits: "limits",
33
35
  model: "model",
34
36
  git: "git",
35
37
  tools: "tools",