claude-code-runrate 0.3.0 → 0.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.
@@ -64,8 +64,14 @@ function configPath(env) {
64
64
  */
65
65
  function resolvePanePath(p, baseDir, home) {
66
66
  let out = p;
67
+ // `~\` as well as `~/`: a Windows user writes the separator their shell shows
68
+ // them, and accepting only the forward slash left `~\tools\blob.json` to
69
+ // resolve against the config directory — a path that cannot exist, whose only
70
+ // symptom is a pane that never appears. The tilde means home on the machine
71
+ // the config was written for; the separator it is followed by does not
72
+ // change that.
67
73
  if (out === '~') out = home;
68
- else if (out.startsWith('~/')) out = path.join(home, out.slice(2));
74
+ else if (out.startsWith('~/') || out.startsWith('~\\')) out = path.join(home, out.slice(2));
69
75
  return path.resolve(baseDir, out);
70
76
  }
71
77
 
@@ -75,22 +81,50 @@ function resolvePanePath(p, baseDir, home) {
75
81
  * economy sidebar exactly as before this feature existed.
76
82
  *
77
83
  * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
78
- * @returns {{ panes: Array<{ path: string, source: string }>, configPath: string }}
84
+ * @returns {{ panes: Array<{ path: string, source: string }>, configPath: string,
85
+ * error: string|null }}
79
86
  * `path` is absolute and ready to read; `source` is the string the user wrote
80
87
  * (what error states name, so the message matches their config, not ours).
88
+ * `error` names why a config that EXISTS produced no panes. A config that is
89
+ * simply absent is not an error — that is most users — but one the user wrote
90
+ * and got wrong must say so somewhere, or the only symptom of a typo is panes
91
+ * that never appear.
81
92
  */
82
93
  function loadPaneConfig(opts = {}) {
83
94
  const env = opts.env || process.env;
84
95
  const home = opts.home || os.homedir();
85
96
  const file = configPath(env);
86
- const empty = { panes: [], configPath: file };
97
+ const empty = { panes: [], configPath: file, error: null };
87
98
 
88
99
  const raw = readTextCapped(file, MAX_CONFIG_BYTES);
89
100
  if (raw == null || !raw.trim()) return empty;
90
101
 
102
+ // A UTF-8 byte-order mark makes JSON.parse throw. PowerShell writes one by
103
+ // default — `Set-Content`, `Out-File`, and `>` under Windows PowerShell all
104
+ // do — so a config written the obvious way on Windows is malformed on
105
+ // arrival, and the only symptom is panes that never appear. Strip it rather
106
+ // than diagnose it later: there is no config for which a leading BOM is
107
+ // content. (`.trim()` above already treats a BOM-only file as empty; U+FEFF
108
+ // is whitespace to the trimmer but not to the parser.)
109
+ const text = raw.replace(/^\uFEFF/, '');
110
+
111
+ // UTF-16 is the likelier Windows mistake, and it is NOT what the strip above
112
+ // catches. Windows PowerShell 5.1 writes UTF-16LE for `>` and `Out-File` by
113
+ // default (Set-Content writes ANSI; only `-Encoding utf8` gives the UTF-8 BOM;
114
+ // PowerShell 7+ writes UTF-8 without one). Read as UTF-8 those bytes survive
115
+ // trim() and reach the parser as a leading U+FFFD pair and NUL-interleaved
116
+ // text, so the parse fails and the honest-looking report is "not valid JSON"
117
+ // — sending someone to hunt for a syntax error in a file whose syntax is
118
+ // fine. A NUL is never content in a JSON config, so it names the real cause.
119
+ if (text.includes('\u0000')) {
120
+ return { ...empty, error: 'looks like UTF-16 — save it as UTF-8' };
121
+ }
122
+
91
123
  let parsed;
92
- try { parsed = JSON.parse(raw); } catch { return empty; }
93
- if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes)) return empty;
124
+ try { parsed = JSON.parse(text); } catch { return { ...empty, error: 'not valid JSON' }; }
125
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes)) {
126
+ return { ...empty, error: 'no panes array' };
127
+ }
94
128
 
95
129
  const baseDir = path.dirname(file);
96
130
  /** @type {Array<{ path: string, source: string }>} */
@@ -103,7 +137,7 @@ function loadPaneConfig(opts = {}) {
103
137
  if (!source.trim()) continue;
104
138
  panes.push({ path: resolvePanePath(source, baseDir, home), source });
105
139
  }
106
- return { panes, configPath: file };
140
+ return { panes, configPath: file, error: null };
107
141
  }
108
142
 
109
143
  module.exports = { loadPaneConfig, configPath, resolvePanePath, MAX_CONFIG_BYTES };
