claude-code-runrate 0.4.0 → 0.6.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 +154 -0
- package/README.md +28 -9
- package/bin/ccr.js +12 -2
- package/package.json +7 -3
- package/scripts/launch.sh +8 -1
- package/sidecar/ccr.tmux.conf +38 -0
- package/src/doctor.js +61 -5
- package/src/launch-win.js +168 -8
- package/src/pane-config.js +40 -6
- package/src/render/economy.js +30 -5
- package/src/render/shared.js +21 -1
- package/src/render/statusline.js +10 -3
- package/src/sidecar-keys.js +16 -3
- package/src/sidecar.js +30 -8
- package/src/state-dir.js +18 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Every release also has a [GitHub release](https://github.com/bingh0/ccr/releases)
|
|
4
|
+
and a release commit, and those carry the full notes — what was measured, what
|
|
5
|
+
was rejected, and why. This file is the index: enough to decide whether a
|
|
6
|
+
version is worth upgrading to, and where to read the rest.
|
|
7
|
+
|
|
8
|
+
Dates are npm publish dates, because that is when a version reached anyone.
|
|
9
|
+
|
|
10
|
+
## 0.6.0 — 2026-09-01
|
|
11
|
+
|
|
12
|
+
The remote-session release. Over mosh, two things had been degrading in
|
|
13
|
+
silence — every clipboard copy was dropped in transit, and 24-bit colour was
|
|
14
|
+
quietly flattened to 256 — and the tmux version floor the sidecar had grown
|
|
15
|
+
was written down nowhere. All three were measured at the terminal, reading
|
|
16
|
+
the raw bytes that reach a mosh client, not inferred.
|
|
17
|
+
|
|
18
|
+
- **The clipboard survives mosh.** tmux's built-in OSC 52 emits an empty
|
|
19
|
+
selection field and mosh's parser accepts only an explicit `c`, so every
|
|
20
|
+
copy vanished between the two. The shipped tmux config now spells the
|
|
21
|
+
selection out — and names `c` explicitly, which is *more* compatible than
|
|
22
|
+
tmux's default everywhere, not a mosh-only workaround.
|
|
23
|
+
- **True colour survives mosh.** mosh forces `TERM=xterm-256color` and drops
|
|
24
|
+
`COLORTERM`, which tmux reads as "no RGB" and silently downgrades every
|
|
25
|
+
24-bit colour to the nearest 256. The config now declares RGB for that
|
|
26
|
+
TERM, restoring what mosh was carrying fine all along.
|
|
27
|
+
- **The tmux floor is declared: 3.2.** Percentage splits and
|
|
28
|
+
`terminal-features` need 3.1; the pane-scoped hooks that keep the sidebar
|
|
29
|
+
out of copy-mode need 3.2. Below the floor each failure is silent and looks
|
|
30
|
+
like something else, so `ccr doctor` now reports the tmux version and names
|
|
31
|
+
one that is too old — and an unreadable version is reported as unreadable,
|
|
32
|
+
never manufactured into a failure.
|
|
33
|
+
- **The sidebar split speaks tmux 3.4's dialect.** `-p 34` was deprecated in
|
|
34
|
+
3.1 and rejected outright in 3.4; the launcher sizes with `-l 34%`, which
|
|
35
|
+
every supported tmux accepts.
|
|
36
|
+
- **The Node floor rises to 22.17.** Node 18 and 20 are past their end of
|
|
37
|
+
life, and the vendored test runner's own floor is 22.17 — testing below it
|
|
38
|
+
produced a lane that hung rather than failed. `engines`, the CI matrix,
|
|
39
|
+
`ccr doctor`'s check, and the spec all state the same floor now, and the
|
|
40
|
+
matrix pins 22.17 exactly so the floor is tested, not just declared.
|
|
41
|
+
- **The acceptance suite hardened underneath.** The vendored Gherkin runner
|
|
42
|
+
moved to gherkin-node-test 0.11.0, whose step linter enforces a rule the
|
|
43
|
+
suite now lives by: a negative assertion over a literal needle passes
|
|
44
|
+
forever once the needle rots, so *absence must be earned* — by a control
|
|
45
|
+
proving the needle can still find, or a sanction naming what proves it.
|
|
46
|
+
Every structural refusal in the step layer (the renderer spawns nothing,
|
|
47
|
+
the launcher reads no blob, the sidecar has no input channel) now carries
|
|
48
|
+
one or the other, and both of 0.11's linters gate the suite.
|
|
49
|
+
|
|
50
|
+
## 0.5.0 — 2026-08-22
|
|
51
|
+
|
|
52
|
+
The Windows release. The things that could not be reasoned about were measured
|
|
53
|
+
on a real Windows 11 machine, because none of them are reachable from CI: every
|
|
54
|
+
Windows test injects the launcher, so the suite proves the argv `ccr` builds and
|
|
55
|
+
nothing about what Windows Terminal does when handed it.
|
|
56
|
+
|
|
57
|
+
- **The panes open where `ccr` was run.** `wt.exe` does not inherit the working
|
|
58
|
+
directory the way `tmux new-session` does, so every pane opened in
|
|
59
|
+
`%USERPROFILE%` while the launch record claimed otherwise — and the sidecar
|
|
60
|
+
prefers that record, so the git pane described the project in full confidence
|
|
61
|
+
for a terminal that was not in it. One source now feeds both.
|
|
62
|
+
- **Measured limits, and they are real.** Past 256 characters `wt` opens *no
|
|
63
|
+
tab at all*; a UNC path opens a tab and silently lands in `%SystemRoot%`; a
|
|
64
|
+
semicolon costs the whole tab; a backtick does not, so refusing it was wrong
|
|
65
|
+
and no longer happens. Each unusable path degrades — the panes open in the
|
|
66
|
+
default directory and stderr names which of the four reasons applied.
|
|
67
|
+
- **The sidecar pane has a cycle key** on Windows Terminal. F3 or Space. F3 has
|
|
68
|
+
three encodings and on Windows which one arrives depends on the *Node*
|
|
69
|
+
version, so `ccr` answers all three rather than pinning a Node floor for a
|
|
70
|
+
keystroke.
|
|
71
|
+
- **A pane config that says what is wrong.** A UTF-8 BOM is stripped; UTF-16 —
|
|
72
|
+
what PowerShell 5.1 writes for `>` and `Out-File` — is named as an encoding
|
|
73
|
+
problem instead of reported as bad JSON; `~\` expands as well as `~/`; and a
|
|
74
|
+
broken config says so on the panel and in `ccr doctor`, which reports pane
|
|
75
|
+
paths as *resolved*.
|
|
76
|
+
- **`ccr sidecar` resolves the instance from the working directory**, which the
|
|
77
|
+
feature files and the README had both described for a while.
|
|
78
|
+
- **The economy screen earns a decimal** where the next tenth is a decision: at
|
|
79
|
+
or past 95% used, one truncated digit, so `floor(shown)` still matches
|
|
80
|
+
`/usage`. A snapshot that stopped refreshing dims rather than blanks.
|
|
81
|
+
|
|
82
|
+
## 0.4.0 — 2026-08-07
|
|
83
|
+
|
|
84
|
+
[The instance layer and the git pane.](https://github.com/bingh0/ccr/releases/tag/v0.4.0)
|
|
85
|
+
|
|
86
|
+
- **Instances.** Every launch is a named, slotted instance under one container
|
|
87
|
+
home; the name is derived from the project or given with `-i`, and travels
|
|
88
|
+
into the window title, status line and sidebar.
|
|
89
|
+
- **A git pane** on F3: which repository the tab is in, the working tree, and
|
|
90
|
+
recent history in lanes. Read-only — it never writes to the repository.
|
|
91
|
+
- The three launchers (tmux, Windows Terminal, VS Code) take naming, titles and
|
|
92
|
+
retention from one shared path rather than the tmux path owning them.
|
|
93
|
+
|
|
94
|
+
## 0.3.0 — 2026-08-04
|
|
95
|
+
|
|
96
|
+
[External tool panes in the sidebar.](https://github.com/bingh0/ccr/releases/tag/v0.3.0)
|
|
97
|
+
|
|
98
|
+
- **Panes from other tools.** The sidebar hosts full-height read-only panes
|
|
99
|
+
rendered from small JSON blobs another tool writes beside its own artifacts.
|
|
100
|
+
You list the path in `~/.config/ccr/config.json`; F3 cycles.
|
|
101
|
+
- A rate-limit correctness fix, and `strict` type checking across the codebase.
|
|
102
|
+
|
|
103
|
+
## 0.2.4 — 2026-07-12
|
|
104
|
+
|
|
105
|
+
[VS Code sidecars stop piling up; profile state-dir fix.](https://github.com/bingh0/ccr/releases/tag/v0.2.4)
|
|
106
|
+
|
|
107
|
+
- One live sidecar per session: relaunching inside VS Code no longer
|
|
108
|
+
accumulates identical panes.
|
|
109
|
+
- Profile sessions write their snapshots to the right state dir.
|
|
110
|
+
|
|
111
|
+
## 0.2.3 — 2026-07-03
|
|
112
|
+
|
|
113
|
+
[Sidecars agree on 5h/weekly across profiles.](https://github.com/bingh0/ccr/releases/tag/v0.2.3)
|
|
114
|
+
|
|
115
|
+
- The 5h and weekly walls are one account-wide resource, but each profile only
|
|
116
|
+
captures its own; two sidecars on the same account could disagree. They
|
|
117
|
+
reconcile now.
|
|
118
|
+
|
|
119
|
+
## 0.2.2 — 2026-07-02
|
|
120
|
+
|
|
121
|
+
[Aligned meter bars + a staleness marker.](https://github.com/bingh0/ccr/releases/tag/v0.2.2)
|
|
122
|
+
|
|
123
|
+
- A barely-used window projects an enormous time-to-exhaust, which overflowed
|
|
124
|
+
the fixed time column and shoved that row's bar out of line. Long horizons
|
|
125
|
+
are capped, so the bars stay aligned and the figure reads more honestly.
|
|
126
|
+
- A stale snapshot is annotated (`· updated Nm ago`) rather than silently
|
|
127
|
+
frozen. Never a wipe — Claude Code legitimately stops ticking during a long
|
|
128
|
+
operation.
|
|
129
|
+
|
|
130
|
+
## 0.2.1 — 2026-06-26
|
|
131
|
+
|
|
132
|
+
[Windows Terminal teardown and sidecar width fixes.](https://github.com/bingh0/ccr/releases/tag/v0.2.1)
|
|
133
|
+
|
|
134
|
+
- ConPTY makes a split pane's reported width unreliable, so the panel
|
|
135
|
+
soft-wrapped. The launcher injects the computed width and the sidecar clamps
|
|
136
|
+
to the smaller of the two.
|
|
137
|
+
- On exit the sidecar pane collapses first and the border sweeps left to right,
|
|
138
|
+
fixing an ordering bug where Claude's pane could close first.
|
|
139
|
+
|
|
140
|
+
## 0.2.0 — 2026-06-26
|
|
141
|
+
|
|
142
|
+
[Native Windows and VS Code live sidecars.](https://github.com/bingh0/ccr/releases/tag/v0.2.0)
|
|
143
|
+
|
|
144
|
+
- **Native Windows** — the live sidecar hosted in Windows Terminal split panes,
|
|
145
|
+
no tmux, bash or WSL, with a graceful fallback when `wt.exe` is absent.
|
|
146
|
+
- **VS Code integrated terminal on any OS** — split-pane sidecar via a
|
|
147
|
+
clipboard one-liner.
|
|
148
|
+
- `statusLine` injected per launch through a temp settings file, so `~/.claude`
|
|
149
|
+
is never mutated.
|
|
150
|
+
|
|
151
|
+
## 0.1.0 — 2026-06-24
|
|
152
|
+
|
|
153
|
+
First publish: the CLI and status line — `economy`, `resume`, `statusline` —
|
|
154
|
+
with the live sidebar on tmux. Native Windows arrived in 0.2.0.
|
package/README.md
CHANGED
|
@@ -69,8 +69,8 @@ On Linux/macOS the default host is `tmux`. No WSL required.
|
|
|
69
69
|
|
|
70
70
|
## Requirements
|
|
71
71
|
|
|
72
|
-
- **Node ≥
|
|
73
|
-
- For the optional live `ccr sidecar`:
|
|
72
|
+
- **Node ≥ 22.17** — that's it for the core. **Zero runtime dependencies.**
|
|
73
|
+
- For the optional live `ccr sidecar`: **`tmux` ≥ 3.2** on Linux/macOS, or **Windows
|
|
74
74
|
Terminal** / **VS Code's integrated terminal** on Windows (no WSL).
|
|
75
75
|
|
|
76
76
|
## VS Code (split terminal)
|
|
@@ -173,11 +173,11 @@ small JSON blob beside its own artifacts; you list that file's path in ccr's
|
|
|
173
173
|
config; the sidebar cycles between the economy panel, the built-in git pane, and
|
|
174
174
|
each configured pane.
|
|
175
175
|
|
|
176
|
-
**Cycling views.** Under tmux the launcher binds **F3
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
pane and press **Space**
|
|
180
|
-
lives in a separate parent process that runs the panel as a child, the same
|
|
176
|
+
**Cycling views.** Under tmux the launcher binds **F3** at the host. **Windows
|
|
177
|
+
Terminal** binds no key of its own, and neither do VS Code and its forks (Cursor,
|
|
178
|
+
Positron, Antigravity) — so on those the sidecar pane carries its own key: click
|
|
179
|
+
the pane and press **Space** or **F3**. The renderer still reads no input — the
|
|
180
|
+
key lives in a separate parent process that runs the panel as a child, the same
|
|
181
181
|
separation tmux enforces. Anywhere else, `ccr cycle-view -i <name>` (or
|
|
182
182
|
`--state-dir <dir>`); a bare `ccr cycle-view` resolves the instance from your
|
|
183
183
|
working directory, like `ccr economy` does.
|
|
@@ -191,6 +191,19 @@ read from a repository:
|
|
|
191
191
|
{ "panes": [ { "path": "/home/you/project/.your-tool/sidecar.json" } ] }
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
A leading `~` expands, with either separator (`~/tools/blob.json`,
|
|
195
|
+
`~\tools\blob.json`), and a relative path resolves against the config file's own
|
|
196
|
+
directory. **On Windows, save the file as UTF-8** — `>` and `Out-File` in Windows
|
|
197
|
+
PowerShell 5.1 write UTF-16, which is not JSON as far as any parser is concerned:
|
|
198
|
+
|
|
199
|
+
```powershell
|
|
200
|
+
Set-Content -Encoding utf8 $env:USERPROFILE\.config\ccr\config.json $json
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`ccr doctor` reports what it read out of the config — the paths as **resolved**,
|
|
204
|
+
not as written — and names the problem when there is one, so a pane that never
|
|
205
|
+
appears is a question you can answer rather than a silence.
|
|
206
|
+
|
|
194
207
|
A pane is a full-height view carrying the producing tool's own rows — here
|
|
195
208
|
`gherkin-trace`, whose blob ships as the golden example:
|
|
196
209
|
|
|
@@ -253,10 +266,16 @@ method has followed since, and is honest about which point on that line this
|
|
|
253
266
|
repository represents.
|
|
254
267
|
|
|
255
268
|
```bash
|
|
256
|
-
npm test
|
|
257
|
-
npm run typecheck
|
|
269
|
+
npm test # node --test — harness self-tests + feature scenarios
|
|
270
|
+
npm run typecheck # tsc --noEmit over @ts-check'd JS (needs: npm i first)
|
|
271
|
+
npm run install-hooks # copy .githooks/ into this clone (run once)
|
|
258
272
|
```
|
|
259
273
|
|
|
274
|
+
`install-hooks` installs a fail-closed `pre-push` guard for the public remote:
|
|
275
|
+
only `main`, fast-forward only, and a scan of the commits the push would make
|
|
276
|
+
public. It is worth running in any clone you push from — a push is the moment
|
|
277
|
+
history stops being private, and it is the last moment the check can help.
|
|
278
|
+
|
|
260
279
|
- **No runtime dependencies**, ever — it's what lets `npx claude-code-runrate` install
|
|
261
280
|
instantly on every OS, including native Windows.
|
|
262
281
|
- **Dev-only** tooling (`typescript`, `@types/node`) exists solely for
|
package/bin/ccr.js
CHANGED
|
@@ -323,8 +323,18 @@ function cmdResume(arg) {
|
|
|
323
323
|
* @returns {number | undefined}
|
|
324
324
|
*/
|
|
325
325
|
function cmdSidecar(stateDir, showHint, exitOnEnd, useKeys, view, target = null) {
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
// Resolve whether or not -i was typed, which is what `ccr cycle-view` has
|
|
327
|
+
// always done. Consulting the chain ONLY for -i meant a bare `ccr sidecar`
|
|
328
|
+
// skipped every other link in it — including the one that matters most here,
|
|
329
|
+
// "the live instance whose launch directory contains the cwd" — and landed on
|
|
330
|
+
// the CONTAINER (~/.ccr) instead. The container holds no session, so the pane
|
|
331
|
+
// sat empty next to a running instance, and the fix looked like naming the
|
|
332
|
+
// instance exactly right when the whole point of the chain is not having to.
|
|
333
|
+
//
|
|
334
|
+
// --hint is left out: it prints the VS Code split instructions, which are
|
|
335
|
+
// worth printing before any instance exists, so requiring a live one to
|
|
336
|
+
// explain how to start one would be backwards.
|
|
337
|
+
if (!stateDir && !showHint) {
|
|
328
338
|
const res = require('../src/instance-resolve').resolveInstance({ target, command: 'sidecar' });
|
|
329
339
|
if (!res.ok) { process.stderr.write(res.error + '\n'); return 1; }
|
|
330
340
|
stateDir = res.stateDir;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-runrate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bing Ho <reps-attic-riot@duck.com>",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"ccr": "bin/ccr.js"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
20
|
+
"node": ">=22.17"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"bin",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"scripts/launch.sh",
|
|
26
26
|
"sidecar",
|
|
27
27
|
"README.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
28
29
|
"LICENSE"
|
|
29
30
|
],
|
|
30
31
|
"scripts": {
|
|
@@ -32,7 +33,10 @@
|
|
|
32
33
|
"typecheck": "tsc --noEmit -p jsconfig.json",
|
|
33
34
|
"lint": "oxlint",
|
|
34
35
|
"release-gate": "node scripts/release-gate.js",
|
|
35
|
-
"
|
|
36
|
+
"install-hooks": "node scripts/install-hooks.js",
|
|
37
|
+
"probe:wt": "node scripts/probe-wt.js",
|
|
38
|
+
"scan-package": "node scripts/scan-package.js",
|
|
39
|
+
"prepublishOnly": "npm run release-gate && npm run scan-package && npm run lint && npm run typecheck && npm test"
|
|
36
40
|
},
|
|
37
41
|
"keywords": [
|
|
38
42
|
"claude-code",
|
package/scripts/launch.sh
CHANGED
|
@@ -135,7 +135,14 @@ if [ -n "${CCR_TITLE:-}" ]; then
|
|
|
135
135
|
fi
|
|
136
136
|
|
|
137
137
|
# Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
|
|
138
|
-
|
|
138
|
+
# The width is `-l <pct>%`, NOT `-p <pct>`. Both spell the same split, but `-p` is the
|
|
139
|
+
# deprecated form and tmux 3.4 — what Ubuntu 24.04 LTS ships, i.e. every stock Ubuntu
|
|
140
|
+
# Server — rejects it with `size missing`. That killed the launcher AFTER new-session
|
|
141
|
+
# had already succeeded, so the failure mode was silent and confusing: a live Claude
|
|
142
|
+
# pane, no sidebar, no error on screen (launch.sh's stderr goes to the terminal it was
|
|
143
|
+
# spawned from, which is gone by then). `-l N%` is accepted by 3.1 through 3.6+ alike
|
|
144
|
+
# and produces an identical split, so there is no version guard to maintain.
|
|
145
|
+
SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -l "${CCR_SIDEBAR_PCT:-34}%" -P -F '#{pane_id}' \
|
|
139
146
|
"$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
|
|
140
147
|
|
|
141
148
|
# The sidebar is a live dashboard — there is nothing to scroll. A stray mouse-wheel
|
package/sidecar/ccr.tmux.conf
CHANGED
|
@@ -22,3 +22,41 @@ set -g set-clipboard on
|
|
|
22
22
|
|
|
23
23
|
# prefix + P — dump the whole pane scrollback to a file, regardless of length.
|
|
24
24
|
bind-key P command-prompt -p 'save pane to:' 'capture-pane -S - ; save-buffer %1'
|
|
25
|
+
|
|
26
|
+
# --- Remote sessions: true colour and a clipboard that survives mosh ---------
|
|
27
|
+
#
|
|
28
|
+
# Both lines matter only over a remote transport, and mosh is the case that
|
|
29
|
+
# breaks without them. Measured over mosh -> tmux, reading the raw bytes that
|
|
30
|
+
# reach the client; neither is guesswork.
|
|
31
|
+
#
|
|
32
|
+
# 1. TRUE COLOUR. mosh forces TERM=xterm-256color and DROPS COLORTERM. tmux
|
|
33
|
+
# infers RGB support from COLORTERM, so with it gone tmux silently
|
|
34
|
+
# downgrades 24-bit colour to the nearest 256 (38;2;R;G;B -> 38;5;N). mosh
|
|
35
|
+
# itself carries 24-bit fine; tmux is the one degrading it. Declaring the
|
|
36
|
+
# feature restores it.
|
|
37
|
+
#
|
|
38
|
+
# 2. CLIPBOARD. tmux's built-in OSC 52 emits an EMPTY selection field
|
|
39
|
+
# (`\033]52;;<base64>`), and mosh's OSC 52 parser accepts ONLY an explicit
|
|
40
|
+
# `c`. So every copy is silently dropped in transit. This override spells
|
|
41
|
+
# the selection out. Three details are load-bearing:
|
|
42
|
+
# * `%p1%.0s` CONSUMES the selection parameter while printing nothing.
|
|
43
|
+
# Omitting it leaves p1 unconsumed and the expansion is invalid.
|
|
44
|
+
# * `\007`, never `\7`. tmux 3.x SILENTLY REJECTS a terminal-overrides
|
|
45
|
+
# value containing `\7` — the option is discarded whole with no error,
|
|
46
|
+
# and `tmux show -s terminal-overrides` simply will not list it. Older
|
|
47
|
+
# guides on the web all use `\7`; that spelling was fine on tmux 2.x.
|
|
48
|
+
# * terminal-overrides keys on the client's TERM, so this matches when
|
|
49
|
+
# TERM is xterm-256color — which is exactly what mosh sets.
|
|
50
|
+
# Emitting an explicit `c` is also strictly more compatible than tmux's
|
|
51
|
+
# default empty field, so this is not a mosh-only workaround.
|
|
52
|
+
#
|
|
53
|
+
# Both require tmux 3.1+; ccr asks for 3.2+ overall (see `ccr doctor`).
|
|
54
|
+
#
|
|
55
|
+
# RGB is declared unconditionally. 24-bit has been table stakes for years —
|
|
56
|
+
# iTerm2, Ghostty, WezTerm, kitty, Alacritty, Konsole, every libvte terminal
|
|
57
|
+
# (GNOME Terminal et al), Windows Terminal and PuTTY all handle it. The lone
|
|
58
|
+
# holdout that reports TERM=xterm-256color without it is macOS Terminal.app
|
|
59
|
+
# before macOS 26, which degrades rather than breaking. Optimising the default
|
|
60
|
+
# for that shrinking case would cost every other user their colours.
|
|
61
|
+
set -as terminal-features ',xterm-256color:RGB'
|
|
62
|
+
set -as terminal-overrides ",xterm-256color:Ms=\E]52;c%p1%.0s;%p2%s\007"
|
package/src/doctor.js
CHANGED
|
@@ -9,6 +9,7 @@ const path = require('node:path');
|
|
|
9
9
|
const os = require('node:os');
|
|
10
10
|
const { spawnSync } = require('node:child_process');
|
|
11
11
|
const { stripControl } = require('./sanitize');
|
|
12
|
+
const { loadPaneConfig } = require('./pane-config');
|
|
12
13
|
|
|
13
14
|
const ok = (/** @type {string} */ s) => `\x1b[32m✓\x1b[0m ${s}`;
|
|
14
15
|
const bad = (/** @type {string} */ s) => `\x1b[31m✗\x1b[0m ${s}`;
|
|
@@ -34,15 +35,36 @@ function isExec(/** @type {string} */ f) {
|
|
|
34
35
|
try { return (fs.statSync(f).mode & 0o111) !== 0; } catch { return false; }
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// ccr's tmux dialect has a floor. 3.1 is HARD: percentage splits
|
|
39
|
+
// (`split-window -l 34%`, since `-p` was deprecated there and 3.4+ rejects it)
|
|
40
|
+
// and `terminal-features`, which sidecar/ccr.tmux.conf needs for true colour
|
|
41
|
+
// over mosh. 3.2 adds pane-scoped hooks, which the launcher uses to keep the
|
|
42
|
+
// sidebar out of copy-mode; below that it degrades quietly rather than
|
|
43
|
+
// breaking, so 3.2 is what we ask for and 3.1 is what we tolerate.
|
|
44
|
+
const TMUX_MIN = { major: 3, minor: 2 };
|
|
45
|
+
/** @param {string} bin @returns {{major:number,minor:number}|null} */
|
|
46
|
+
function tmuxVersion(bin) {
|
|
47
|
+
try {
|
|
48
|
+
const r = spawnSync(bin, ['-V'], { encoding: 'utf8' });
|
|
49
|
+
if (r.status !== 0) return null;
|
|
50
|
+
// "tmux 3.7c", "tmux 3.2a", "tmux next-3.6" — only the numbers matter.
|
|
51
|
+
const m = /(\d+)\.(\d+)/.exec(r.stdout || '');
|
|
52
|
+
return m ? { major: Number(m[1]), minor: Number(m[2]) } : null;
|
|
53
|
+
} catch { return null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
/**
|
|
38
57
|
* @param {{ platform?: string, has?: (cmd: string) => (string|null),
|
|
39
|
-
*
|
|
58
|
+
* tmuxVersion?: (bin: string) => ({major:number,minor:number}|null),
|
|
59
|
+
* homedir?: string, repo?: string, write?: (s: string) => void,
|
|
60
|
+
* env?: Record<string, string|undefined> }} [opts]
|
|
40
61
|
* side effects are injectable for testing; defaults hit the real environment
|
|
41
62
|
* @returns {number} exit code (0 = healthy)
|
|
42
63
|
*/
|
|
43
64
|
function run(opts = {}) {
|
|
44
65
|
const platform = opts.platform || process.platform;
|
|
45
66
|
const hasFn = opts.has || has;
|
|
67
|
+
const tmuxVerFn = opts.tmuxVersion || tmuxVersion;
|
|
46
68
|
const homedir = opts.homedir || os.homedir();
|
|
47
69
|
const REPO = opts.repo || path.join(__dirname, '..');
|
|
48
70
|
const write = opts.write || ((s) => { process.stdout.write(s); });
|
|
@@ -51,8 +73,8 @@ function run(opts = {}) {
|
|
|
51
73
|
let problems = 0;
|
|
52
74
|
|
|
53
75
|
const [maj, min] = process.versions.node.split('.').map(Number);
|
|
54
|
-
const nodeOk = maj >
|
|
55
|
-
out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >=
|
|
76
|
+
const nodeOk = maj > 22 || (maj === 22 && min >= 17);
|
|
77
|
+
out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 22.17`));
|
|
56
78
|
if (!nodeOk) problems++;
|
|
57
79
|
|
|
58
80
|
const ccr = hasFn('ccr');
|
|
@@ -72,8 +94,24 @@ function run(opts = {}) {
|
|
|
72
94
|
// temp settings file, so there's no shipped shim asset to check on Windows.
|
|
73
95
|
} else {
|
|
74
96
|
const tmux = hasFn('tmux');
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
if (!tmux) {
|
|
98
|
+
out.push(warn('tmux missing — needed for the `ccr [profile]` sidebar'));
|
|
99
|
+
problems++;
|
|
100
|
+
} else {
|
|
101
|
+
const v = tmuxVerFn(tmux);
|
|
102
|
+
// An unreadable version is not evidence of a problem — say so and move on
|
|
103
|
+
// rather than manufacturing a failure out of a parse miss.
|
|
104
|
+
if (!v) {
|
|
105
|
+
out.push(ok(`tmux (${stripControl(tmux)})`) + dim(' — version unreadable'));
|
|
106
|
+
} else if (v.major < TMUX_MIN.major
|
|
107
|
+
|| (v.major === TMUX_MIN.major && v.minor < TMUX_MIN.minor)) {
|
|
108
|
+
out.push(warn(`tmux ${v.major}.${v.minor} (${stripControl(tmux)}) — ccr wants `
|
|
109
|
+
+ `${TMUX_MIN.major}.${TMUX_MIN.minor}+; below 3.1 the sidebar split fails outright`));
|
|
110
|
+
problems++;
|
|
111
|
+
} else {
|
|
112
|
+
out.push(ok(`tmux ${v.major}.${v.minor} (${stripControl(tmux)})`));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
77
115
|
out.push(hasFn('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
|
|
78
116
|
|
|
79
117
|
const sl = path.join(REPO, 'sidecar', 'ccr-statusline');
|
|
@@ -91,6 +129,24 @@ function run(opts = {}) {
|
|
|
91
129
|
out.push(dim('· ccs not installed (optional — only for `ccr <profile>`)'));
|
|
92
130
|
}
|
|
93
131
|
|
|
132
|
+
// Pane wiring. This command exists to diagnose "nothing happens", and a pane
|
|
133
|
+
// config the user wrote and got wrong is exactly that — the sidecar has room
|
|
134
|
+
// for a one-line marker and no more. Here there is room for the path, the
|
|
135
|
+
// reason, and what ccr actually read out of the file, which is the question
|
|
136
|
+
// someone whose pane never appeared is really asking.
|
|
137
|
+
const cfg = loadPaneConfig({ env: opts.env, home: homedir });
|
|
138
|
+
if (cfg.error) {
|
|
139
|
+
out.push(bad(`pane config: ${cfg.error} — ${stripControl(cfg.configPath)}`));
|
|
140
|
+
problems++;
|
|
141
|
+
} else if (cfg.panes.length) {
|
|
142
|
+
out.push(ok(`pane config: ${cfg.panes.length} pane(s) (${stripControl(cfg.configPath)})`));
|
|
143
|
+
// The path as ccr resolved it, not as written: a tilde that did not expand
|
|
144
|
+
// is invisible in the source string and obvious in the resolved one.
|
|
145
|
+
for (const pane of cfg.panes) out.push(dim(` · ${stripControl(pane.path)}`));
|
|
146
|
+
} else {
|
|
147
|
+
out.push(dim(`· no panes configured (optional — ${stripControl(cfg.configPath)})`));
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
// newest captured snapshot across the container. Instances live TWO levels
|
|
95
151
|
// down under the 0.4.0 layout (~/.ccr/instances/<n>/last-status.json) — the
|
|
96
152
|
// one-level scan alone would report "no status captured" while instances run
|
package/src/launch-win.js
CHANGED
|
@@ -39,6 +39,109 @@ function isWtArgSafe(value) {
|
|
|
39
39
|
return !WT_UNSAFE_RE.test(String(value));
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// The starting directory is a DIFFERENT problem from the values above, and
|
|
43
|
+
// reusing WT_UNSAFE_RE for it would be a bug in both directions.
|
|
44
|
+
//
|
|
45
|
+
// `%` is LEGAL in a Windows path (C:\100%done) and harmless here: -d's value
|
|
46
|
+
// is its own argv token handed to wt, never interpolated into a cmd /c
|
|
47
|
+
// payload, so cmd's variable expansion never sees it. WT_UNSAFE_RE rejects
|
|
48
|
+
// `%`, so reusing it would refuse a perfectly valid directory.
|
|
49
|
+
//
|
|
50
|
+
// `;` is legal in a Windows path AND is wt's own command separator — wt
|
|
51
|
+
// re-parses its command line, and Node only quotes an argv token that
|
|
52
|
+
// contains whitespace, so `C:\my;dir` would arrive unquoted and split the
|
|
53
|
+
// command. WT_UNSAFE_RE does NOT reject `;`, so it misses the one character
|
|
54
|
+
// that actually matters here.
|
|
55
|
+
//
|
|
56
|
+
// A directory that fails this does not fail the launch: run() drops -d and
|
|
57
|
+
// says where the panes landed (features/windows-launcher.feature, "A launch
|
|
58
|
+
// directory Windows Terminal cannot be given still launches"). Refusing to
|
|
59
|
+
// start over a legal directory name would trade one broken launch for another.
|
|
60
|
+
//
|
|
61
|
+
// MEASURED on Windows 11 (10.0.26200) with scripts/probe-wt.js, because none of
|
|
62
|
+
// this was knowable from the documentation and none of it is covered by CI —
|
|
63
|
+
// every test injects spawnWt, so the suite proves the argv and nothing about
|
|
64
|
+
// what Windows Terminal does with it.
|
|
65
|
+
const WT_PATH_UNSAFE_RE = /[";\r\n]/;
|
|
66
|
+
|
|
67
|
+
// The backtick used to be in that class and is NOT: a path containing one was
|
|
68
|
+
// passed through and the tab landed exactly where it was asked to. wt is not
|
|
69
|
+
// PowerShell. Refusing it cost a launch that would have worked.
|
|
70
|
+
//
|
|
71
|
+
// The semicolon earns its place — wt splits its own command line on it, and a
|
|
72
|
+
// path containing one opens NO TAB AT ALL.
|
|
73
|
+
|
|
74
|
+
// Longest path wt will accept as `-d`. Measured: 256 characters opens a tab,
|
|
75
|
+
// 259 opens none — so the true limit is 257 or 258, and 256 is the longest
|
|
76
|
+
// length known to work. Two characters of headroom is not worth another round
|
|
77
|
+
// trip on someone else's machine.
|
|
78
|
+
//
|
|
79
|
+
// This is NOT the filesystem's limit. The machine that measured it has long
|
|
80
|
+
// paths enabled: a 299-character directory was created successfully and then
|
|
81
|
+
// refused a tab. The refusal is Windows Terminal's.
|
|
82
|
+
const WT_PATH_MAX = 256;
|
|
83
|
+
|
|
84
|
+
// Naming the character rather than echoing it. Two reasons: a raw CR or LF
|
|
85
|
+
// interpolated into a diagnostic would break the diagnostic, and "a semicolon"
|
|
86
|
+
// is what the reader needs anyway — they are looking at the path already.
|
|
87
|
+
/** @type {Record<string, string>} */
|
|
88
|
+
const WT_PATH_CHAR_NAMES = {
|
|
89
|
+
'"': 'a double quote',
|
|
90
|
+
';': 'a semicolon',
|
|
91
|
+
'\r': 'a carriage return',
|
|
92
|
+
'\n': 'a newline',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Why `dir` cannot be given to wt as `-d`, or null when it can.
|
|
97
|
+
*
|
|
98
|
+
* Every branch here degrades rather than refuses to launch: the panes open in
|
|
99
|
+
* Windows Terminal's default directory and stderr says so. The point of the
|
|
100
|
+
* REASON is that "we would not pass this one" is a different sentence for each
|
|
101
|
+
* cause, and the previous single message named characters the path did not
|
|
102
|
+
* contain.
|
|
103
|
+
*
|
|
104
|
+
* @param {string|null|undefined} dir
|
|
105
|
+
* @returns {string|null} a clause completing "the path …", or null if usable
|
|
106
|
+
*/
|
|
107
|
+
function wtPathProblem(dir) {
|
|
108
|
+
if (typeof dir !== 'string' || dir.length === 0) return 'is not a directory ccr could read';
|
|
109
|
+
// UNC is the dangerous one, and the only case here that is SILENT. The tab
|
|
110
|
+
// OPENS — cmd.exe simply refuses a UNC working directory and starts in
|
|
111
|
+
// %SystemRoot% without a word. Measured: \\localhost\c$\Users reported
|
|
112
|
+
// C:\Windows. Left unhandled, the git pane would describe the project in
|
|
113
|
+
// full confidence for a terminal sitting in C:\Windows.
|
|
114
|
+
if (/^[\\/]{2}/.test(dir)) return 'is a UNC path — cmd.exe refuses those and starts in %SystemRoot% without saying so';
|
|
115
|
+
if (dir.length > WT_PATH_MAX) return `is ${dir.length} characters — Windows Terminal opens no tab at all past ${WT_PATH_MAX}`;
|
|
116
|
+
const m = WT_PATH_UNSAFE_RE.exec(dir);
|
|
117
|
+
if (m) return `contains ${WT_PATH_CHAR_NAMES[m[0]] || 'a character'} — Windows Terminal parses it and opens no tab at all`;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What the user could DO about it, when there is anything.
|
|
123
|
+
*
|
|
124
|
+
* Only the UNC case has an answer: cmd.exe will take a mapped drive letter
|
|
125
|
+
* where it refuses the UNC form, so the directory is reachable — just not by
|
|
126
|
+
* that name. The other causes are the path they have, and telling someone to
|
|
127
|
+
* rename their project or shorten it below 256 characters is not advice.
|
|
128
|
+
*
|
|
129
|
+
* @param {string|null|undefined} dir
|
|
130
|
+
* @returns {string|null}
|
|
131
|
+
*/
|
|
132
|
+
function wtPathHint(dir) {
|
|
133
|
+
if (typeof dir !== 'string' || !/^[\\/]{2}/.test(dir)) return null;
|
|
134
|
+
return 'map the share to a drive letter (net use Z: \\\\server\\share) and run ccr from there';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string|null|undefined} dir
|
|
139
|
+
* @returns {boolean} true if `dir` can be passed to wt.exe as `-d <dir>`
|
|
140
|
+
*/
|
|
141
|
+
function isWtPathSafe(dir) {
|
|
142
|
+
return wtPathProblem(dir) === null;
|
|
143
|
+
}
|
|
144
|
+
|
|
42
145
|
// Upstream default split: the sidecar gets ~34% of the width.
|
|
43
146
|
const DEFAULT_SIDEBAR_PCT = 34;
|
|
44
147
|
|
|
@@ -181,9 +284,16 @@ function sidecarCols(termCols, fracNum, splitFlag) {
|
|
|
181
284
|
* the case of %, hijack) the cmd /c payload — see isWtArgSafe. run() catches
|
|
182
285
|
* this and reports a clean error instead of spawning a broken command.
|
|
183
286
|
*
|
|
287
|
+
* `cwd` is the directory BOTH panes start in. Windows Terminal does not
|
|
288
|
+
* inherit it the way `tmux new-session` does: without `-d` a pane opens in the
|
|
289
|
+
* WT profile's own `startingDirectory`, which defaults to %USERPROFILE% — so
|
|
290
|
+
* omitting it silently moved Claude Code out of the user's project. Absent or
|
|
291
|
+
* unpassable (see isWtPathSafe), `-d` is left off entirely and the panes fall
|
|
292
|
+
* back to that profile default; run() is what tells the user.
|
|
293
|
+
*
|
|
184
294
|
* @param {{ ccCmd: string, settingsFile: string, stateDir: string,
|
|
185
295
|
* node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
|
|
186
|
-
* termCols?: number, title?: string }} o
|
|
296
|
+
* termCols?: number, title?: string, cwd?: string|null }} o
|
|
187
297
|
* @returns {string[]}
|
|
188
298
|
*/
|
|
189
299
|
function buildWtArgs(o) {
|
|
@@ -227,13 +337,17 @@ function buildWtArgs(o) {
|
|
|
227
337
|
const cols = sidecarCols(o.termCols, Number(frac), splitFlag);
|
|
228
338
|
const sidecarBody =
|
|
229
339
|
(cols != null ? `set "CCR_SIDECAR_COLS=${cols}"&& ` : '') +
|
|
230
|
-
`"${node}" "${ccrJs}" sidecar --exit-on-end`;
|
|
340
|
+
`"${node}" "${ccrJs}" sidecar --exit-on-end --keys`;
|
|
231
341
|
const pane1 = paneCommand(stateDir, sidecarBody);
|
|
232
342
|
|
|
343
|
+
// Both panes get the same starting directory, or neither does. `-d` sits
|
|
344
|
+
// with the other options, before each pane's `cmd` payload.
|
|
345
|
+
const startIn = isWtPathSafe(o.cwd) ? ['-d', String(o.cwd)] : [];
|
|
346
|
+
|
|
233
347
|
return [
|
|
234
|
-
'-w', '0', 'new-tab', '--title', title, 'cmd', '/c', pane0,
|
|
348
|
+
'-w', '0', 'new-tab', '--title', title, ...startIn, 'cmd', '/c', pane0,
|
|
235
349
|
';',
|
|
236
|
-
'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
|
|
350
|
+
'split-pane', splitFlag, '-s', frac, ...startIn, 'cmd', '/c', pane1,
|
|
237
351
|
];
|
|
238
352
|
}
|
|
239
353
|
|
|
@@ -263,7 +377,7 @@ function defaultWhere(name) {
|
|
|
263
377
|
|
|
264
378
|
const inject = require('./settings-inject');
|
|
265
379
|
const slots = require('./instance-slot');
|
|
266
|
-
const { ensureSecureDir, recordLaunchDir } = require('./state-dir');
|
|
380
|
+
const { ensureSecureDir, recordLaunchDir, clearLaunchDir } = require('./state-dir');
|
|
267
381
|
|
|
268
382
|
/**
|
|
269
383
|
* Fill in real-environment implementations for anything the caller didn't
|
|
@@ -282,6 +396,15 @@ function withDefaults(deps) {
|
|
|
282
396
|
// The launcher's own stdout reports the live terminal width — the sidecar's
|
|
283
397
|
// does not, inside its cmd /c pane (see sidecarCols). undefined on non-TTY.
|
|
284
398
|
cols: deps.cols != null ? deps.cols : process.stdout.columns,
|
|
399
|
+
// The launch directory, injected like every other external effect. It is
|
|
400
|
+
// the INPUT to that decision, not the answer: run() narrows it to paneCwd —
|
|
401
|
+
// the directory wt will actually be given — and both the record and `-d`
|
|
402
|
+
// read that, so the pane and the record cannot disagree about where this
|
|
403
|
+
// session is. This comment claimed the invariant before the code held it.
|
|
404
|
+
// process.cwd() throws only if the cwd has been deleted.
|
|
405
|
+
cwd: deps.cwd != null ? deps.cwd : (() => {
|
|
406
|
+
try { return process.cwd(); } catch { return null; }
|
|
407
|
+
})(),
|
|
285
408
|
node: deps.node || process.execPath,
|
|
286
409
|
ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
|
|
287
410
|
out: deps.out || ((s) => { process.stdout.write(s); }),
|
|
@@ -292,6 +415,7 @@ function withDefaults(deps) {
|
|
|
292
415
|
listDir: deps.listDir || defaultListDir,
|
|
293
416
|
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
294
417
|
recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
|
|
418
|
+
clearLaunchDir: deps.clearLaunchDir || clearLaunchDir,
|
|
295
419
|
allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
|
|
296
420
|
prepareInstance: deps.prepareInstance
|
|
297
421
|
|| ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
|
|
@@ -409,11 +533,39 @@ function run(profile, deps = {}, opts = {}) {
|
|
|
409
533
|
|
|
410
534
|
// 4. Prepare the per-profile state dir; clear a stale sentinel.
|
|
411
535
|
try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
|
|
536
|
+
// ONE source for both consumers: the directory the panes will ACTUALLY start
|
|
537
|
+
// in. Everything below reads paneCwd — the record that gives the git pane its
|
|
538
|
+
// identity, and wt's `-d` — so the two cannot describe different directories.
|
|
539
|
+
//
|
|
540
|
+
// Recording d.cwd unconditionally is what CREATED the divergence rather than
|
|
541
|
+
// merely failing to prevent it: src/sidecar.js launchDir() PREFERS the record
|
|
542
|
+
// over the pane's own cwd. readGitRepo self-heals after Claude's first status
|
|
543
|
+
// tick, but before that tick — the state every tab starts in — the pane draws
|
|
544
|
+
// the project's repo, branch, tree and history in full confidence for a
|
|
545
|
+
// session sitting somewhere else entirely.
|
|
546
|
+
const paneCwd = isWtPathSafe(d.cwd) ? d.cwd : null;
|
|
547
|
+
if (paneCwd) {
|
|
548
|
+
try { d.recordLaunchDir(st.stateDir, paneCwd); } catch { /* best effort */ }
|
|
549
|
+
} else {
|
|
550
|
+
// Clearing, not skipping. Slots are reused, so the record from whatever ran
|
|
551
|
+
// in this slot before would otherwise win (src/state-dir.js clearLaunchDir).
|
|
552
|
+
try { d.clearLaunchDir(st.stateDir); } catch { /* best effort */ }
|
|
553
|
+
}
|
|
415
554
|
d.removeExited(st.stateDir);
|
|
416
555
|
|
|
556
|
+
// The panes must open where `ccr` was run. wt does not inherit that, so it
|
|
557
|
+
// is passed explicitly below; when the directory cannot be passed the launch
|
|
558
|
+
// still goes ahead, and saying so is the whole difference between a surprise
|
|
559
|
+
// and a known limitation.
|
|
560
|
+
const pathProblem = d.cwd ? wtPathProblem(d.cwd) : null;
|
|
561
|
+
if (pathProblem) {
|
|
562
|
+
d.err(`ccr: cannot open the panes in ${d.cwd}\n`);
|
|
563
|
+
d.err(` the path ${pathProblem}\n`);
|
|
564
|
+
d.err(" — they will open in Windows Terminal's default directory instead.\n");
|
|
565
|
+
const hint = wtPathHint(d.cwd);
|
|
566
|
+
if (hint) d.err(` ${hint}.\n`);
|
|
567
|
+
}
|
|
568
|
+
|
|
417
569
|
// 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
|
|
418
570
|
const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
|
|
419
571
|
const settingsFile = d.writeSettings(inject.buildSettings(command));
|
|
@@ -432,6 +584,7 @@ function run(profile, deps = {}, opts = {}) {
|
|
|
432
584
|
sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
|
|
433
585
|
termCols: d.cols,
|
|
434
586
|
title: inst ? inst.title : undefined,
|
|
587
|
+
cwd: paneCwd,
|
|
435
588
|
});
|
|
436
589
|
} catch (e) {
|
|
437
590
|
d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
@@ -453,6 +606,8 @@ function run(profile, deps = {}, opts = {}) {
|
|
|
453
606
|
* @property {NodeJS.ProcessEnv} env
|
|
454
607
|
* @property {string} home
|
|
455
608
|
* @property {number|undefined} cols
|
|
609
|
+
* @property {string|null} cwd the directory `ccr` was run in — recorded as the
|
|
610
|
+
* tab's launch dir AND passed to wt as the panes' starting directory
|
|
456
611
|
* @property {string} node
|
|
457
612
|
* @property {string} ccrJs
|
|
458
613
|
* @property {(s: string) => void} out
|
|
@@ -463,6 +618,7 @@ function run(profile, deps = {}, opts = {}) {
|
|
|
463
618
|
* @property {(dir: string) => string[]} listDir
|
|
464
619
|
* @property {(dir: string) => void} ensureDir
|
|
465
620
|
* @property {(dir: string, cwd: string) => void} recordLaunchDir
|
|
621
|
+
* @property {(dir: string) => void} clearLaunchDir
|
|
466
622
|
* @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
|
|
467
623
|
* @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
|
|
468
624
|
* @property {(dir: string) => void} removeExited
|
|
@@ -477,6 +633,10 @@ module.exports = {
|
|
|
477
633
|
DEFAULT_SIDEBAR_SIDE,
|
|
478
634
|
validateProfile,
|
|
479
635
|
isWtArgSafe,
|
|
636
|
+
isWtPathSafe,
|
|
637
|
+
wtPathProblem,
|
|
638
|
+
wtPathHint,
|
|
639
|
+
WT_PATH_MAX,
|
|
480
640
|
resolveProfileState,
|
|
481
641
|
sidebarFraction,
|
|
482
642
|
sidebarSplitFlag,
|
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.
|
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);
|
|
@@ -186,6 +206,6 @@ function ellipsize(s, cols) {
|
|
|
186
206
|
}
|
|
187
207
|
|
|
188
208
|
module.exports = {
|
|
189
|
-
e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset,
|
|
209
|
+
e, dim, bold, green, red, yellow, cyan, flash, pctColor, CRIT_PCT, usedLabel, bar, clampVisible, tok, fmtMins, fmtReset,
|
|
190
210
|
charWidth, visibleWidth, ellipsize,
|
|
191
211
|
};
|
package/src/render/statusline.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Deterministic middle ellipsis: the same input shortens the same way at
|
|
@@ -66,12 +66,19 @@ function renderStatusline(view, identity = {}) {
|
|
|
66
66
|
const b = binding(live);
|
|
67
67
|
if (b && b.minutesLeft != null) {
|
|
68
68
|
const row = rows.find((r) => r.key === b.window);
|
|
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)}%` : '';
|
|
69
76
|
if (b.minutesLeft <= 30) {
|
|
70
77
|
// "About to hit the wall" outranks orientation for the next thing the
|
|
71
78
|
// user types: the warning jumps ahead of everything, identity included.
|
|
72
|
-
parts.unshift(`⚠ ${
|
|
79
|
+
parts.unshift(`⚠ ${label}${pct} ~${fmtMins(b.minutesLeft)}`);
|
|
73
80
|
} else {
|
|
74
|
-
parts.push(`${
|
|
81
|
+
parts.push(`${label}${pct} ~${fmtMins(b.minutesLeft)}`);
|
|
75
82
|
}
|
|
76
83
|
} else {
|
|
77
84
|
parts.push('within limits');
|
package/src/sidecar-keys.js
CHANGED
|
@@ -41,15 +41,28 @@ const path = require('node:path');
|
|
|
41
41
|
const { spawn } = require('node:child_process');
|
|
42
42
|
const { cycleView } = require('./cycle-view');
|
|
43
43
|
|
|
44
|
-
// F3 in
|
|
45
|
-
//
|
|
44
|
+
// F3 in every encoding a terminal actually sends it, plus SPACE.
|
|
45
|
+
//
|
|
46
|
+
// \x1bOR SS3. What xterm, screen, tmux and vt220 all send (infocmp: kf3).
|
|
47
|
+
// \x1b[[C The Linux console — infocmp gives `linux: kf3=\E[[C`, and it is
|
|
48
|
+
// the ONLY entry that differs. It is also what arrives on Windows
|
|
49
|
+
// from Node before v22.17.0 / v24.2.0: until "tty: use terminal VT
|
|
50
|
+
// mode on Windows" (db2aae802) setRawMode passed UV_TTY_MODE_RAW,
|
|
51
|
+
// where libuv translates the keypress itself rather than letting
|
|
52
|
+
// the terminal's own sequence through. From UV_TTY_MODE_RAW_VT on
|
|
53
|
+
// it sets ENABLE_VIRTUAL_TERMINAL_INPUT and Windows Terminal sends
|
|
54
|
+
// SS3 like everyone else. Which of the two arrives on Windows is
|
|
55
|
+
// therefore a property of the NODE VERSION, not the terminal.
|
|
56
|
+
// \x1b[13~ The CSI-tilde form VS Code's xterm.js sends. An earlier version
|
|
57
|
+
// of this comment called it the Linux console encoding; it is not,
|
|
58
|
+
// and the console form above was missing entirely.
|
|
46
59
|
//
|
|
47
60
|
// Space is not a fallback for tidiness: an editor that keeps F3 for its own
|
|
48
61
|
// "find next" while the terminal is focused would otherwise leave this pane with
|
|
49
62
|
// no key at all, and that behavior differs across VS Code, Cursor, Positron and
|
|
50
63
|
// Antigravity. The pane is dedicated to the sidecar, so nothing else there is
|
|
51
64
|
// waiting for a space.
|
|
52
|
-
const CYCLE_KEYS = ['\x1bOR', '\x1b[13~', ' '];
|
|
65
|
+
const CYCLE_KEYS = ['\x1bOR', '\x1b[[C', '\x1b[13~', ' '];
|
|
53
66
|
|
|
54
67
|
// In raw mode Ctrl-C arrives as a BYTE, not a signal. Without handling it the
|
|
55
68
|
// pane could not be closed from the keyboard at all.
|
package/src/sidecar.js
CHANGED
|
@@ -167,6 +167,7 @@ function updateFeed(tpath) {
|
|
|
167
167
|
|
|
168
168
|
const dim = (/** @type {string} */ s) => `\x1b[2m${s}\x1b[0m`;
|
|
169
169
|
const bold = (/** @type {string} */ s) => `\x1b[1m${s}\x1b[0m`;
|
|
170
|
+
const yellow = (/** @type {string} */ s) => `\x1b[33m${s}\x1b[0m`;
|
|
170
171
|
|
|
171
172
|
let prev = '';
|
|
172
173
|
function draw(/** @type {string} */ s) {
|
|
@@ -204,7 +205,15 @@ function sessionDir(stateDir) {
|
|
|
204
205
|
/**
|
|
205
206
|
* The directory ccr was LAUNCHED in — the tab's stable identity. Written by the
|
|
206
207
|
* launcher (src/state-dir.js: recordLaunchDir); the sidecar's own cwd is the
|
|
207
|
-
* fallback
|
|
208
|
+
* fallback.
|
|
209
|
+
*
|
|
210
|
+
* That fallback is right under tmux, where `new-session` inherits the caller's
|
|
211
|
+
* cwd. It is NOT free under wt.exe: a pane opens in the Windows Terminal
|
|
212
|
+
* profile's own startingDirectory unless the launcher passes `-d`, which it
|
|
213
|
+
* now does (src/launch-win.js: buildWtArgs). This comment previously claimed
|
|
214
|
+
* wt.exe inherited it, and that claim was the only place the assumption
|
|
215
|
+
* surfaced anywhere in the codebase — see features/windows-launcher.feature,
|
|
216
|
+
* "Claude Code opens in the directory ccr was launched from".
|
|
208
217
|
*
|
|
209
218
|
* `process.cwd()` throws when the directory it names has been deleted, which is
|
|
210
219
|
* not hypothetical here: it is the "repository is deleted while the pane is
|
|
@@ -263,7 +272,8 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
263
272
|
// External panes. Config is re-read per tick so adding a pane needs no
|
|
264
273
|
// relaunch, and it is best-effort: a broken config costs the panes, never the
|
|
265
274
|
// panel (loadPaneConfig is total — see src/pane-config.js).
|
|
266
|
-
const
|
|
275
|
+
const cfg = opts.panes ? { panes: opts.panes, error: null } : loadPaneConfig();
|
|
276
|
+
const panes = cfg.panes;
|
|
267
277
|
// View order: 0 economy, 1 the git pane, 2… external panes. The git pane is
|
|
268
278
|
// BUILT IN and therefore always in the cycle — including in a directory that
|
|
269
279
|
// is not a repository at all, where it says so. A view that appeared and
|
|
@@ -341,6 +351,12 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
341
351
|
if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
|
|
342
352
|
let state;
|
|
343
353
|
try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
|
|
354
|
+
// Snapshot age drives BOTH the dimming of the used figures inside the panel
|
|
355
|
+
// and the freshness marker appended below — read ONCE so the two can never
|
|
356
|
+
// disagree about whether what you are looking at is live. An unreadable mtime
|
|
357
|
+
// reads as fresh: a missing timestamp is not evidence of staleness.
|
|
358
|
+
let ageMs = 0;
|
|
359
|
+
try { ageMs = Math.max(0, now - fs.statSync(snapshot).mtimeMs); } catch { /* unknown → fresh */ }
|
|
344
360
|
let out;
|
|
345
361
|
try {
|
|
346
362
|
// 5h/weekly are ACCOUNT-WIDE but captured per-profile, so an idle sibling's
|
|
@@ -348,7 +364,7 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
348
364
|
// SAME account (see src/account-limits.js) before rendering — best-effort, and
|
|
349
365
|
// strictly guarded so a different account is never mixed in.
|
|
350
366
|
const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir, opts.home ? { home: opts.home } : {}) };
|
|
351
|
-
out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0 });
|
|
367
|
+
out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0, ageMs });
|
|
352
368
|
// ccr's own view is position 1 of the cycle, named only when the cycle is
|
|
353
369
|
// long enough for the position to tell you something (see showPosition).
|
|
354
370
|
if (showPosition) out = out.replace(/\n/, dim(` ${positionAt(0)}`) + '\n');
|
|
@@ -375,11 +391,17 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
375
391
|
// quiet "updated Nm ago" so a stale panel reads as stale rather than broken —
|
|
376
392
|
// otherwise a long agent run (or a CC statusLine that stopped firing) looks like
|
|
377
393
|
// the sidecar just froze. See src/liveness.js + features/liveness.feature.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
394
|
+
const mark = liveness({ exited: false, ageMs }).marker;
|
|
395
|
+
if (mark) out += (out.endsWith('\n') ? '' : '\n') + ' ' + dim('· ' + mark);
|
|
396
|
+
// A config the user wrote and got wrong used to cost the panes in silence —
|
|
397
|
+
// the panel rendered exactly as it does for someone who configured nothing,
|
|
398
|
+
// so a typo was indistinguishable from never having tried. Name it here and
|
|
399
|
+
// send them to `ccr doctor`, which has room for the path and the reason.
|
|
400
|
+
// Still never fatal: the panel is whole, only the panes are missing.
|
|
401
|
+
if (cfg.error) {
|
|
402
|
+
out += (out.endsWith('\n') ? '' : '\n')
|
|
403
|
+
+ ' ' + yellow('· config: ' + cfg.error) + dim(' — see `ccr doctor`');
|
|
404
|
+
}
|
|
383
405
|
return clamp(out.endsWith('\n') ? out : out + '\n');
|
|
384
406
|
}
|
|
385
407
|
|
package/src/state-dir.js
CHANGED
|
@@ -61,4 +61,21 @@ function recordLaunchDir(dir, cwd) {
|
|
|
61
61
|
} catch { /* best effort */ }
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Forget the recorded launch directory.
|
|
66
|
+
*
|
|
67
|
+
* NOT the same as declining to write one. Slots are REUSED, so a record left by
|
|
68
|
+
* whatever ran in this slot before would still be sitting there — and
|
|
69
|
+
* src/sidecar.js launchDir() PREFERS the record over the pane's own cwd, so a
|
|
70
|
+
* stale one wins outright. The launcher calls this whenever it could not
|
|
71
|
+
* deliver the directory to the panes, which is the only moment it knows the
|
|
72
|
+
* record would be a lie.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} dir The state directory.
|
|
75
|
+
*/
|
|
76
|
+
function clearLaunchDir(dir) {
|
|
77
|
+
const path = require('node:path');
|
|
78
|
+
try { fs.rmSync(path.join(dir, 'launch-cwd'), { force: true }); } catch { /* best effort */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { ensureSecureDir, recordLaunchDir, clearLaunchDir };
|