claude-code-runrate 0.2.4 → 0.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.
@@ -0,0 +1,109 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/pane-config.js — where the list of pane blob paths comes from.
4
+ //
5
+ // RULED 2026-08-02 (the question left open since session e2994e0d):
6
+ //
7
+ // Location: $XDG_CONFIG_HOME/ccr/config.json, defaulting to
8
+ // ~/.config/ccr/config.json. Overridable by CCR_CONFIG for tests and for
9
+ // users who keep dotfiles elsewhere.
10
+ //
11
+ // NOT ccr's state dir (~/.ccr): that holds state ccr writes, and mixing
12
+ // user-authored configuration into a directory the program rewrites invites
13
+ // exactly one accident — clobbering it. NOT repo-local, ever: a config file
14
+ // discovered by walking up from the working directory would let anyone who
15
+ // can land a PR add a pane path to a teammate's sidecar. That is the same
16
+ // reasoning that removed configurable prompt files (see the contract's
17
+ // ruling log); config is the user's, and only the user's.
18
+ //
19
+ // Format: JSON. ccr already parses JSON at every ingestion point, so this
20
+ // adds no new parser and no new attack surface, and the verifier discipline
21
+ // (whitelist-construct, types checked not coerced, total function) applies
22
+ // here unchanged. A bespoke line format would need all of that written again.
23
+ //
24
+ // Shape (v1):
25
+ // { "panes": [ { "path": "~/code/app/.gherkin-trace/sidecar.json" } ] }
26
+ //
27
+ // Entries are OBJECTS rather than bare strings so a later optional key is an
28
+ // additive change rather than a format break. Order is significant (it is the
29
+ // cycle order). Two entries naming the same path are two panes, per the
30
+ // contract — this never de-duplicates.
31
+ //
32
+ // Config is trusted more than a blob (the user wrote it) but is still parsed
33
+ // defensively: a malformed config yields NO panes rather than throwing into
34
+ // the draw loop. The sidecar's own panel must survive a typo in a config file.
35
+
36
+ const path = require('node:path');
37
+ const os = require('node:os');
38
+ const { readTextCapped } = require('./safe-read');
39
+
40
+ /** Config is small; this is a sanity bound, not a policy. */
41
+ const MAX_CONFIG_BYTES = 64 * 1024;
42
+
43
+ /**
44
+ * The config file path, without touching the filesystem.
45
+ * @param {Record<string, string|undefined>} [env]
46
+ * @returns {string}
47
+ */
48
+ function configPath(env) {
49
+ const e = env || process.env;
50
+ if (e.CCR_CONFIG) return e.CCR_CONFIG;
51
+ const xdg = e.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
52
+ return path.join(xdg, 'ccr', 'config.json');
53
+ }
54
+
55
+ /**
56
+ * Expand a leading `~`, then resolve relative paths against `baseDir` — the
57
+ * config file's own directory, per the contract ("a relative path resolves
58
+ * against the config file's directory"). Resolving against the CWD instead
59
+ * would make a pane's identity depend on where the sidecar happened to start.
60
+ * @param {string} p
61
+ * @param {string} baseDir
62
+ * @param {string} home
63
+ * @returns {string}
64
+ */
65
+ function resolvePanePath(p, baseDir, home) {
66
+ let out = p;
67
+ if (out === '~') out = home;
68
+ else if (out.startsWith('~/')) out = path.join(home, out.slice(2));
69
+ return path.resolve(baseDir, out);
70
+ }
71
+
72
+ /**
73
+ * Load the configured pane list. Never throws: a missing, unreadable, or
74
+ * malformed config is "no panes configured", which renders as the plain
75
+ * economy sidebar exactly as before this feature existed.
76
+ *
77
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
78
+ * @returns {{ panes: Array<{ path: string, source: string }>, configPath: string }}
79
+ * `path` is absolute and ready to read; `source` is the string the user wrote
80
+ * (what error states name, so the message matches their config, not ours).
81
+ */
82
+ function loadPaneConfig(opts = {}) {
83
+ const env = opts.env || process.env;
84
+ const home = opts.home || os.homedir();
85
+ const file = configPath(env);
86
+ const empty = { panes: [], configPath: file };
87
+
88
+ const raw = readTextCapped(file, MAX_CONFIG_BYTES);
89
+ if (raw == null || !raw.trim()) return empty;
90
+
91
+ let parsed;
92
+ try { parsed = JSON.parse(raw); } catch { return empty; }
93
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes)) return empty;
94
+
95
+ const baseDir = path.dirname(file);
96
+ /** @type {Array<{ path: string, source: string }>} */
97
+ const panes = [];
98
+ for (const entry of parsed.panes) {
99
+ // Whitelist-construct: read the one field v1 names, off a fresh object.
100
+ // Never spread the entry — same rule the blob verifier follows.
101
+ if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') continue;
102
+ const source = entry.path;
103
+ if (!source.trim()) continue;
104
+ panes.push({ path: resolvePanePath(source, baseDir, home), source });
105
+ }
106
+ return { panes, configPath: file };
107
+ }
108
+
109
+ module.exports = { loadPaneConfig, configPath, resolvePanePath, MAX_CONFIG_BYTES };
@@ -16,10 +16,20 @@ const { stripControl } = require('./sanitize');
16
16
  const FIVE = 300, WEEK = 10080, MONTH = 43200;
