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.
- package/CHANGELOG.md +114 -0
- package/README.md +107 -12
- package/bin/ccr.js +233 -26
- package/package.json +8 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +34 -3
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/history-privacy.js +435 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +202 -11
- package/src/migrate.js +155 -0
- package/src/pane-config.js +40 -6
- package/src/render/economy.js +30 -5
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +69 -1
- package/src/render/statusline.js +50 -5
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +167 -0
- package/src/sidecar.js +174 -27
- package/src/state-dir.js +61 -1
package/src/pane-config.js
CHANGED
|
@@ -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(
|
|
93
|
-
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes))
|
|
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 };
|
package/src/render/economy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
};
|
package/src/render/shared.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/src/render/statusline.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
|
37
|
-
|
|
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
|
}
|