@@ -10,16 +10,23 @@
10
10
  const { clearROI } = require('../burn');
11
11
  const { classifyWindows, band } = require('../economy-model');
12
12
  const { resolveTheme, lexicon } = require('../theme');
13
- const { dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins, fmtReset } = require('./shared');
13
+ const { dim, bold, green, red, yellow, cyan, flash, pctColor, CRIT_PCT, usedLabel, bar, tok, fmtMins, fmtReset } = require('./shared');
14
+ const { liveness } = require('../liveness');
14
15
 
15
16
  const bandColor = { imminent: red, warn: yellow, ok: cyan };
16
17
 
17
- function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolean} */ tick, /** @type {number} */ labelW) {
18
+ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolean} */ tick, /** @type {number} */ labelW, /** @type {boolean} */ stale, /** @type {number} */ numW) {
18
19
  // Truncate, don't round: Claude's own surfaces (`/usage`, claude.ai usage)
19
20
  // floor the fractional `used_percentage` (e.g. 41.6 → "41%"). Math.round here
20
21
  // read ~1pt high on values past the half-point. Display only — the burn/ROI
21
22
  // math below still uses the raw fractional `row.est.usedPct`.
23
+ //
24
+ // `used` (whole %) drives the bar fill and the colour band; `usedTxt` is the
25
+ // number actually shown, which gains one truncated decimal in the critical
26
+ // zone. Both floor, so they can never disagree about which side of a whole
27
+ // number the reading falls on.
22
28
  const used = Math.floor(row.est.usedPct);
29
+ const usedTxt = usedLabel(row.est.usedPct);
23
30
  const ml = row.est.minutesLeft;
24
31
  const b = band(ml);
25
32
  // Per-row colour dot: green when the window resets before you'd hit it,
@@ -37,7 +44,11 @@ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolea
37
44
  const leftTxt = (ml != null ? '~' + fmtMins(ml) : '—').padEnd(7);
38
45
  const left = row.binding ? bold(leftTxt) : dim(leftTxt);
39
46
  const resets = row.reset != null ? dim('resets ' + fmtReset(row.reset)) : '';
40
- const meter = pctColor(used)(bar(used)) + ' ' + String(used).padStart(2) + '% used';
47
+ // A stale snapshot dims the figure so a number frozen between chat rounds
48
+ // never reads as live. The bar colour stays, so the band still pops, and the
49
+ // sidecar appends the "updated …" note that says why (see src/liveness.js).
50
+ const numTxt = usedTxt.padStart(numW) + '% used';
51
+ const meter = pctColor(used)(bar(used)) + ' ' + (stale ? dim(numTxt) : numTxt);
41
52
  const main = ' ' + dot + ' ' + label + ' ' + left + ' ' + meter + ' ' + resets;
42
53
 
43
54
  // The binding window's "wall" call-out drops to its own indented line below —
@@ -51,16 +62,30 @@ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolea
51
62
 
52
63
  /**
53
64
  * @param {any} view normalized economy data
54
- * @param {{ theme?: 'plain'|'mary', now?: Date, tick?: boolean, env?: any }} [opts]
65
+ * @param {{ theme?: 'plain'|'mary', now?: Date, tick?: boolean, env?: any,
66
+ * ageMs?: number, staleMs?: number }} [opts]
67
+ * ageMs/staleMs: how old the captured snapshot is, and the threshold past
68
+ * which it counts as stale. The snapshot only refreshes per chat round, so
69
+ * between rounds it ages — past the threshold the used figures dim, so they
70
+ * read as last-known rather than live. The threshold decision itself stays
71
+ * in src/liveness.js, which also owns the "updated …" note the sidecar
72
+ * appends; this renderer only asks whether the data is stale.
55
73
  * @returns {string}
56
74
  */
57
75
  function renderEconomy(view, opts = {}) {
58
76
  const themeName = opts.theme || resolveTheme(opts.now, opts.env);
59
77
  const L = lexicon(themeName);
60
78
  const tick = !!opts.tick;
79
+ const stale = !!liveness({ ageMs: opts.ageMs ?? 0, staleMs: opts.staleMs }).marker;
61
80
  const out = [bold('economy') + dim(' ' + (view.model || '')), ''];
62
81
 
63
82
  const { rows, next } = classifyWindows(view);
83
+ // The used% column widens to fit a decimal only when some row actually has
84
+ // one. Widening it unconditionally would shift every meter two columns right
85
+ // for the whole time you are nowhere near the wall — which both costs a
86
+ // narrow sidebar two columns it needs and blunts the point of the decimal,
87
+ // whose appearing is itself the "you are in the zone" cue.
88
+ const numW = rows.some((/** @type {any} */ r) => r.est.usedPct >= CRIT_PCT && r.est.usedPct < 100) ? 4 : 2;
64
89
  // Cap the label column. `labelW` multiplies: every row pads to it, so cost is
65
90
  // rows × longest-label, and BOTH come from the snapshot's rate_limits keys. A
66
91
  // planted file with many buckets and one very long key built a string large
@@ -86,7 +111,7 @@ function renderEconomy(view, opts = {}) {
86
111
  }
87
112
  out.push('');
88
113
 
89
- for (const r of rows) out.push(wallRow(r, L, tick, labelW));
114
+ for (const r of rows) out.push(wallRow(r, L, tick, labelW, stale, numW));
90
115
  if (rows.length) out.push('');
91
116
 
92
117
  // CLEAR — plain language, framed against the binding wall, only when it looms.
@@ -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
+ };
@@ -16,6 +16,26 @@ const flash = (/** @type {boolean} */ tick, /** @type {string} */ s) => (tick ?
16
16
 
17
17
  const pctColor = (/** @type {number} */ p) => (p >= 75 ? red : p >= 60 ? yellow : green);
18
18
 
19
+ // At/above this used%, surface one extra digit of Claude's own (fractional)
20
+ // `used_percentage`. Near the wall the next tenth is a decision input; below it
21
+ // it is noise. A decimal appearing IS the "you are in the zone" salience signal.
22
+ const CRIT_PCT = 95;
23
+
24
+ /**
25
+ * Used% display string. Below the critical zone: the whole number. In the zone
26
+ * (and under 100): one TRUNCATED decimal — the same downward direction as the
27
+ * integer floor, so `floor(shown)` still equals what `/usage` reports. We never
28
+ * tick above Claude's own number, only out-resolve it. The +1e-9 guards float
29
+ * representation: 98.7 * 10 is 986.9999… and would otherwise truncate to 98.6.
30
+ * @param {number} pct raw fractional used_percentage
31
+ * @returns {string}
32
+ */
33
+ function usedLabel(pct) {
34
+ if (pct < CRIT_PCT || pct >= 100) return String(Math.floor(pct));
35
+ const tenths = Math.floor(pct * 10 + 1e-9);
36
+ return `${Math.floor(tenths / 10)}.${tenths % 10}`;
37
+ }
38
+
19
39
  function bar(/** @type {number} */ p, w = 10) {
20
40
  const f = Math.max(0, Math.min(w, Math.round((p / 100) * w)));
21
41
  return '▓'.repeat(f) + '░'.repeat(w - f);
@@ -140,4 +160,52 @@ function fmtReset(/** @type {number|null} */ min) {
140
160
  return `${m}m`;
141
161
  }
142
162
 
143
- module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset };
163
+ /**
164
+ * Terminal columns a PLAIN string occupies — the same accounting `clampVisible`
165
+ * does, exposed for the callers that must budget space before they build a line
166
+ * rather than clamp one afterwards. No SGR handling: the strings measured here
167
+ * are display text before any colour is applied.
168
+ * @param {string} s
169
+ * @returns {number}
170
+ */
171
+ function visibleWidth(s) {
172
+ let w = 0;
173
+ for (const ch of s) w += charWidth(/** @type {number} */ (ch.codePointAt(0)));
174
+ return w;
175
+ }
176
+
177
+ /**
178
+ * Fit plain text into `cols` columns, marking the cut with an ellipsis so a
179
+ * shortened value never reads as a complete one. Cutting is by code point and
180
+ * by COLUMN (a wide glyph costs two), and the ellipsis is inside the budget —
181
+ * the result is never wider than `cols`.
182
+ *
183
+ * Distinct from `clampVisible`, which is the hard safety net applied to a
184
+ * finished line: this one is composition, so the caller can lay out around a
185
+ * value it knows will fit. Returns '' for a non-positive budget.
186
+ *
187
+ * @param {string} s
188
+ * @param {number} cols
189
+ * @returns {string}
190
+ */
191
+ function ellipsize(s, cols) {
192
+ if (!(typeof cols === 'number' && cols > 0)) return '';
193
+ if (visibleWidth(s) <= cols) return s;
194
+ // One column is spent on the ellipsis, so the text gets cols-1. At cols === 1
195
+ // that leaves nothing, and the ellipsis alone is the honest answer.
196
+ const budget = cols - 1;
197
+ let out = '';
198
+ let w = 0;
199
+ for (const ch of s) {
200
+ const cw = charWidth(/** @type {number} */ (ch.codePointAt(0)));
201
+ if (w + cw > budget) break;
202
+ out += ch;
203
+ w += cw;
204
+ }
205
+ return out + '…';
206
+ }
207
+
208
+ module.exports = {
209
+ e, dim, bold, green, red, yellow, cyan, flash, pctColor, CRIT_PCT, usedLabel, bar, clampVisible, tok, fmtMins, fmtReset,
210
+ charWidth, visibleWidth, ellipsize,
211
+ };
@@ -4,14 +4,47 @@
4
4
  // Plain text (no ANSI) so it renders cleanly wherever the status line appears.
5
5
 
6
6
  const { windowEstimate, binding } = require('../burn');
7
- const { fmtMins } = require('./shared');
7
+ const { fmtMins, usedLabel, CRIT_PCT } = require('./shared');
8
+
9
+ /**
10
+ * Deterministic middle ellipsis: the same input shortens the same way at
11
+ * every glance — the anti-marquee rule. (Animation was rejected outright:
12
+ * Claude re-renders this line per turn, not on a clock, so anything animated
13
+ * freezes exactly when the user is idle and orienting.)
14
+ * @param {string} s @param {number} max
15
+ */
16
+ function midEllipsis(s, max) {
17
+ if (s.length <= max) return s;
18
+ const head = Math.ceil((max - 1) / 2);
19
+ const tail = max - 1 - head;
20
+ return s.slice(0, head) + '…' + (tail > 0 ? s.slice(-tail) : '');
21
+ }
8
22
 
9
23
  /**
10
24
  * @param {any} view normalized economy data
11
- * @returns {string} one line, e.g. "Sonnet 4.6 · weekly · Sonnet ~5h · ctx 15% · $2.50"
25
+ * @param {{ name?: string|null, location?: string|null, cols?: number }} [identity]
26
+ * The instance identity, shown FIRST so terminal end-truncation eats meters,
27
+ * never orientation. The location half is LIVE (follows a mid-session cd)
28
+ * and appears only when it differs from the name — "notes @ notes" says
29
+ * nothing twice. `cols` bounds the identity: the location stays whole, the
30
+ * name takes the ellipsis.
31
+ * @returns {string} one line, e.g. "a-is-awesome @ ccr · Opus 4.8 · 5h ~2h · ctx 15% · $2.50"
12
32
  */
13
- function renderStatusline(view) {
33
+ function renderStatusline(view, identity = {}) {
14
34
  const parts = [];
35
+ const name = identity.name || null;
36
+ const loc = identity.location || null;
37
+ if (name) {
38
+ const withLoc = loc && loc !== name;
39
+ let shownName = name;
40
+ if (identity.cols && withLoc) {
41
+ const budget = identity.cols - (' @ '.length + (loc ? loc.length : 0));
42
+ if (name.length > budget) shownName = midEllipsis(name, Math.max(5, budget));
43
+ } else if (identity.cols && name.length > identity.cols) {
44
+ shownName = midEllipsis(name, Math.max(5, identity.cols));
45
+ }
46
+ parts.push(withLoc ? `${shownName} @ ${loc}` : shownName);
47
+ }
15
48
  if (view.model) parts.push(view.model);
16
49
 
17
50
  // Annotated because Array.isArray does not narrow an `any`: without this the
@@ -33,8 +66,20 @@ function renderStatusline(view) {
33
66
  const b = binding(live);
34
67
  if (b && b.minutesLeft != null) {
35
68
  const row = rows.find((r) => r.key === b.window);
36
- const imminent = b.minutesLeft <= 30 ? '⚠ ' : '';
37
- parts.push(`${imminent}${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
69
+ const label = row ? row.label : b.window;
70
+ // Progressive disclosure: the one-line summary earns the precise used%
71
+ // ONLY in the critical zone, and truncated, so floor() still matches
72
+ // /usage. Below the zone the line stays a single glanceable beat — the
73
+ // time-to-limit already answers "am I near the wall?", and a percentage
74
+ // that is always present stops being a signal when it starts to matter.
75
+ const pct = row && row.est.usedPct >= CRIT_PCT ? ` ${usedLabel(row.est.usedPct)}%` : '';
76
+ if (b.minutesLeft <= 30) {
77
+ // "About to hit the wall" outranks orientation for the next thing the
78
+ // user types: the warning jumps ahead of everything, identity included.
79
+ parts.unshift(`⚠ ${label}${pct} ~${fmtMins(b.minutesLeft)}`);
80
+ } else {
81
+ parts.push(`${label}${pct} ~${fmtMins(b.minutesLeft)}`);
82
+ }
38
83
  } else {
39
84
  parts.push('within limits');
40
85
  }