17
17
 
18
18
  // Known keys get exact labels/windows; everything else falls back to heuristics.
19
- const KNOWN = {
19
+ //
20
+ // NULL PROTOTYPE, deliberately: bucket names arrive from the status JSON, so a
21
+ // bucket named `toString`, `constructor`, or `__proto__` must MISS this table
22
+ // and fall through to the heuristics below. A plain object literal inherits
23
+ // those names from Object.prototype and hands back a function, whose `.label`
24
+ // is undefined — so labelFor would return undefined while its own contract
25
+ // promises a string. That only stayed harmless because every consumer happens
26
+ // to write `wd.label || wd.key`; a consumer trusting the declared type would
27
+ // break. Structural fix rather than a guard at each lookup.
28
+ /** @type {Record<string, { label: string, windowMinutes: number }>} */
29
+ const KNOWN = Object.assign(Object.create(null), {
20
30
  five_hour: { label: '5h', windowMinutes: FIVE },
21
31
  seven_day: { label: 'weekly', windowMinutes: WEEK },
22
- };
32
+ });
23
33
 
24
34
  /** @param {string} key → 'Sonnet' | 'Opus' | 'Haiku' | null */
25
35
  function modelScope(key) {
@@ -28,7 +28,9 @@ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolea
28
28
  const dotColor = row.resetsFirst ? green : bandColor[b];
29
29
  const dot = (row.binding && b === 'imminent') ? flash(tick, '●') : dotColor('●');
30
30
 
31
- const labelTxt = row.label.padEnd(labelW);
31
+ // Truncate as well as pad: labelW is capped, so a longer label must be cut to
32
+ // the column rather than pushing every sibling row out of alignment.
33
+ const labelTxt = (row.label.length > labelW ? row.label.slice(0, labelW - 1) + '…' : row.label).padEnd(labelW);
32
34
  const label = row.binding ? bold(bandColor[b](labelTxt)) : dim(labelTxt);
33
35
  // Time-to-exhaust carries no word: the sibling "resets …" is self-labelling,
34
36
  // so a bare "~8h43m" reads unambiguously as remaining budget.
@@ -59,7 +61,13 @@ function renderEconomy(view, opts = {}) {
59
61
  const out = [bold('economy') + dim(' ' + (view.model || '')), ''];
60
62
 
61
63
  const { rows, next } = classifyWindows(view);
62
- const labelW = Math.max(8, ...rows.map((/** @type {any} */ r) => r.label.length));
64
+ // Cap the label column. `labelW` multiplies: every row pads to it, so cost is
65
+ // rows × longest-label, and BOTH come from the snapshot's rate_limits keys. A
66
+ // planted file with many buckets and one very long key built a string large
67
+ // enough to throw RangeError and blank the panel — an amplifier, not a leak,
68
+ // but it costs the whole display. 18 columns fits every real bucket name.
69
+ const LABEL_MAX = 18;
70
+ const labelW = Math.min(LABEL_MAX, Math.max(8, ...rows.map((/** @type {any} */ r) => r.label.length)));
63
71
 
64
72
  // HERO
65
73
  if (!rows.length) {
@@ -0,0 +1,345 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/render/git-pane.js — draw the git pane from a model src/git-repo.js built.
4
+ //
5
+ // Same split as src/pane-blob.js → src/render/pane.js: the reader is the choke
6
+ // point that sanitizes and caps, so this file does no validation and no
7
+ // stripping. Every string arriving here is already printable. What it does own
8
+ // is LAYOUT, and the one layout rule the contract actually pins is that a value
9
+ // too long for the pane is shortened rather than wrapped — a wrapped line
10
+ // corrupts the sidecar's cursor-home redraw (the reason clampVisible exists).
11
+ //
12
+ // The identity line is the pane's whole point: features/git-repo-identity.feature
13
+ // exists because six tabs each labelled with an instance name say nothing about
14
+ // which repo they sit in. So it is the first row, it is always exactly one row,
15
+ // and it budgets its own space rather than letting a long branch name push the
16
+ // repo name off the end.
17
+
18
+ const {
19
+ dim, bold, cyan, green, red, yellow, clampVisible, visibleWidth, ellipsize, charWidth,
20
+ } = require('./shared');
21
+
22
+ // Left margin, matching every other ccr surface.
23
+ const INDENT = ' ';
24
+
25
+ // Fallback width when the caller knows nothing (non-TTY, `ccr sidecar` piped).
26
+ // Same default the external pane renderer uses.
27
+ const DEFAULT_WIDTH = 48;
28
+
29
+ // The launch repo gets its OWN ROW, and this is a contract detail rather than a
30
+ // layout preference. The option the visionary chose at scoping was "Follows,
31
+ // with the launch repo pinned — shows the current repo, but ALWAYS keeps the
32
+ // launch repo visible AS A SECOND LINE, so the tab keeps a stable identity while
33
+ // the pane tracks the work" (features/OUT-OF-SCOPE.md, Roads not taken).
34
+ //
35
+ // An earlier build put it inline as "launch › current" and had to invent a rule
36
+ // for dropping it when the row got tight — which meant a repo with a long branch
37
+ // name silently lost the pinned identity the option promises to keep. A second
38
+ // row cannot be crowded out by a branch name.
39
+ const LAUNCH_PREFIX = 'launched in ';
40
+
41
+ /**
42
+ * Lay two values out on one line: `left` at the margin, `right` flushed to the
43
+ * end, at least `gap` columns between them. When they do not both fit, each
44
+ * gets what it needs up to a fair half and the remainder goes to the other, so
45
+ * a short name never costs a long one room it could have used.
46
+ *
47
+ * @param {string} left
48
+ * @param {string} right
49
+ * @param {number} avail Columns available to left + gap + right.
50
+ * @param {number} gap
51
+ * @returns {{ left: string, right: string, pad: number }}
52
+ */
53
+ function fitPair(left, right, avail, gap) {
54
+ const room = Math.max(0, avail - gap);
55
+ const wl = visibleWidth(left);
56
+ const wr = visibleWidth(right);
57
+ if (wl + wr <= room) return { left, right, pad: room - wl - wr + gap };
58
+ const half = Math.floor(room / 2);
59
+ let bl;
60
+ let br;
61
+ if (wl <= half) { bl = wl; br = room - wl; } else if (wr <= half) { br = wr; bl = room - wr; } else { bl = room - half; br = half; }
62
+ return { left: ellipsize(left, bl), right: ellipsize(right, br), pad: gap };
63
+ }
64
+
65
+ /**
66
+ * The identity row — one row, always, whatever the model says.
67
+ *
68
+ * The position marker arrives as PLAIN text and is coloured here, deliberately.
69
+ * Passing it in pre-coloured is the obvious shape and it is wrong: visibleWidth
70
+ * counts display characters and knows nothing about SGR, so a dimmed " 2/2"
71
+ * measures 13 columns instead of 5 and quietly steals eight from the names.
72
+ * That version rendered fine at 48 columns and collapsed to "c… …" at 20 —
73
+ * a layout bug that only appears off the demo path.
74
+ *
75
+ * @param {import('../git-repo').RepoIdentity} id
76
+ * @param {number} width Total columns the row may occupy, marker included.
77
+ * @param {string} position Plain cycle position, e.g. "2/3" (may be '').
78
+ * @returns {string}
79
+ */
80
+ function identityLine(id, width, position) {
81
+ const markerText = position ? ' ' + position : '';
82
+ const marker = markerText ? dim(markerText) : '';
83
+ const avail = Math.max(1, width - visibleWidth(INDENT) - visibleWidth(markerText));
84
+
85
+ // ONE layout for every state. The right-hand slot holds the branch when there
86
+ // is one and the state's own sentence when there is not, so a repository whose
87
+ // HEAD cannot be read still gets NAMED on the left — the pane's entire job.
88
+ // An earlier version early-returned on any non-ok state and threw the name
89
+ // away, though the model had it.
90
+ //
91
+ // The two failure sentences stay distinct: "not a git repository" is a fact
92
+ // about the directory, and features/git-pane-safety.feature separately
93
+ // requires that a repo whose data cannot be READ says so instead. Conflating
94
+ // them would report a broken clone as a scratch directory.
95
+ const right = id.state === 'unreadable'
96
+ ? { text: 'git data unavailable', paint: yellow }
97
+ : id.state !== 'ok'
98
+ ? { text: 'not a git repository', paint: dim }
99
+ // Before `detached`, because it is the larger fact: a bare repository has
100
+ // no working tree, so there is no checkout for a branch name to describe
101
+ // and nothing for the working-tree section to ever show. "bare", not
102
+ // "empty" — `git init --bare` then push a thousand commits and it is
103
+ // still bare, so "empty" would be a different claim, and a false one.
104
+ : id.bare
105
+ ? { text: 'bare repository', paint: yellow }
106
+ : id.detached
107
+ ? { text: 'detached', paint: yellow }
108
+ // The `|| ''` is a type guard, not a case: readHead returns either a
109
+ // non-empty branch or detached: true, so an ok state with a null branch
110
+ // cannot occur. It stays because `branch` is nullable in the model and
111
+ // strict mode is right to insist the reader handle that.
112
+ : { text: id.branch || '', paint: cyan };
113
+
114
+ // Nothing to name on the left — the row is the sentence alone, which is what
115
+ // "the pane shows no branch name" pins for a plain scratch directory. The
116
+ // launch repo, if there is one, is a separate row and does not appear here.
117
+ if (!id.name) return INDENT + right.paint(right.text) + marker;
118
+
119
+ const leftPlain = id.name;
120
+ const leftColored = bold(id.name);
121
+ const fit = fitPair(leftPlain, right.text, avail, 2);
122
+ // Re-apply colour only when the plain text survived intact; a shortened value
123
+ // is rebuilt from the fitted string, so the ellipsis lands inside the colour
124
+ // run rather than after it.
125
+ const leftOut = fit.left === leftPlain ? leftColored : bold(fit.left);
126
+ const rightOut = fit.right === right.text ? right.paint(right.text) : right.paint(fit.right);
127
+ return INDENT + leftOut + ' '.repeat(Math.max(1, fit.pad)) + rightOut + marker;
128
+ }
129
+
130
+ // ── The working-tree section ────────────────────────────────────────────────
131
+ //
132
+ // Sits between the identity rows and the commit graph, which is why its row
133
+ // budget is a stated formula rather than "whatever fits": the flat file list
134
+ // and the graph compete for the same rows (features/git-working-tree.feature's
135
+ // header says so), and the cap is the contract's answer to that competition.
136
+
137
+ // Rows always reserved for the commit graph below the list, so a long file
138
+ // list can never starve history off the pane entirely.
139
+ const GRAPH_RESERVE = 8;
140
+
141
+ // The section's own chrome: the counts row, the possible rebase row, the
142
+ // possible "N more" row, and the blank line above the section.
143
+ const WT_CHROME = 4;
144
+
145
+ // A ceiling regardless of pane height: past this many file rows the list stops
146
+ // informing and starts scrolling the reader.
147
+ const WT_MAX_FILE_ROWS = 16;
148
+
149
+ /**
150
+ * How many file rows the working-tree list may use in a pane `rows` tall.
151
+ * Exported because it IS the contract the long-list scenario names ("the pane
152
+ * has room for 8 file rows") — the steps derive the pane height from this
153
+ * formula rather than duplicating the arithmetic.
154
+ * @param {number} rows
155
+ * @returns {number}
156
+ */
157
+ function fileRowBudget(rows) {
158
+ return Math.max(2, Math.min(WT_MAX_FILE_ROWS, Math.trunc(rows) - GRAPH_RESERVE - WT_CHROME));
159
+ }
160
+
161
+ /**
162
+ * Shorten from the FRONT, keeping the tail — the working-tree list's rule,
163
+ * because the tail is the file name and the file name is the answer ("A path
164
+ * too long for the pane keeps its file name"). The mirror of ellipsize.
165
+ * @param {string} s
166
+ * @param {number} cols
167
+ * @returns {string}
168
+ */
169
+ function ellipsizeStart(s, cols) {
170
+ if (cols <= 0) return '';
171
+ if (visibleWidth(s) <= cols) return s;
172
+ if (cols === 1) return '…';
173
+ const cps = [...s];
174
+ let used = 1; // the ellipsis
175
+ let start = cps.length;
176
+ while (start > 0) {
177
+ const w = charWidth(/** @type {number} */(cps[start - 1].codePointAt(0)));
178
+ if (used + w > cols) break;
179
+ used += w;
180
+ start -= 1;
181
+ }
182
+ return '…' + cps.slice(start).join('');
183
+ }
184
+
185
+ /** @type {Record<import('../git-working-tree').ChangeMark, (s: string) => string>} */
186
+ const MARK_PAINT = { '!': red, '+': green, M: yellow, '?': dim };
187
+
188
+ /**
189
+ * The working-tree rows: counts, the capped file list, the remainder.
190
+ *
191
+ * @param {import('../git-working-tree').WorkingTree} wt
192
+ * @param {{ width: number, rows: number }} opts
193
+ * @returns {string[]}
194
+ */
195
+ function workingTreeLines(wt, opts) {
196
+ const { width } = opts;
197
+ if (wt.state !== 'ok') return [INDENT + yellow('git data unavailable')];
198
+
199
+ /** @type {string[]} */
200
+ const lines = [];
201
+ // The rebase banner leads: it is the state that explains every "!" below it.
202
+ if (wt.rebase) lines.push(INDENT + yellow('rebase in progress'));
203
+
204
+ const total = wt.entries.length;
205
+ if (total === 0) {
206
+ if (!wt.rebase) lines.push(INDENT + dim('clean'));
207
+ return lines;
208
+ }
209
+ // `truncated` means the untracked walk hit its visit budget, so `total` is a
210
+ // floor rather than the count; the "+" keeps the headline honest.
211
+ lines.push(INDENT + (total === 1 && !wt.truncated ? '1 change' : `${total}${wt.truncated ? '+' : ''} changes`));
212
+
213
+ const budget = fileRowBudget(opts.rows);
214
+ const listed = wt.entries.slice(0, budget);
215
+ // Columns for the path: margin, one mark column, one space.
216
+ const pathCols = Math.max(1, width - visibleWidth(INDENT) - 2);
217
+ for (const e of listed) {
218
+ lines.push(INDENT + MARK_PAINT[e.mark](e.mark) + ' ' + ellipsizeStart(e.path, pathCols));
219
+ }
220
+ const rest = total - listed.length;
221
+ if (rest > 0) lines.push(INDENT + dim(`${rest}${wt.truncated ? '+' : ''} more`));
222
+ return lines;
223
+ }
224
+
225
+ // ── The commit graph ────────────────────────────────────────────────────────
226
+
227
+ // One column per lane; the rest of a graph row is margin, hash, subject, age.
228
+ // Reserving this much keeps a readable subject at every lane count the budget
229
+ // can return.
230
+ const LANE_RESERVE = 26;
231
+ const MAX_LANES = 6;
232
+
233
+ /**
234
+ * How many lanes a pane `width` columns wide may draw. Exported for the same
235
+ * reason as fileRowBudget: "the pane has room for 3 lanes" is a fact about
236
+ * THIS formula, and the steps derive the width from it.
237
+ * @param {number} width
238
+ * @returns {number}
239
+ */
240
+ function laneBudget(width) {
241
+ return Math.max(1, Math.min(MAX_LANES, Math.trunc(width) - LANE_RESERVE));
242
+ }
243
+
244
+ /**
245
+ * A relative age: "now", then minutes, hours, days. Coarse on purpose — the
246
+ * scenario pins that an age is SHOWN, and a graph is not a clock.
247
+ * @param {number} whenSec
248
+ * @param {number} nowMs
249
+ * @returns {string}
250
+ */
251
+ function fmtAge(whenSec, nowMs) {
252
+ const s = Math.max(0, Math.floor(nowMs / 1000) - whenSec);
253
+ if (s < 90) return 'now';
254
+ if (s < 90 * 60) return Math.round(s / 60) + 'm';
255
+ if (s < 36 * 3600) return Math.round(s / 3600) + 'h';
256
+ return Math.round(s / 86400) + 'd';
257
+ }
258
+
259
+ /**
260
+ * The graph rows: lane cells, short hash, subject, age — plus the overflow
261
+ * count when branches outnumber lanes.
262
+ *
263
+ * @param {import('../git-history').History} history
264
+ * @param {{ width: number, maxRows: number, now: number }} opts
265
+ * @returns {string[]}
266
+ */
267
+ function commitGraphLines(history, opts) {
268
+ const { width } = opts;
269
+ if (history.state === 'unavailable') return [INDENT + yellow('git data unavailable')];
270
+ if (history.state === 'empty') return [INDENT + dim('no commits yet')];
271
+
272
+ /** @type {string[]} */
273
+ const lines = [];
274
+ const span = Math.max(1, history.laneCount);
275
+ for (const row of history.rows.slice(0, Math.max(1, opts.maxRows))) {
276
+ let cells = '';
277
+ for (let i = 0; i < span; i += 1) {
278
+ if (i === row.lane) cells += '●';
279
+ else if (row.joinLanes && row.joinLanes.includes(i)) cells += '╮';
280
+ else cells += (row.activeMask && row.activeMask[i]) ? '│' : ' ';
281
+ }
282
+ const age = fmtAge(row.when, opts.now);
283
+ // Margin + cells + space + hash + space + subject + gap + age = width.
284
+ const subjCols = Math.max(1,
285
+ width - visibleWidth(INDENT) - span - 1 - row.shortHash.length - 1 - 2 - visibleWidth(age));
286
+ const subject = ellipsize(row.subject, subjCols);
287
+ lines.push(INDENT + cyan(cells) + ' ' + dim(row.shortHash) + ' ' + subject
288
+ + ' ' + dim(age));
289
+ }
290
+ if (history.droppedBranches > 0) {
291
+ lines.push(INDENT + dim(`${history.droppedBranches} more branches`));
292
+ }
293
+ return lines;
294
+ }
295
+
296
+ /**
297
+ * Render the whole git pane.
298
+ *
299
+ * @param {{ identity: import('../git-repo').RepoIdentity,
300
+ * workingTree?: import('../git-working-tree').WorkingTree,
301
+ * history?: import('../git-history').History }} model
302
+ * @param {{ width?: number, position?: string, rows?: number, now?: number }} [opts]
303
+ * @returns {string}
304
+ */
305
+ function renderGitPane(model, opts = {}) {
306
+ const width = opts.width && opts.width > 0 ? opts.width : DEFAULT_WIDTH;
307
+ const rows = opts.rows && opts.rows > 0 ? opts.rows : 24;
308
+ const id = model.identity;
309
+ const lines = [identityLine(id, width, opts.position || '')];
310
+ // The pinned launch repo: its own row, shown only when it differs from the
311
+ // repo the session is in — a tab that names the same repo twice has told the
312
+ // reader nothing, and the row costs vertical space the graph wants.
313
+ if (id.launchName) {
314
+ lines.push(INDENT + dim(ellipsize(LAUNCH_PREFIX + id.launchName, Math.max(1, width - visibleWidth(INDENT)))));
315
+ }
316
+ // The body sections render only where a working tree can exist: a located,
317
+ // readable, non-bare repository. Everywhere else the identity row already
318
+ // carries the pane's whole sentence.
319
+ const bodied = id.state === 'ok' && !id.bare;
320
+ if (model.workingTree && bodied) {
321
+ lines.push('');
322
+ lines.push(...workingTreeLines(model.workingTree, { width, rows }));
323
+ }
324
+ // The graph sits below the list — and is skipped when the working tree
325
+ // already degraded, so "git data unavailable" is said once, not twice from
326
+ // two sections that failed to read the same store.
327
+ if (model.history && bodied && (!model.workingTree || model.workingTree.state === 'ok')) {
328
+ lines.push('');
329
+ lines.push(...commitGraphLines(model.history, {
330
+ width,
331
+ maxRows: Math.max(3, rows - lines.length - 1),
332
+ now: opts.now != null ? opts.now : 0,
333
+ }));
334
+ }
335
+ // clampVisible is the net, not the mechanism: identityLine already budgets to
336
+ // `width`. It stays because a layout bug must cost a truncated line, never
337
+ // the wrap that corrupts the redraw.
338
+ return lines.map((l) => clampVisible(l, width)).join('\n');
339
+ }
340
+
341
+ module.exports = {
342
+ renderGitPane, identityLine, fitPair, workingTreeLines, commitGraphLines,
343
+ fileRowBudget, laneBudget, ellipsizeStart, fmtAge,
344
+ GRAPH_RESERVE, WT_MAX_FILE_ROWS, MAX_LANES,
345
+ };