claude-code-runrate 0.3.0 → 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.
package/src/launch-win.js CHANGED
@@ -82,11 +82,15 @@ function resolveProfileState(profile, opts = {}) {
82
82
  const env = opts.env || process.env;
83
83
  const home = opts.home || os.homedir();
84
84
 
85
+ // The stateDir fallbacks are reachable only when called with neither a slot's
86
+ // env nor the user's own override — never from the launchers, which refuse an
87
+ // exhausted allocation before this. They point at slot 1's member dir so no
88
+ // path here can ever name the container itself as a state dir.
85
89
  if (profile) {
86
90
  return {
87
91
  ccCmd: `ccs ${profile}`,
88
- session: env.CCR_SESSION || `ccr-${profile}`,
89
- stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', profile),
92
+ session: env.CCR_SESSION || 'ccr',
93
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
90
94
  instanceDir: path.join(home, '.ccs', 'instances', profile),
91
95
  usesCcs: true,
92
96
  };
@@ -94,7 +98,7 @@ function resolveProfileState(profile, opts = {}) {
94
98
  return {
95
99
  ccCmd: env.CC_BIN || 'claude',
96
100
  session: env.CCR_SESSION || 'ccr',
97
- stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr'),
101
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
98
102
  instanceDir: null,
99
103
  usesCcs: false,
100
104
  };
@@ -179,7 +183,7 @@ function sidecarCols(termCols, fracNum, splitFlag) {
179
183
  *
180
184
  * @param {{ ccCmd: string, settingsFile: string, stateDir: string,
181
185
  * node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
182
- * termCols?: number }} o
186
+ * termCols?: number, title?: string }} o
183
187
  * @returns {string[]}
184
188
  */
185
189
  function buildWtArgs(o) {
@@ -200,6 +204,9 @@ function buildWtArgs(o) {
200
204
  }
201
205
  const frac = sidebarFraction(o.sidebarPct);
202
206
  const splitFlag = sidebarSplitFlag(o.sidebarSide);
207
+ // The tab's ADDRESS ("[profile / ]name") when the instance has one — both
208
+ // halves allow-listed, so it is wt-arg-safe by construction.
209
+ const title = o.title || 'Claude';
203
210
  const exited = path.win32.join(stateDir, 'exited');
204
211
 
205
212
  // After Claude exits we drop the sentinel + clean the settings file, then idle
@@ -224,7 +231,7 @@ function buildWtArgs(o) {
224
231
  const pane1 = paneCommand(stateDir, sidecarBody);
225
232
 
226
233
  return [
227
- '-w', '0', 'new-tab', '--title', 'Claude', 'cmd', '/c', pane0,
234
+ '-w', '0', 'new-tab', '--title', title, 'cmd', '/c', pane0,
228
235
  ';',
229
236
  'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
230
237
  ];
@@ -255,7 +262,8 @@ function defaultWhere(name) {
255
262
  }
256
263
 
257
264
  const inject = require('./settings-inject');
258
- const { ensureSecureDir } = require('./state-dir');
265
+ const slots = require('./instance-slot');
266
+ const { ensureSecureDir, recordLaunchDir } = require('./state-dir');
259
267
 
260
268
  /**
261
269
  * Fill in real-environment implementations for anything the caller didn't
@@ -283,6 +291,11 @@ function withDefaults(deps) {
283
291
  existsDir: deps.existsDir || defaultExistsDir,
284
292
  listDir: deps.listDir || defaultListDir,
285
293
  ensureDir: deps.ensureDir || ensureSecureDir,
294
+ recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
295
+ allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
296
+ prepareInstance: deps.prepareInstance
297
+ || ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
298
+ require('./instance-name').prepareInstance(s, { ...o, home: deps.home || os.homedir() })),
286
299
  removeExited: deps.removeExited || defaultRemoveExited,
287
300
  writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
288
301
  cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
@@ -347,9 +360,10 @@ function fallbackNoWt(d) {
347
360
  *
348
361
  * @param {string} [profile]
349
362
  * @param {Partial<Deps>} [deps]
363
+ * @param {{ name?: string|null }} [opts] explicit --name, already validated
350
364
  * @returns {number}
351
365
  */
352
- function run(profile, deps = {}) {
366
+ function run(profile, deps = {}, opts = {}) {
353
367
  const d = withDefaults(deps);
354
368
 
355
369
  // 1. Validate the profile (it lands in paths and a spawned command).
@@ -362,8 +376,18 @@ function run(profile, deps = {}) {
362
376
  const wt = d.findWt();
363
377
  if (!wt) return fallbackNoWt(d);
364
378
 
365
- // 3. Resolve profile state + required binaries.
366
- const st = resolveProfileState(profile, { env: d.env, home: d.home });
379
+ // 3. Resolve profile state + required binaries. Every launch first claims a
380
+ // free instance slot (src/instance-slot.js) so a second window never shares
381
+ // another's state dir; only an explicit override skips it.
382
+ const slot = d.allocateSlot({ profile, env: d.env, home: d.home });
383
+ if (slot && 'exhausted' in slot) {
384
+ d.err(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
385
+ return 1;
386
+ }
387
+ // The instance's name, profile record and title — same on every platform.
388
+ const inst = slot && !('exhausted' in slot)
389
+ ? d.prepareInstance(slot, { profile, name: opts.name }) : null;
390
+ const st = resolveProfileState(profile, { env: slots.applySlotEnv(d.env, slot), home: d.home });
367
391
  if (st.usesCcs) {
368
392
  if (!d.which('ccs')) {
369
393
  d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
@@ -385,6 +409,9 @@ function run(profile, deps = {}) {
385
409
 
386
410
  // 4. Prepare the per-profile state dir; clear a stale sentinel.
387
411
  try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
412
+ // The tab's stable identity for the git pane. Recorded here because only the
413
+ // launcher knows where ccr was started (src/state-dir.js).
414
+ try { d.recordLaunchDir(st.stateDir, process.cwd()); } catch { /* best effort */ }
388
415
  d.removeExited(st.stateDir);
389
416
 
390
417
  // 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
@@ -404,6 +431,7 @@ function run(profile, deps = {}) {
404
431
  sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
405
432
  sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
406
433
  termCols: d.cols,
434
+ title: inst ? inst.title : undefined,
407
435
  });
408
436
  } catch (e) {
409
437
  d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
@@ -434,6 +462,9 @@ function run(profile, deps = {}) {
434
462
  * @property {(dir: string) => boolean} existsDir
435
463
  * @property {(dir: string) => string[]} listDir
436
464
  * @property {(dir: string) => void} ensureDir
465
+ * @property {(dir: string, cwd: string) => void} recordLaunchDir
466
+ * @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
467
+ * @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
437
468
  * @property {(dir: string) => void} removeExited
438
469
  * @property {(settings: object) => string} writeSettings
439
470
  * @property {(file: string) => void} cleanup
package/src/migrate.js ADDED
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/migrate.js — move a 0.3 ccr home to the 0.4 container layout, once,
4
+ // safely (features/instance-migration.feature).
5
+ //
6
+ // There is nothing to relocate but HISTORY: the only persistent content the
7
+ // 0.3 layout ever held was account burn history, misplaced inside profile
8
+ // dirs because the logger wrote to whatever the state dir was. So migration
9
+ // HARVESTS burnlogs to the container's top level, SWEEPS the profile dirs and
10
+ // the loose ephemeral droppings (a dead session's captured status, heartbeat,
11
+ // sentinel), and writes the ".layout" marker LAST — an interrupted migration
12
+ // is indistinguishable from one that has not started, and every move is
13
+ // move-if-present, so the next launch simply completes it.
14
+ //
15
+ // THE GENERAL SAFETY PROPERTY, ruled: if the source is not what migration
16
+ // expects, it STOPS AND CHANGES NOTHING. That is also the entire handling of
17
+ // an entry named "instances" or "profiles" (reserved by the new layout) and
18
+ // of a CCS profile literally named either — declined as real scope ("no other
19
+ // users will have this problem"); the stop makes it fail loudly for free.
20
+ //
21
+ // Runs AT LAUNCH ONLY. `ccr statusline` — invoked headlessly by Claude
22
+ // mid-session — never calls this. Removed at 1.0.0.
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const os = require('node:os');
27
+
28
+ const MARKER = '.layout';
29
+
30
+ // The 0.3 layout's loose per-session droppings — dead state the ephemeral
31
+ // rule says dies. Everything here may appear at the container root (bare
32
+ // launches) or inside a profile dir.
33
+ const DROPPINGS = ['last-status.json', 'launch-cwd', 'exited', 'sidecar-alive', 'slot-owner', 'view-request'];
34
+
35
+ const BURNLOG_RE = /^burnlog-[A-Za-z0-9_-]+\.jsonl$/;
36
+ // 0.4's own session join key (src/session-log.js). An upgrade while a session
37
+ // is open leaves the new statusline ticking against an unmigrated home, so
38
+ // these appear at the root BEFORE any launch migrates it — already at their
39
+ // final location, they are kept, not a surprise.
40
+ const SESSION_RE = /^session-[A-Za-z0-9_-]+\.jsonl$/;
41
+ // Same allow-list the launcher enforces for profile names.
42
+ const PROFILE_DIR_RE = /^[A-Za-z0-9._-]+$/;
43
+ // Names the new layout owns; their presence in an unmigrated home is exactly
44
+ // the surprise the safety property exists for.
45
+ const RESERVED = new Set(['instances', 'profiles']);
46
+
47
+ /**
48
+ * Is a 0.3 session still running in this dir, as far as 0.3 state can say?
49
+ * The published 0.3 recorded no owning pid, so the freshest signal it left is
50
+ * the sidecar heartbeat. Refusing on a live heartbeat is the safe direction:
51
+ * a false "live" delays migration by seconds; a false "dead" would harvest
52
+ * burnlogs out from under a writing session.
53
+ * @param {string} dir
54
+ */
55
+ function legacyLive(dir) {
56
+ try {
57
+ if (fs.existsSync(path.join(dir, 'exited'))) return false;
58
+ return require('./sidecar').sidecarAlive(dir);
59
+ } catch { return false; }
60
+ }
61
+
62
+ /**
63
+ * Move a burnlog to the container root, first-wins on a name collision (the
64
+ * name carries the Claude session id, so a collision is the same session's
65
+ * data twice — the copy already at the root is kept).
66
+ * @param {string} from @param {string} rootDir @param {string} name
67
+ */
68
+ function harvestBurnlog(from, rootDir, name) {
69
+ const dest = path.join(rootDir, name);
70
+ try {
71
+ if (fs.existsSync(dest)) { fs.rmSync(from, { force: true }); return; }
72
+ fs.renameSync(from, dest);
73
+ } catch { /* best effort — a later launch completes it */ }
74
+ }
75
+
76
+ /**
77
+ * Bring <home>/.ccr to the 0.4 layout. Total: never throws.
78
+ *
79
+ * @param {{ home?: string }} [opts]
80
+ * @returns {{ ok: true } | { ok: false, error: string }}
81
+ */
82
+ function ensureLayout(opts = {}) {
83
+ const home = opts.home || os.homedir();
84
+ const root = path.join(home, '.ccr');
85
+ const marker = path.join(root, MARKER);
86
+
87
+ try {
88
+ if (!fs.existsSync(root)) {
89
+ // Fresh install: create the container already migrated.
90
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
91
+ fs.writeFileSync(marker, '1\n', { mode: 0o600 });
92
+ return { ok: true };
93
+ }
94
+ if (fs.existsSync(marker)) {
95
+ sweepLegacyDroppings(root);
96
+ return { ok: true };
97
+ }
98
+
99
+ // ---- Classify EVERYTHING before touching ANYTHING. ----
100
+ /** @type {{ dir: string, name: string }[]} */
101
+ const profileDirs = [];
102
+ /** @type {string[]} */
103
+ const looseBurnlogs = [];
104
+ let rootHasDroppings = false;
105
+ for (const name of fs.readdirSync(root)) {
106
+ const p = path.join(root, name);
107
+ if (RESERVED.has(name)) return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}' (reserved by the new layout); move it aside and relaunch` };
108
+ if (name.startsWith('.')) continue; // dotted container entries are left alone
109
+ if (BURNLOG_RE.test(name)) { looseBurnlogs.push(name); continue; }
110
+ if (SESSION_RE.test(name)) continue; // 0.4 join key — already at its final home
111
+ if (DROPPINGS.includes(name)) { rootHasDroppings = true; continue; }
112
+ let st; try { st = fs.lstatSync(p); } catch { continue; }
113
+ if (st.isDirectory() && PROFILE_DIR_RE.test(name)) { profileDirs.push({ dir: p, name }); continue; }
114
+ return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}'; move it aside and relaunch` };
115
+ }
116
+
117
+ // A live 0.3 session anywhere refuses the whole migration, by name.
118
+ if (legacyLive(root)) return { ok: false, error: 'ccr: cannot migrate ~/.ccr while a session is running there — close it first' };
119
+ for (const { dir, name } of profileDirs) {
120
+ if (legacyLive(dir)) return { ok: false, error: `ccr: cannot migrate ~/.ccr while an instance is running — close '${name}' first` };
121
+ }
122
+
123
+ // ---- Harvest, then sweep, then mark. ----
124
+ for (const { dir } of profileDirs) {
125
+ let names; try { names = fs.readdirSync(dir); } catch { continue; }
126
+ for (const n of names) if (BURNLOG_RE.test(n)) harvestBurnlog(path.join(dir, n), root, n);
127
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* next launch retries */ }
128
+ }
129
+ if (rootHasDroppings) for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
130
+ try { fs.chmodSync(root, 0o700); } catch { /* best effort */ }
131
+ fs.writeFileSync(marker, '1\n', { mode: 0o600 });
132
+ return { ok: true };
133
+ } catch (e) {
134
+ return { ok: false, error: `ccr: migration failed: ${e instanceof Error ? e.message : String(e)}` };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A DOWNGRADE-then-re-upgrade leaves a migrated container plus a 0.3 session's
140
+ * loose droppings at the root — a dead instance's live state, which the
141
+ * ephemeral rule says dies. Its burnlogs already live where the pool is, so
142
+ * the day's history merges for free. Swept only once that session shows no
143
+ * heartbeat (the safe direction, same as everywhere else).
144
+ * @param {string} root
145
+ */
146
+ function sweepLegacyDroppings(root) {
147
+ try {
148
+ if (!fs.existsSync(path.join(root, 'last-status.json'))
149
+ && !fs.existsSync(path.join(root, 'sidecar-alive'))) return;
150
+ if (legacyLive(root)) return;
151
+ for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
152
+ } catch { /* best effort */ }
153
+ }
154
+
155
+ module.exports = { ensureLayout, MARKER };
@@ -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
+ };