claude-code-runrate 0.1.0 → 0.2.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/README.md +35 -5
- package/bin/ccr.js +30 -12
- package/package.json +11 -4
- package/src/doctor.js +33 -23
- package/src/launch-vscode.js +288 -0
- package/src/launch-win.js +401 -0
- package/src/render/economy.js +5 -1
- package/src/render/shared.js +29 -1
- package/src/settings-inject.js +77 -0
- package/src/sidecar.js +44 -14
package/README.md
CHANGED
|
@@ -51,15 +51,42 @@ npm i -g claude-code-runrate
|
|
|
51
51
|
| `ccr economy --json` | Stable machine-readable model (scripting) | ✅ | ✅ | ✅ |
|
|
52
52
|
| `ccr resume` | Recent sessions ranked by cost to resume | ✅ | ✅ | ✅ |
|
|
53
53
|
| `ccr statusline` | One-line summary wired into CC's `statusLine` | ✅ | ✅ | ✅ |
|
|
54
|
-
| `ccr sidecar` | Live
|
|
54
|
+
| `ccr sidecar` | Live dashboard + tool/skills feed | ✅ tmux | ✅ tmux | ✅ Windows Terminal / VS Code |
|
|
55
55
|
|
|
56
|
-
The CLI and statusline are pure Node — they run on native Windows. The
|
|
57
|
-
sidebar
|
|
56
|
+
The CLI and statusline are pure Node — they run on native Windows. The live
|
|
57
|
+
sidebar runs on **Windows Terminal** (`ccr` splits it automatically) or inside
|
|
58
|
+
**VS Code's integrated terminal** on any OS (see [VS Code](#vs-code-split-terminal)).
|
|
59
|
+
On Linux/macOS the default host is `tmux`. No WSL required.
|
|
58
60
|
|
|
59
61
|
## Requirements
|
|
60
62
|
|
|
61
63
|
- **Node ≥ 18.3** — that's it for the core. **Zero runtime dependencies.**
|
|
62
|
-
-
|
|
64
|
+
- For the optional live `ccr sidecar`: `tmux` on Linux/macOS, or **Windows
|
|
65
|
+
Terminal** / **VS Code's integrated terminal** on Windows (no WSL).
|
|
66
|
+
|
|
67
|
+
## VS Code (split terminal)
|
|
68
|
+
|
|
69
|
+
`ccr` detects VS Code's integrated terminal (`TERM_PROGRAM=vscode`) and wires the
|
|
70
|
+
live sidebar into a **split pane** — no separate window, no WSL. A shell can't
|
|
71
|
+
trigger the split itself, so `ccr` does everything around it:
|
|
72
|
+
|
|
73
|
+
1. Run `ccr` (or `ccr <profile>`). Claude starts in the **current** pane and a
|
|
74
|
+
bright banner shows the steps. The sidecar command is **copied to your
|
|
75
|
+
clipboard** automatically (via an OSC 52 escape — works over SSH/remote too).
|
|
76
|
+
2. **Split the terminal** — `Ctrl+Shift+5` (Windows/Linux) or `Cmd+\` (macOS).
|
|
77
|
+
3. **Paste** into the new pane and press Enter — the live sidebar runs there.
|
|
78
|
+
|
|
79
|
+
Lost the banner once Claude takes the screen? Run `ccr sidecar --hint` to reprint
|
|
80
|
+
the steps and re-copy the command.
|
|
81
|
+
|
|
82
|
+
On **Windows** this is the default inside VS Code (Windows Terminal otherwise
|
|
83
|
+
opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
|
|
84
|
+
`ccr` defaults to `tmux` (which works inside the VS Code terminal too); set
|
|
85
|
+
`CCR_VSCODE=1` to use the split-terminal flow there instead.
|
|
86
|
+
|
|
87
|
+
> Automating the split keystroke itself would need a VS Code extension (the `code`
|
|
88
|
+
> CLI has no "run command" verb) — out of scope for the zero-dependency core. The
|
|
89
|
+
> clipboard + `--hint` reduce it to split-and-paste.
|
|
63
90
|
|
|
64
91
|
## Wiring the statusline into Claude Code
|
|
65
92
|
|
|
@@ -77,7 +104,10 @@ latency.)
|
|
|
77
104
|
|
|
78
105
|
This project is built **BDD-first**: the Gherkin in [`features/`](features/) is
|
|
79
106
|
the source of truth, executed by a hand-rolled zero-dependency harness on top of
|
|
80
|
-
Node's built-in test runner
|
|
107
|
+
Node's built-in test runner — a ~250-line Gherkin parser + runner that supports
|
|
108
|
+
the practical core of the grammar and rejects everything else loudly rather than
|
|
109
|
+
mis-parsing it. See [`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the
|
|
110
|
+
deliberate limits, and the API.
|
|
81
111
|
|
|
82
112
|
```bash
|
|
83
113
|
npm test # node --test — harness self-tests + feature scenarios
|
package/bin/ccr.js
CHANGED
|
@@ -40,7 +40,8 @@ Examples:
|
|
|
40
40
|
|
|
41
41
|
Options:
|
|
42
42
|
-h, --help Show this help
|
|
43
|
-
-v, --version Show version
|
|
43
|
+
-v, --version Show version
|
|
44
|
+
--mary Enable the mary interface`;
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* @param {string[]} argv
|
|
@@ -56,6 +57,9 @@ function main(argv) {
|
|
|
56
57
|
help: { type: 'boolean', short: 'h' },
|
|
57
58
|
version: { type: 'boolean', short: 'v' },
|
|
58
59
|
json: { type: 'boolean' },
|
|
60
|
+
'state-dir': { type: 'string' },
|
|
61
|
+
hint: { type: 'boolean' },
|
|
62
|
+
mary: { type: 'boolean' },
|
|
59
63
|
},
|
|
60
64
|
});
|
|
61
65
|
} catch (err) {
|
|
@@ -66,6 +70,9 @@ function main(argv) {
|
|
|
66
70
|
const { values, positionals } = parsed;
|
|
67
71
|
if (values.version) { process.stdout.write(`${pkg.version}\n`); return 0; }
|
|
68
72
|
if (values.help) { process.stdout.write(`${HELP}\n`); return 0; }
|
|
73
|
+
// The mary interface is an env toggle under the hood; surface it as a flag so
|
|
74
|
+
// in-process panels (economy/statusline/resume) pick it up for this run.
|
|
75
|
+
if (values.mary) process.env.CCR_ENABLE_MARY_INTERFACE = '1';
|
|
69
76
|
|
|
70
77
|
const cmd = positionals[0];
|
|
71
78
|
if (!cmd) return cmdLaunch(undefined); // bare `ccr` → launch
|
|
@@ -73,7 +80,7 @@ function main(argv) {
|
|
|
73
80
|
case 'economy': return cmdEconomy(!!values.json);
|
|
74
81
|
case 'resume': return cmdResume(positionals[1]);
|
|
75
82
|
case 'statusline': return cmdStatusline();
|
|
76
|
-
case 'sidecar': return cmdSidecar();
|
|
83
|
+
case 'sidecar': return cmdSidecar(values['state-dir'], !!values.hint);
|
|
77
84
|
case 'doctor': return require('../src/doctor').run();
|
|
78
85
|
case 'launch': return cmdLaunch(positionals[1]);
|
|
79
86
|
default: return cmdLaunch(cmd); // anything else → treat as a CCS profile
|
|
@@ -151,26 +158,37 @@ function cmdResume(arg) {
|
|
|
151
158
|
return 0;
|
|
152
159
|
}
|
|
153
160
|
|
|
154
|
-
/**
|
|
155
|
-
|
|
161
|
+
/**
|
|
162
|
+
* `ccr sidecar` — live economy panel; keeps the process alive (no exit code).
|
|
163
|
+
* `--state-dir <dir>` targets a specific session (used by the VS Code split-pane
|
|
164
|
+
* one-liner, which is shell-agnostic). `--hint` reprints the VS Code split
|
|
165
|
+
* instructions + re-copies the one-liner instead of running the panel.
|
|
166
|
+
* @param {string | undefined} stateDir
|
|
167
|
+
* @param {boolean} [showHint]
|
|
168
|
+
* @returns {number | undefined}
|
|
169
|
+
*/
|
|
170
|
+
function cmdSidecar(stateDir, showHint) {
|
|
171
|
+
if (stateDir) process.env.CCR_STATE_DIR = stateDir;
|
|
172
|
+
if (showHint) return require('../src/launch-vscode').hint(process.env.CCR_STATE_DIR || STATE_DIR);
|
|
156
173
|
require('../src/sidecar').run();
|
|
157
174
|
return undefined;
|
|
158
175
|
}
|
|
159
176
|
|
|
160
177
|
/**
|
|
161
|
-
* `ccr [profile]` — launch the
|
|
178
|
+
* `ccr [profile]` — launch the live sidecar. Inside VS Code's integrated terminal
|
|
179
|
+
* we split it in place (Windows always; other OSes via CCR_VSCODE=1, since tmux
|
|
180
|
+
* already works there); on native Windows we drive Windows Terminal; otherwise
|
|
181
|
+
* the tmux launcher (scripts/launch.sh).
|
|
162
182
|
* @param {string | undefined} profile
|
|
163
183
|
* @returns {number}
|
|
164
184
|
*/
|
|
165
185
|
function cmdLaunch(profile) {
|
|
186
|
+
const inVscode = process.env.TERM_PROGRAM === 'vscode';
|
|
187
|
+
if (inVscode && (process.platform === 'win32' || process.env.CCR_VSCODE === '1')) {
|
|
188
|
+
return require('../src/launch-vscode').run(profile);
|
|
189
|
+
}
|
|
166
190
|
if (process.platform === 'win32') {
|
|
167
|
-
|
|
168
|
-
'ccr: the live sidebar needs tmux + bash, which native Windows lacks.\n' +
|
|
169
|
-
' Use WSL for the sidebar, or run these directly (they work natively):\n' +
|
|
170
|
-
' ccr economy one-off economy panel\n' +
|
|
171
|
-
" ccr statusline wire into Claude Code's statusLine\n" +
|
|
172
|
-
' ccr doctor check your setup\n');
|
|
173
|
-
return 1;
|
|
191
|
+
return require('../src/launch-win').run(profile);
|
|
174
192
|
}
|
|
175
193
|
const { spawnSync } = require('node:child_process');
|
|
176
194
|
const launcher = path.join(__dirname, '..', 'scripts', 'launch.sh');
|
package/package.json
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-runrate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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>",
|
|
7
|
-
"repository": {
|
|
8
|
-
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/bingh0/ccr.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/bingh0/ccr/issues"
|
|
13
|
+
},
|
|
9
14
|
"homepage": "https://github.com/bingh0/ccr#readme",
|
|
10
15
|
"type": "commonjs",
|
|
11
16
|
"bin": {
|
|
@@ -24,7 +29,8 @@
|
|
|
24
29
|
],
|
|
25
30
|
"scripts": {
|
|
26
31
|
"test": "node --test",
|
|
27
|
-
"typecheck": "tsc --noEmit -p jsconfig.json"
|
|
32
|
+
"typecheck": "tsc --noEmit -p jsconfig.json",
|
|
33
|
+
"lint": "oxlint"
|
|
28
34
|
},
|
|
29
35
|
"keywords": [
|
|
30
36
|
"claude-code",
|
|
@@ -36,6 +42,7 @@
|
|
|
36
42
|
"dependencies": {},
|
|
37
43
|
"devDependencies": {
|
|
38
44
|
"@types/node": "^24.0.0",
|
|
45
|
+
"oxlint": "^1.71.0",
|
|
39
46
|
"typescript": "^5.7.0"
|
|
40
47
|
}
|
|
41
48
|
}
|
package/src/doctor.js
CHANGED
|
@@ -34,10 +34,19 @@ function isExec(/** @type {string} */ f) {
|
|
|
34
34
|
try { return (fs.statSync(f).mode & 0o111) !== 0; } catch { return false; }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
/**
|
|
38
|
+
* @param {{ platform?: string, has?: (cmd: string) => (string|null),
|
|
39
|
+
* homedir?: string, repo?: string, write?: (s: string) => void }} [opts]
|
|
40
|
+
* side effects are injectable for testing; defaults hit the real environment
|
|
41
|
+
* @returns {number} exit code (0 = healthy)
|
|
42
|
+
*/
|
|
43
|
+
function run(opts = {}) {
|
|
44
|
+
const platform = opts.platform || process.platform;
|
|
45
|
+
const hasFn = opts.has || has;
|
|
46
|
+
const homedir = opts.homedir || os.homedir();
|
|
47
|
+
const REPO = opts.repo || path.join(__dirname, '..');
|
|
48
|
+
const write = opts.write || ((s) => { process.stdout.write(s); });
|
|
49
|
+
const isWin = platform === 'win32';
|
|
41
50
|
const out = [bold('ccr doctor'), ''];
|
|
42
51
|
let problems = 0;
|
|
43
52
|
|
|
@@ -46,29 +55,35 @@ function run() {
|
|
|
46
55
|
out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 18.3`));
|
|
47
56
|
if (!nodeOk) problems++;
|
|
48
57
|
|
|
49
|
-
const ccr =
|
|
58
|
+
const ccr = hasFn('ccr');
|
|
50
59
|
out.push(ccr ? ok(`ccr on PATH (${stripControl(ccr)})`) : warn('ccr not on PATH — run `npm link` in the repo'));
|
|
51
60
|
if (!ccr) problems++;
|
|
52
61
|
|
|
53
62
|
if (isWin) {
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
// Native Windows hosts the sidecar in Windows Terminal — no tmux/bash/WSL.
|
|
64
|
+
const wt = hasFn('wt');
|
|
65
|
+
if (wt) {
|
|
66
|
+
out.push(ok(`Windows Terminal (sidecar host) (${stripControl(wt)})`));
|
|
67
|
+
} else {
|
|
68
|
+
out.push(warn('Windows Terminal not found — the sidecar needs it (winget install Microsoft.WindowsTerminal); the CLI still works'));
|
|
69
|
+
problems++;
|
|
70
|
+
}
|
|
71
|
+
// statusLine is injected inline (node + bin/ccr.js by path) via the per-launch
|
|
72
|
+
// temp settings file, so there's no shipped shim asset to check on Windows.
|
|
58
73
|
} else {
|
|
59
|
-
const tmux =
|
|
60
|
-
out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for `ccr [profile]` sidebar
|
|
74
|
+
const tmux = hasFn('tmux');
|
|
75
|
+
out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for the `ccr [profile]` sidebar'));
|
|
61
76
|
if (!tmux) problems++;
|
|
62
|
-
out.push(
|
|
77
|
+
out.push(hasFn('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
|
|
63
78
|
|
|
64
79
|
const sl = path.join(REPO, 'sidecar', 'ccr-statusline');
|
|
65
80
|
out.push(isExec(sl) ? ok('sidecar/ccr-statusline is executable') : warn('sidecar/ccr-statusline not executable (the launcher self-heals this)'));
|
|
66
81
|
}
|
|
67
82
|
|
|
68
|
-
const ccs =
|
|
83
|
+
const ccs = hasFn('ccs');
|
|
69
84
|
if (ccs) {
|
|
70
85
|
let profiles = [];
|
|
71
|
-
try { profiles = fs.readdirSync(path.join(
|
|
86
|
+
try { profiles = fs.readdirSync(path.join(homedir, '.ccs', 'instances')).filter((p) => !p.startsWith('.')); } catch { /* none */ }
|
|
72
87
|
// Profile + path come from the filesystem; sanitize before display.
|
|
73
88
|
out.push(ok(`ccs (${stripControl(ccs)}) · profiles: ${profiles.map(stripControl).join(', ') || '(none)'}`));
|
|
74
89
|
} else {
|
|
@@ -77,7 +92,7 @@ function run() {
|
|
|
77
92
|
|
|
78
93
|
// newest captured snapshot across ~/.ccr and its per-profile subdirs (state
|
|
79
94
|
// lives under the user's home now, never world-shared /tmp).
|
|
80
|
-
const ccrDir = path.join(
|
|
95
|
+
const ccrDir = path.join(homedir, '.ccr');
|
|
81
96
|
const dirs = [ccrDir];
|
|
82
97
|
try {
|
|
83
98
|
for (const d of fs.readdirSync(ccrDir)) {
|
|
@@ -98,17 +113,12 @@ function run() {
|
|
|
98
113
|
out.push(ok(`status captured ${ageMin}m ago (${stripControl(newest.d)})`));
|
|
99
114
|
out.push(dim(` buckets: ${keys.map(stripControl).join(', ') || '(none — API session?)'}`));
|
|
100
115
|
} else {
|
|
101
|
-
out.push(warn(
|
|
102
|
-
? 'no status captured yet — wire `ccr statusline` into Claude Code (settings.json) to start capturing'
|
|
103
|
-
: 'no status captured yet — launch with `ccr` (or `ccr <profile>`) to start capturing'));
|
|
116
|
+
out.push(warn('no status captured yet — launch with `ccr` (or `ccr <profile>`) to start capturing'));
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
out.push('');
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
: 'all good — `ccr` to launch, `ccr economy` for the panel';
|
|
110
|
-
out.push(problems ? warn(`${problems} thing(s) to address above`) : ok(allGood));
|
|
111
|
-
process.stdout.write(out.join('\n') + '\n');
|
|
120
|
+
out.push(problems ? warn(`${problems} thing(s) to address above`) : ok('all good — `ccr` to launch, `ccr economy` for the panel'));
|
|
121
|
+
write(out.join('\n') + '\n');
|
|
112
122
|
return problems ? 1 : 0;
|
|
113
123
|
}
|
|
114
124
|
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/launch-vscode.js — split-terminal launcher for VS Code's integrated
|
|
4
|
+
// terminal (any OS). Unlike the wt.exe path (a separate window) or tmux (a real
|
|
5
|
+
// multiplexer), a shell process CANNOT split the VS Code terminal itself — the
|
|
6
|
+
// `code` CLI exposes no "run command" verb. So we automate everything AROUND the
|
|
7
|
+
// split: do all the state/statusLine wiring, run Claude in the CURRENT pane, and
|
|
8
|
+
// hand the user a prominent, clipboard-copied one-liner for the new pane.
|
|
9
|
+
//
|
|
10
|
+
// run(profile) → banner + clipboard + Claude in this pane; sidecar one-liner
|
|
11
|
+
// for the split pane (Ctrl+Shift+5 / Cmd+\).
|
|
12
|
+
// hint(dir) → reprint that banner + re-copy the one-liner (no Claude).
|
|
13
|
+
//
|
|
14
|
+
// The zero-dependency / no-config-mutation contracts are preserved exactly as in
|
|
15
|
+
// launch-win: statusLine is injected via a per-launch temp settings file.
|
|
16
|
+
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const launchWin = require('./launch-win');
|
|
20
|
+
const inject = require('./settings-inject');
|
|
21
|
+
const { ensureSecureDir } = require('./state-dir');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* VS Code's "Split Terminal" default keybinding, per platform.
|
|
25
|
+
* @param {string} [platform]
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function splitKeybinding(platform) {
|
|
29
|
+
return (platform || process.platform) === 'darwin' ? 'Cmd+\\' : 'Ctrl+Shift+5';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The command the user runs in the split pane. Carries the resolved state dir as
|
|
34
|
+
* an explicit arg (shell-agnostic: no per-shell `set`/`$env:`/`export` needed).
|
|
35
|
+
* Prefers the `ccr` binary when on PATH; falls back to node + ccr.js by path.
|
|
36
|
+
* @param {{ stateDir: string, ccrBin?: string|null, node: string, ccrJs: string, hint?: boolean }} o
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function sidecarPasteCommand(o) {
|
|
40
|
+
const head = o.ccrBin ? 'ccr' : `"${o.node}" "${o.ccrJs}"`;
|
|
41
|
+
const tail = o.hint ? ' --hint' : ` --state-dir "${o.stateDir}"`;
|
|
42
|
+
return `${head} sidecar${tail}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* OSC 52 clipboard-set escape — zero-dep, works over SSH/remote/dev-containers,
|
|
47
|
+
* and honored by VS Code's terminal. Setting the clipboard needs no native tool.
|
|
48
|
+
* @param {string} text
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function osc52(text) {
|
|
52
|
+
return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The prominent, scrollback-findable banner. A bright reverse-video header plus a
|
|
57
|
+
* blinking ▶/◀ marker is the "animated cue" — terminals honoring blink animate
|
|
58
|
+
* it (live AND when scrolled to); elsewhere it degrades to a bold static banner.
|
|
59
|
+
* @param {{ sidecarCmd: string, splitKey: string, hintCmd: string, color: boolean }} o
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function buildBanner(o) {
|
|
63
|
+
const c = o.color ? (/** @type {string} */ code, /** @type {string} */ s) => `\x1b[${code}m${s}\x1b[0m` : (/** @type {string} */ _code, /** @type {string} */ s) => s;
|
|
64
|
+
const blink = o.color ? (/** @type {string} */ s) => `\x1b[5m${s}\x1b[25m` : (/** @type {string} */ s) => s;
|
|
65
|
+
return [
|
|
66
|
+
'',
|
|
67
|
+
c('1;30;103', ` ${blink('▶')} ccr · live sidecar — split your VS Code terminal ${blink('◀')} `),
|
|
68
|
+
'',
|
|
69
|
+
` ${c('1;96', '1.')} Split this terminal: ${c('1;97', o.splitKey)}`,
|
|
70
|
+
` ${c('1;96', '2.')} In the new pane, run ${c('2', '(already on your clipboard — just paste)')}:`,
|
|
71
|
+
'',
|
|
72
|
+
` ${c('1;92', o.sidecarCmd)}`,
|
|
73
|
+
'',
|
|
74
|
+
c('2', ` Claude is starting in THIS pane. Lost these steps? Run: ${o.hintCmd}`),
|
|
75
|
+
'',
|
|
76
|
+
].join('\n') + '\n';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Best-effort copy: OSC 52 first (covers VS Code + remote), then a native tool
|
|
81
|
+
* for terminals with OSC 52 disabled. Never throws; silent if nothing is found.
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @param {{ platform: string, out: (s: string) => void, spawnCopy: (cmd: string, args: string[], input: string) => { status: number|null, error?: Error } }} d
|
|
84
|
+
*/
|
|
85
|
+
function copyToClipboard(text, d) {
|
|
86
|
+
try { d.out(osc52(text)); } catch { /* terminal may not support OSC 52 */ }
|
|
87
|
+
const p = d.platform;
|
|
88
|
+
const attempts = p === 'win32' ? [['clip', []]]
|
|
89
|
+
: p === 'darwin' ? [['pbcopy', []]]
|
|
90
|
+
: [['wl-copy', []], ['xclip', ['-selection', 'clipboard']]];
|
|
91
|
+
for (const [cmd, args] of attempts) {
|
|
92
|
+
try {
|
|
93
|
+
const r = d.spawnCopy(String(cmd), /** @type {string[]} */ (args), text);
|
|
94
|
+
if (r && !r.error && (r.status === 0 || r.status == null)) return;
|
|
95
|
+
} catch { /* try the next tool */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `ccr [profile]` inside a VS Code integrated terminal: wire the split-view
|
|
101
|
+
* sidecar, then run Claude in the current pane. Returns Claude's exit code.
|
|
102
|
+
* @param {string} [profile]
|
|
103
|
+
* @param {Partial<Deps>} [deps]
|
|
104
|
+
* @returns {number}
|
|
105
|
+
*/
|
|
106
|
+
function run(profile, deps = {}) {
|
|
107
|
+
const d = withDefaults(deps);
|
|
108
|
+
|
|
109
|
+
if (profile !== undefined && !launchWin.validateProfile(profile)) {
|
|
110
|
+
d.err(`ccr: invalid profile name '${profile}' (allowed: letters, digits, . _ -)\n`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const st = launchWin.resolveProfileState(profile, { env: d.env, home: d.home });
|
|
115
|
+
if (st.usesCcs) {
|
|
116
|
+
if (!d.which('ccs')) {
|
|
117
|
+
d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
if (st.instanceDir && !d.existsDir(st.instanceDir)) {
|
|
121
|
+
d.err(`ccr: CCS profile '${profile}' not found (${st.instanceDir}).\n`);
|
|
122
|
+
d.err(` available: ${d.listDir(path.join(d.home, '.ccs', 'instances')).join(' ')}\n`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
const bin = st.ccCmd.split(' ')[0];
|
|
127
|
+
if (!d.which(bin)) { d.err(`ccr: '${bin}' not found on PATH.\n`); return 1; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
|
|
131
|
+
d.removeExited(st.stateDir);
|
|
132
|
+
|
|
133
|
+
// statusLine via a per-launch temp settings file (no ~/.claude mutation).
|
|
134
|
+
const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
|
|
135
|
+
const settingsFile = d.writeSettings(inject.buildSettings(command));
|
|
136
|
+
|
|
137
|
+
// Show the split instructions + copy the sidecar one-liner BEFORE Claude takes
|
|
138
|
+
// over the pane (the clipboard + hint make it recoverable once it scrolls off).
|
|
139
|
+
const ccrBin = d.which('ccr');
|
|
140
|
+
const sidecarCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
|
|
141
|
+
const hintCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
|
|
142
|
+
d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
|
|
143
|
+
copyToClipboard(sidecarCmd, d);
|
|
144
|
+
|
|
145
|
+
// Run Claude in the current pane (blocks until exit). The temp settings file is
|
|
146
|
+
// always removed; the "session ended" sentinel is only dropped if Claude
|
|
147
|
+
// actually ran — a failed spawn must NOT flip the sidecar to "ended".
|
|
148
|
+
const parts = st.ccCmd.split(' ');
|
|
149
|
+
const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile]);
|
|
150
|
+
d.cleanup(settingsFile);
|
|
151
|
+
if (r && r.error) { d.err(`ccr: failed to launch Claude: ${r.error.message}\n`); return 1; }
|
|
152
|
+
d.dropExited(st.stateDir);
|
|
153
|
+
return r && typeof r.status === 'number' ? r.status : 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reprint the split instructions and re-copy the sidecar one-liner for an
|
|
158
|
+
* already-resolved state dir. Backs `ccr sidecar --hint`. Never launches Claude.
|
|
159
|
+
* @param {string} stateDir
|
|
160
|
+
* @param {Partial<Deps>} [deps]
|
|
161
|
+
* @returns {number}
|
|
162
|
+
*/
|
|
163
|
+
function hint(stateDir, deps = {}) {
|
|
164
|
+
const d = withDefaults(deps);
|
|
165
|
+
const ccrBin = d.which('ccr');
|
|
166
|
+
const sidecarCmd = sidecarPasteCommand({ stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
|
|
167
|
+
const hintCmd = sidecarPasteCommand({ stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
|
|
168
|
+
d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
|
|
169
|
+
copyToClipboard(sidecarCmd, d);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build the spawn invocation for launching Claude in the CURRENT pane.
|
|
175
|
+
*
|
|
176
|
+
* On POSIX this is a direct exec of `bin`. On Windows the resolved binary is
|
|
177
|
+
* typically `claude.cmd` / `ccs.cmd`, which Node's spawnSync refuses to run by
|
|
178
|
+
* bare name without a shell (it would ENOENT) — so we resolve the real path via
|
|
179
|
+
* `which` and run it through cmd.exe with our own quoting, which keeps a temp
|
|
180
|
+
* path containing spaces working. We reject the two characters cmd quoting
|
|
181
|
+
* cannot neutralize — `"` (ends the quote) and `%` (cmd expansion), plus CR/LF —
|
|
182
|
+
* with a clear error rather than spawn a broken or hijackable line. This mirrors
|
|
183
|
+
* the WT_UNSAFE policy in launch-win.js; trust boundary is the user's own env.
|
|
184
|
+
*
|
|
185
|
+
* Returns either a spawn descriptor or, on a rejected character, an `{ error }`
|
|
186
|
+
* that run() surfaces exactly like a spawn failure.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} bin
|
|
189
|
+
* @param {string[]} args
|
|
190
|
+
* @param {{ platform: string, which: (name: string) => (string|null) }} o
|
|
191
|
+
* @returns {{ command: string, args: string[]|null, shell: boolean } | { error: Error }}
|
|
192
|
+
*/
|
|
193
|
+
function buildClaudeSpawn(bin, args, o) {
|
|
194
|
+
if (o.platform !== 'win32') {
|
|
195
|
+
return { command: bin, args, shell: false };
|
|
196
|
+
}
|
|
197
|
+
const resolved = o.which(bin) || bin;
|
|
198
|
+
const all = [resolved, ...args.map(String)];
|
|
199
|
+
const bad = all.find((p) => /["%\r\n]/.test(p));
|
|
200
|
+
if (bad !== undefined) {
|
|
201
|
+
return { error: new Error(
|
|
202
|
+
`argument contains an unsupported character (", %, or newline) for the Windows shell: ${JSON.stringify(bad)}`,
|
|
203
|
+
) };
|
|
204
|
+
}
|
|
205
|
+
// cmd.exe strips the outer quote pair Node adds around the /c payload, leaving
|
|
206
|
+
// each value individually quoted — spaces are safe, `"`/`%` are pre-rejected.
|
|
207
|
+
return { command: all.map((p) => `"${p}"`).join(' '), args: null, shell: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Real-environment Claude launcher: resolve+route through cmd.exe on Windows
|
|
212
|
+
* (see buildClaudeSpawn), direct exec elsewhere. Stays injectable so tests drive
|
|
213
|
+
* run() without spawning — but the Windows resolution itself is unit-tested via
|
|
214
|
+
* buildClaudeSpawn so this seam can't silently regress to a bare-name spawn.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} bin
|
|
217
|
+
* @param {string[]} args
|
|
218
|
+
* @returns {{ status: number|null, error?: Error }}
|
|
219
|
+
*/
|
|
220
|
+
function defaultSpawnClaude(bin, args) {
|
|
221
|
+
const built = buildClaudeSpawn(bin, args, { platform: process.platform, which: defaultWhich });
|
|
222
|
+
if ('error' in built) return { status: null, error: built.error };
|
|
223
|
+
const { spawnSync } = require('node:child_process');
|
|
224
|
+
return built.shell
|
|
225
|
+
? spawnSync(built.command, { stdio: 'inherit', shell: true })
|
|
226
|
+
: spawnSync(built.command, built.args || [], { stdio: 'inherit' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @param {string} name @returns {string|null} */
|
|
230
|
+
function defaultWhich(name) {
|
|
231
|
+
const { spawnSync } = require('node:child_process');
|
|
232
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
233
|
+
const r = spawnSync(finder, [name], { encoding: 'utf8' });
|
|
234
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
235
|
+
return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {Partial<Deps>} deps
|
|
240
|
+
* @returns {Deps}
|
|
241
|
+
*/
|
|
242
|
+
function withDefaults(deps) {
|
|
243
|
+
const env = deps.env || process.env;
|
|
244
|
+
return {
|
|
245
|
+
env,
|
|
246
|
+
home: deps.home || os.homedir(),
|
|
247
|
+
node: deps.node || process.execPath,
|
|
248
|
+
ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
|
|
249
|
+
platform: deps.platform || process.platform,
|
|
250
|
+
color: deps.color != null ? deps.color : (!!process.stdout.isTTY && !env.NO_COLOR),
|
|
251
|
+
out: deps.out || ((s) => { process.stdout.write(s); }),
|
|
252
|
+
err: deps.err || ((s) => { process.stderr.write(s); }),
|
|
253
|
+
which: deps.which || defaultWhich,
|
|
254
|
+
existsDir: deps.existsDir || ((dir) => { try { return require('node:fs').statSync(dir).isDirectory(); } catch { return false; } }),
|
|
255
|
+
listDir: deps.listDir || ((dir) => { try { return require('node:fs').readdirSync(dir); } catch { return []; } }),
|
|
256
|
+
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
257
|
+
removeExited: deps.removeExited || ((dir) => { try { require('node:fs').rmSync(path.join(dir, 'exited'), { force: true }); } catch { /* best effort */ } }),
|
|
258
|
+
dropExited: deps.dropExited || ((dir) => { try { require('node:fs').writeFileSync(path.join(dir, 'exited'), ''); } catch { /* best effort */ } }),
|
|
259
|
+
writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
|
|
260
|
+
cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
|
|
261
|
+
spawnClaude: deps.spawnClaude || defaultSpawnClaude,
|
|
262
|
+
spawnCopy: deps.spawnCopy || ((cmd, args, input) => require('node:child_process').spawnSync(cmd, args, { input, stdio: ['pipe', 'ignore', 'ignore'] })),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @typedef {object} Deps
|
|
268
|
+
* @property {NodeJS.ProcessEnv} env
|
|
269
|
+
* @property {string} home
|
|
270
|
+
* @property {string} node
|
|
271
|
+
* @property {string} ccrJs
|
|
272
|
+
* @property {string} platform
|
|
273
|
+
* @property {boolean} color
|
|
274
|
+
* @property {(s: string) => void} out
|
|
275
|
+
* @property {(s: string) => void} err
|
|
276
|
+
* @property {(name: string) => (string|null)} which
|
|
277
|
+
* @property {(dir: string) => boolean} existsDir
|
|
278
|
+
* @property {(dir: string) => string[]} listDir
|
|
279
|
+
* @property {(dir: string) => void} ensureDir
|
|
280
|
+
* @property {(dir: string) => void} removeExited
|
|
281
|
+
* @property {(dir: string) => void} dropExited
|
|
282
|
+
* @property {(settings: object) => string} writeSettings
|
|
283
|
+
* @property {(file: string) => void} cleanup
|
|
284
|
+
* @property {(bin: string, args: string[]) => {status: number|null, error?: Error}} spawnClaude
|
|
285
|
+
* @property {(cmd: string, args: string[], input: string) => {status: number|null, error?: Error}} spawnCopy
|
|
286
|
+
*/
|
|
287
|
+
|
|
288
|
+
module.exports = { splitKeybinding, sidecarPasteCommand, osc52, buildBanner, copyToClipboard, buildClaudeSpawn, run, hint };
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// src/launch-win.js — native-Windows launcher for `ccr` / `ccr <profile>`.
|
|
5
|
+
//
|
|
6
|
+
// Replaces the bash+tmux launch layer (scripts/launch.sh) on win32 by driving
|
|
7
|
+
// Windows Terminal (wt.exe) split panes: pane 0 runs Claude Code, pane 1 runs
|
|
8
|
+
// `ccr sidecar`, with CCR_STATE_DIR injected into both. See SPEC §4.2/§5.2.
|
|
9
|
+
//
|
|
10
|
+
// The pure, side-effect-free helpers (profile validation, state resolution,
|
|
11
|
+
// wt.exe argv construction, the injected wt.exe lookup) are unit-tested without
|
|
12
|
+
// spawning anything; run()/fallbackNoWt() drive the real side effects through
|
|
13
|
+
// the injectable Deps in withDefaults().
|
|
14
|
+
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
|
|
18
|
+
// Profile names land in filesystem paths and a spawned command, so allow only a
|
|
19
|
+
// safe identifier — identical to the allow-list in scripts/launch.sh.
|
|
20
|
+
const PROFILE_RE = /^[A-Za-z0-9._-]+$/;
|
|
21
|
+
|
|
22
|
+
// Characters we cannot safely embed in the per-pane `cmd /k` payload, even with
|
|
23
|
+
// every path wrapped in double quotes:
|
|
24
|
+
// " ends the quoted string;
|
|
25
|
+
// % triggers cmd.exe variable expansion (it fires even inside quotes);
|
|
26
|
+
// \r \n break the command line.
|
|
27
|
+
// By contrast & | < > ^ ( ) ARE literal inside the double quotes we wrap every
|
|
28
|
+
// value in, so they need no special handling. The trust boundary here is the
|
|
29
|
+
// user's own env/paths (self-injection, not RCE), so the goal is to turn a
|
|
30
|
+
// silently-broken — or, with %, a maliciously-expandable — command into a clear,
|
|
31
|
+
// actionable error rather than to defend against an attacker.
|
|
32
|
+
const WT_UNSAFE_RE = /["%\r\n]/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} value
|
|
36
|
+
* @returns {boolean} true if `value` can be safely interpolated into the cmd /k payload
|
|
37
|
+
*/
|
|
38
|
+
function isWtArgSafe(value) {
|
|
39
|
+
return !WT_UNSAFE_RE.test(String(value));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Upstream default split: the sidecar gets ~34% of the width.
|
|
43
|
+
const DEFAULT_SIDEBAR_PCT = 34;
|
|
44
|
+
|
|
45
|
+
// Where the sidecar pane sits relative to Claude. 'right' is a vertical split
|
|
46
|
+
// (wt split-pane -V, the default — matches the width-based sizing above);
|
|
47
|
+
// 'bottom' is a horizontal split (-H). Set via CCR_SIDEBAR_SIDE.
|
|
48
|
+
const DEFAULT_SIDEBAR_SIDE = 'right';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Map a sidebar side ('right' | 'bottom') to the wt.exe split-pane flag.
|
|
52
|
+
* 'right' → '-V' (new pane to the right), 'bottom' → '-H' (new pane below).
|
|
53
|
+
* Anything unrecognized falls back to the default side.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} [side]
|
|
56
|
+
* @returns {'-V'|'-H'}
|
|
57
|
+
*/
|
|
58
|
+
function sidebarSplitFlag(side) {
|
|
59
|
+
const s = String(side || DEFAULT_SIDEBAR_SIDE).toLowerCase();
|
|
60
|
+
return s === 'bottom' ? '-H' : '-V';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {unknown} profile
|
|
65
|
+
* @returns {boolean} true if the profile name is a safe identifier
|
|
66
|
+
*/
|
|
67
|
+
function validateProfile(profile) {
|
|
68
|
+
return typeof profile === 'string' && PROFILE_RE.test(profile);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the Claude command, tmux-equivalent session name, state dir, and (for
|
|
73
|
+
* profiles) the expected CCS instance dir. Mirrors scripts/launch.sh, honoring
|
|
74
|
+
* the CC_BIN / CCR_SESSION / CCR_STATE_DIR overrides.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} [profile] CCS profile name, or undefined for plain `claude`
|
|
77
|
+
* @param {{ env?: NodeJS.ProcessEnv, home?: string }} [opts]
|
|
78
|
+
* @returns {{ ccCmd: string, session: string, stateDir: string,
|
|
79
|
+
* instanceDir: string|null, usesCcs: boolean }}
|
|
80
|
+
*/
|
|
81
|
+
function resolveProfileState(profile, opts = {}) {
|
|
82
|
+
const env = opts.env || process.env;
|
|
83
|
+
const home = opts.home || os.homedir();
|
|
84
|
+
|
|
85
|
+
if (profile) {
|
|
86
|
+
return {
|
|
87
|
+
ccCmd: `ccs ${profile}`,
|
|
88
|
+
session: env.CCR_SESSION || `ccr-${profile}`,
|
|
89
|
+
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', profile),
|
|
90
|
+
instanceDir: path.join(home, '.ccs', 'instances', profile),
|
|
91
|
+
usesCcs: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ccCmd: env.CC_BIN || 'claude',
|
|
96
|
+
session: env.CCR_SESSION || 'ccr',
|
|
97
|
+
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr'),
|
|
98
|
+
instanceDir: null,
|
|
99
|
+
usesCcs: false,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Convert a width percentage (e.g. 34) into the fraction string wt.exe's
|
|
105
|
+
* `split-pane -s` expects (e.g. "0.34"). Clamps to a sane 5..95 range.
|
|
106
|
+
*
|
|
107
|
+
* @param {number} [pct]
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function sidebarFraction(pct) {
|
|
111
|
+
let p = Number(pct);
|
|
112
|
+
if (!Number.isFinite(p)) p = DEFAULT_SIDEBAR_PCT;
|
|
113
|
+
p = Math.min(95, Math.max(5, Math.round(p)));
|
|
114
|
+
// Strip any float noise: 34 -> "0.34", 50 -> "0.5".
|
|
115
|
+
return String(Math.round(p) / 100);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the per-pane cmd.exe payload. Env is injected with `set "VAR=val"` so a
|
|
120
|
+
* path with spaces (or & | < >, which are literal inside the quotes) is fine and
|
|
121
|
+
* no trailing space is captured. Callers MUST pre-validate `stateDir` with
|
|
122
|
+
* isWtArgSafe — `"` and `%` are NOT made safe by these quotes (see WT_UNSAFE_RE).
|
|
123
|
+
*
|
|
124
|
+
* @param {string} stateDir
|
|
125
|
+
* @param {string} body the command(s) to run after the env is set
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
function paneCommand(stateDir, body) {
|
|
129
|
+
return `set "CCR_STATE_DIR=${stateDir}"&& ${body}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the argv passed to wt.exe (excluding the wt.exe path itself):
|
|
134
|
+
* new-tab --title Claude cmd /k "<pane0>" ; split-pane -H -s <frac> cmd /k "<pane1>"
|
|
135
|
+
*
|
|
136
|
+
* The ";" pane separator is its own argv token (wt re-parses it). Per-pane env
|
|
137
|
+
* is injected via `cmd /k set ...` rather than wt global env. After Claude
|
|
138
|
+
* exits, pane 0 (unconditional `&`) drops the `exited` sentinel (so the sidecar
|
|
139
|
+
* can show a clean "session ended" state) and deletes the temp settings file
|
|
140
|
+
* (cleanup-after-window-closes; the file was only needed at Claude startup).
|
|
141
|
+
*
|
|
142
|
+
* Throws if any interpolated value contains a character that would break (or, in
|
|
143
|
+
* the case of %, hijack) the cmd /k payload — see isWtArgSafe. run() catches
|
|
144
|
+
* this and reports a clean error instead of spawning a broken command.
|
|
145
|
+
*
|
|
146
|
+
* @param {{ ccCmd: string, settingsFile: string, stateDir: string,
|
|
147
|
+
* node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string }} o
|
|
148
|
+
* @returns {string[]}
|
|
149
|
+
*/
|
|
150
|
+
function buildWtArgs(o) {
|
|
151
|
+
const { ccCmd, settingsFile, stateDir, node, ccrJs } = o;
|
|
152
|
+
for (const [label, value] of [
|
|
153
|
+
['profile/state dir', stateDir],
|
|
154
|
+
['settings file path', settingsFile],
|
|
155
|
+
['claude command', ccCmd],
|
|
156
|
+
['node path', node],
|
|
157
|
+
['ccr.js path', ccrJs],
|
|
158
|
+
]) {
|
|
159
|
+
if (!isWtArgSafe(value)) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`cannot launch: ${label} contains an unsupported character (" or %) for the ` +
|
|
162
|
+
`Windows Terminal launcher: ${value}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const frac = sidebarFraction(o.sidebarPct);
|
|
167
|
+
const splitFlag = sidebarSplitFlag(o.sidebarSide);
|
|
168
|
+
const exited = path.win32.join(stateDir, 'exited');
|
|
169
|
+
|
|
170
|
+
const pane0 = paneCommand(
|
|
171
|
+
stateDir,
|
|
172
|
+
`${ccCmd} --settings "${settingsFile}" & type nul > "${exited}" & del /q "${settingsFile}"`,
|
|
173
|
+
);
|
|
174
|
+
const pane1 = paneCommand(stateDir, `"${node}" "${ccrJs}" sidecar`);
|
|
175
|
+
|
|
176
|
+
return [
|
|
177
|
+
'new-tab', '--title', 'Claude', 'cmd', '/k', pane0,
|
|
178
|
+
';',
|
|
179
|
+
'split-pane', splitFlag, '-s', frac, 'cmd', '/k', pane1,
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve wt.exe via `where`, or return null if absent. The lookup is injected
|
|
185
|
+
* so this stays unit-testable; the default shells out to `where`.
|
|
186
|
+
*
|
|
187
|
+
* @param {{ runWhere?: (name: string) => (string|null) }} [opts]
|
|
188
|
+
* @returns {string|null}
|
|
189
|
+
*/
|
|
190
|
+
function findWindowsTerminal(opts = {}) {
|
|
191
|
+
const lookup = opts.runWhere || defaultWhere;
|
|
192
|
+
return lookup('wt') || lookup('wt.exe') || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} name
|
|
197
|
+
* @returns {string|null} first match path, or null
|
|
198
|
+
*/
|
|
199
|
+
function defaultWhere(name) {
|
|
200
|
+
const { spawnSync } = require('node:child_process');
|
|
201
|
+
const r = spawnSync('where', [name], { encoding: 'utf8' });
|
|
202
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
203
|
+
const first = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
|
|
204
|
+
return first || null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const inject = require('./settings-inject');
|
|
208
|
+
const { ensureSecureDir } = require('./state-dir');
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Fill in real-environment implementations for anything the caller didn't
|
|
212
|
+
* inject. Every external effect (PATH lookups, fs, spawn, output) flows through
|
|
213
|
+
* here so run() can be unit-tested with pure stand-ins.
|
|
214
|
+
*
|
|
215
|
+
* @param {Partial<Deps>} deps
|
|
216
|
+
* @returns {Deps}
|
|
217
|
+
*/
|
|
218
|
+
function withDefaults(deps) {
|
|
219
|
+
const env = deps.env || process.env;
|
|
220
|
+
const home = deps.home || os.homedir();
|
|
221
|
+
return {
|
|
222
|
+
env,
|
|
223
|
+
home,
|
|
224
|
+
node: deps.node || process.execPath,
|
|
225
|
+
ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
|
|
226
|
+
out: deps.out || ((s) => { process.stdout.write(s); }),
|
|
227
|
+
err: deps.err || ((s) => { process.stderr.write(s); }),
|
|
228
|
+
findWt: deps.findWt || (() => findWindowsTerminal()),
|
|
229
|
+
which: deps.which || defaultWhere,
|
|
230
|
+
existsDir: deps.existsDir || defaultExistsDir,
|
|
231
|
+
listDir: deps.listDir || defaultListDir,
|
|
232
|
+
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
233
|
+
removeExited: deps.removeExited || defaultRemoveExited,
|
|
234
|
+
writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
|
|
235
|
+
cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
|
|
236
|
+
spawnWt: deps.spawnWt || defaultSpawnWt,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @param {string} dir @returns {boolean} */
|
|
241
|
+
function defaultExistsDir(dir) {
|
|
242
|
+
try {
|
|
243
|
+
return require('node:fs').statSync(dir).isDirectory();
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @param {string} dir @returns {string[]} */
|
|
250
|
+
function defaultListDir(dir) {
|
|
251
|
+
try {
|
|
252
|
+
return require('node:fs').readdirSync(dir);
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @param {string} stateDir @returns {void} */
|
|
259
|
+
function defaultRemoveExited(stateDir) {
|
|
260
|
+
try {
|
|
261
|
+
require('node:fs').rmSync(path.join(stateDir, 'exited'), { force: true });
|
|
262
|
+
} catch {
|
|
263
|
+
// best effort
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** @param {string} wt @param {string[]} args @returns {{status:number|null, error?:Error}} */
|
|
268
|
+
function defaultSpawnWt(wt, args) {
|
|
269
|
+
return require('node:child_process').spawnSync(wt, args, { stdio: 'ignore' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The graceful no-Windows-Terminal fallback: keep the working native CLI usable
|
|
274
|
+
* instead of dead-ending. Returns 1 (no crash, no stack trace). SPEC §6 / §8.7.
|
|
275
|
+
*
|
|
276
|
+
* @param {{ err: (s: string) => void }} d
|
|
277
|
+
* @returns {number}
|
|
278
|
+
*/
|
|
279
|
+
function fallbackNoWt(d) {
|
|
280
|
+
d.err(
|
|
281
|
+
'ccr: Windows Terminal (wt.exe) not found — it hosts the live sidecar.\n' +
|
|
282
|
+
' Install it: winget install Microsoft.WindowsTerminal\n' +
|
|
283
|
+
' Until then these native commands work without it:\n' +
|
|
284
|
+
' ccr economy one-off economy panel\n' +
|
|
285
|
+
" ccr statusline wire into Claude Code's statusLine\n" +
|
|
286
|
+
' ccr doctor check your setup\n');
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `ccr [profile]` on native Windows: split a Windows Terminal window into
|
|
292
|
+
* Claude Code + ccr sidecar. Implements SPEC §4.2 steps 1–7. Returns an exit
|
|
293
|
+
* code. All side effects are injectable for testing (see withDefaults).
|
|
294
|
+
*
|
|
295
|
+
* @param {string} [profile]
|
|
296
|
+
* @param {Partial<Deps>} [deps]
|
|
297
|
+
* @returns {number}
|
|
298
|
+
*/
|
|
299
|
+
function run(profile, deps = {}) {
|
|
300
|
+
const d = withDefaults(deps);
|
|
301
|
+
|
|
302
|
+
// 1. Validate the profile (it lands in paths and a spawned command).
|
|
303
|
+
if (profile !== undefined && !validateProfile(profile)) {
|
|
304
|
+
d.err(`ccr: invalid profile name '${profile}' (allowed: letters, digits, . _ -)\n`);
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 2. Require Windows Terminal, else fall back gracefully.
|
|
309
|
+
const wt = d.findWt();
|
|
310
|
+
if (!wt) return fallbackNoWt(d);
|
|
311
|
+
|
|
312
|
+
// 3. Resolve profile state + required binaries.
|
|
313
|
+
const st = resolveProfileState(profile, { env: d.env, home: d.home });
|
|
314
|
+
if (st.usesCcs) {
|
|
315
|
+
if (!d.which('ccs')) {
|
|
316
|
+
d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
if (st.instanceDir && !d.existsDir(st.instanceDir)) {
|
|
320
|
+
d.err(`ccr: CCS profile '${profile}' not found (${st.instanceDir}).\n`);
|
|
321
|
+
const avail = d.listDir(path.join(d.home, '.ccs', 'instances')).join(' ');
|
|
322
|
+
d.err(` available: ${avail}\n`);
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
const bin = st.ccCmd.split(' ')[0];
|
|
327
|
+
if (!d.which(bin)) {
|
|
328
|
+
d.err(`ccr: '${bin}' not found on PATH.\n`);
|
|
329
|
+
return 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 4. Prepare the per-profile state dir; clear a stale sentinel.
|
|
334
|
+
try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
|
|
335
|
+
d.removeExited(st.stateDir);
|
|
336
|
+
|
|
337
|
+
// 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
|
|
338
|
+
const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
|
|
339
|
+
const settingsFile = d.writeSettings(inject.buildSettings(command));
|
|
340
|
+
|
|
341
|
+
// 6. Build + spawn the wt.exe command (CCR_STATE_DIR injected per-pane).
|
|
342
|
+
const pct = parseInt(String(d.env.CCR_SIDEBAR_PCT), 10);
|
|
343
|
+
let args;
|
|
344
|
+
try {
|
|
345
|
+
args = buildWtArgs({
|
|
346
|
+
ccCmd: st.ccCmd,
|
|
347
|
+
settingsFile,
|
|
348
|
+
stateDir: st.stateDir,
|
|
349
|
+
node: d.node,
|
|
350
|
+
ccrJs: d.ccrJs,
|
|
351
|
+
sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
|
|
352
|
+
sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
|
|
353
|
+
});
|
|
354
|
+
} catch (e) {
|
|
355
|
+
d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
356
|
+
d.cleanup(settingsFile); // window never opened → nothing else will clean up
|
|
357
|
+
return 1;
|
|
358
|
+
}
|
|
359
|
+
const r = d.spawnWt(wt, args);
|
|
360
|
+
if (r.error) {
|
|
361
|
+
d.err(`ccr: failed to launch Windows Terminal: ${r.error.message}\n`);
|
|
362
|
+
d.cleanup(settingsFile); // window never opened → pane 0 can't clean up
|
|
363
|
+
return 1;
|
|
364
|
+
}
|
|
365
|
+
// 7. On success the settings file is cleaned up by pane 0 when Claude exits.
|
|
366
|
+
return typeof r.status === 'number' ? r.status : 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @typedef {object} Deps
|
|
371
|
+
* @property {NodeJS.ProcessEnv} env
|
|
372
|
+
* @property {string} home
|
|
373
|
+
* @property {string} node
|
|
374
|
+
* @property {string} ccrJs
|
|
375
|
+
* @property {(s: string) => void} out
|
|
376
|
+
* @property {(s: string) => void} err
|
|
377
|
+
* @property {() => (string|null)} findWt
|
|
378
|
+
* @property {(name: string) => (string|null)} which
|
|
379
|
+
* @property {(dir: string) => boolean} existsDir
|
|
380
|
+
* @property {(dir: string) => string[]} listDir
|
|
381
|
+
* @property {(dir: string) => void} ensureDir
|
|
382
|
+
* @property {(dir: string) => void} removeExited
|
|
383
|
+
* @property {(settings: object) => string} writeSettings
|
|
384
|
+
* @property {(file: string) => void} cleanup
|
|
385
|
+
* @property {(wt: string, args: string[]) => {status: number|null, error?: Error}} spawnWt
|
|
386
|
+
*/
|
|
387
|
+
|
|
388
|
+
module.exports = {
|
|
389
|
+
PROFILE_RE,
|
|
390
|
+
DEFAULT_SIDEBAR_PCT,
|
|
391
|
+
DEFAULT_SIDEBAR_SIDE,
|
|
392
|
+
validateProfile,
|
|
393
|
+
isWtArgSafe,
|
|
394
|
+
resolveProfileState,
|
|
395
|
+
sidebarFraction,
|
|
396
|
+
sidebarSplitFlag,
|
|
397
|
+
buildWtArgs,
|
|
398
|
+
findWindowsTerminal,
|
|
399
|
+
run,
|
|
400
|
+
fallbackNoWt,
|
|
401
|
+
};
|
package/src/render/economy.js
CHANGED
|
@@ -15,7 +15,11 @@ const { dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins,
|
|
|
15
15
|
const bandColor = { imminent: red, warn: yellow, ok: cyan };
|
|
16
16
|
|
|
17
17
|
function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolean} */ tick, /** @type {number} */ labelW) {
|
|
18
|
-
|
|
18
|
+
// Truncate, don't round: Claude's own surfaces (`/usage`, claude.ai usage)
|
|
19
|
+
// floor the fractional `used_percentage` (e.g. 41.6 → "41%"). Math.round here
|
|
20
|
+
// read ~1pt high on values past the half-point. Display only — the burn/ROI
|
|
21
|
+
// math below still uses the raw fractional `row.est.usedPct`.
|
|
22
|
+
const used = Math.floor(row.est.usedPct);
|
|
19
23
|
const ml = row.est.minutesLeft;
|
|
20
24
|
const b = band(ml);
|
|
21
25
|
// Per-row colour dot: green when the window resets before you'd hit it,
|
package/src/render/shared.js
CHANGED
|
@@ -21,6 +21,34 @@ function bar(/** @type {number} */ p, w = 10) {
|
|
|
21
21
|
return '▓'.repeat(f) + '░'.repeat(w - f);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Clamp one line to `cols` visible columns: SGR escapes (`\x1b[…m`) pass through
|
|
26
|
+
* with zero width, printable chars count as 1. Appends a reset if it had to cut,
|
|
27
|
+
* so a severed colour run doesn't bleed into the cleared tail. Prevents the soft
|
|
28
|
+
* wrap that corrupts the sidecar's cursor-home redraw in a narrow pane. A
|
|
29
|
+
* non-positive `cols` (e.g. a non-TTY where columns is undefined) is a no-op.
|
|
30
|
+
* @param {string} line
|
|
31
|
+
* @param {number} [cols]
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function clampVisible(line, cols) {
|
|
35
|
+
if (!(typeof cols === 'number' && cols > 0)) return line;
|
|
36
|
+
const sgr = /\x1b\[[0-9;]*m/y;
|
|
37
|
+
let out = '';
|
|
38
|
+
let width = 0;
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < line.length) {
|
|
41
|
+
sgr.lastIndex = i;
|
|
42
|
+
const m = sgr.exec(line);
|
|
43
|
+
if (m) { out += m[0]; i = sgr.lastIndex; continue; }
|
|
44
|
+
if (width >= cols) return out + '\x1b[0m';
|
|
45
|
+
out += line[i];
|
|
46
|
+
width += 1;
|
|
47
|
+
i += 1;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
function tok(/** @type {number|null} */ n) {
|
|
25
53
|
if (n == null) return '?';
|
|
26
54
|
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
@@ -46,4 +74,4 @@ function fmtReset(/** @type {number|null} */ min) {
|
|
|
46
74
|
return `${m}m`;
|
|
47
75
|
}
|
|
48
76
|
|
|
49
|
-
module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins, fmtReset };
|
|
77
|
+
module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// src/settings-inject.js — per-launch statusLine injection (SPEC §4.2 step 5, §5.3).
|
|
5
|
+
//
|
|
6
|
+
// Claude Code must call `ccr statusline` on every status tick WITHOUT mutating
|
|
7
|
+
// any file under ~/.claude. We achieve that exactly like upstream launch.sh:
|
|
8
|
+
// write a throwaway settings object to a temp file and pass it to
|
|
9
|
+
// `claude --settings <file>`. A FILE (not inline --settings '{...}') sidesteps
|
|
10
|
+
// the Windows command-line JSON-quoting minefield.
|
|
11
|
+
//
|
|
12
|
+
// The statusLine `command` value is the inline form: node + bin/ccr.js resolved
|
|
13
|
+
// by absolute path. Because it lives inside the JSON settings file (never on a
|
|
14
|
+
// shell line), no shell-quoting is involved and no separate shim file is needed.
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Inline statusLine command: resolve node + ccr.js by path. Quoting is safe
|
|
23
|
+
* because this value lives inside the JSON settings FILE, not on a shell line.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ node: string, ccrJs: string }} o
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function buildStatusLineCommandInline(o) {
|
|
29
|
+
return `"${o.node}" "${o.ccrJs}" statusline`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The settings object Claude Code consumes via --settings.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} command the statusLine command value
|
|
36
|
+
* @returns {{ statusLine: { type: 'command', command: string } }}
|
|
37
|
+
*/
|
|
38
|
+
function buildSettings(command) {
|
|
39
|
+
return { statusLine: { type: 'command', command } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write the settings object to a uniquely-named temp file and return its path.
|
|
44
|
+
* The file lives under the temp dir only — never under ~/.claude.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} settings
|
|
47
|
+
* @param {{ tmpDir?: string, rand?: string }} [opts]
|
|
48
|
+
* @returns {string} absolute path to the written settings file
|
|
49
|
+
*/
|
|
50
|
+
function writeSettingsFile(settings, opts = {}) {
|
|
51
|
+
const dir = opts.tmpDir || os.tmpdir();
|
|
52
|
+
const rand = opts.rand || crypto.randomBytes(4).toString('hex');
|
|
53
|
+
const file = path.join(dir, `ccr-settings-${rand}.json`);
|
|
54
|
+
fs.writeFileSync(file, JSON.stringify(settings), { encoding: 'utf8' });
|
|
55
|
+
return file;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Best-effort removal of the temp settings file. Never throws.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} file
|
|
62
|
+
* @returns {void}
|
|
63
|
+
*/
|
|
64
|
+
function cleanupSettingsFile(file) {
|
|
65
|
+
try {
|
|
66
|
+
fs.rmSync(file, { force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
// best-effort: a leftover temp file is harmless.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
buildStatusLineCommandInline,
|
|
74
|
+
buildSettings,
|
|
75
|
+
writeSettingsFile,
|
|
76
|
+
cleanupSettingsFile,
|
|
77
|
+
};
|
package/src/sidecar.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/sidecar.js — the live economy panel that runs in the tmux sidebar.
|
|
4
4
|
// Reads the per-session snapshot that `ccr statusline` writes (CCR_STATE_DIR),
|
|
5
5
|
// re-renders the economy screen every second (so the imminent band flashes),
|
|
6
|
-
// and shows a clean ended
|
|
6
|
+
// and shows a clean ended/waiting state. Pure Node, zero dependencies.
|
|
7
7
|
|
|
8
8
|
const fs = require('node:fs');
|
|
9
9
|
const path = require('node:path');
|
|
@@ -11,11 +11,10 @@ const os = require('node:os');
|
|
|
11
11
|
const { normalizeStatus } = require('./normalize');
|
|
12
12
|
const { renderEconomy } = require('./render/economy');
|
|
13
13
|
const { renderFeed } = require('./render/feed');
|
|
14
|
+
const { clampVisible } = require('./render/shared');
|
|
14
15
|
const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcripts');
|
|
15
16
|
|
|
16
17
|
const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
|
|
17
|
-
const SNAPSHOT = path.join(STATE_DIR, 'last-status.json');
|
|
18
|
-
const EXITED = path.join(STATE_DIR, 'exited');
|
|
19
18
|
|
|
20
19
|
// Live feed accumulator: tail the current transcript incrementally (by byte
|
|
21
20
|
// offset) and roll up tool/skill events + per-session stats. Reset on session
|
|
@@ -57,28 +56,58 @@ function draw(/** @type {string} */ s) {
|
|
|
57
56
|
process.stdout.write('\x1b[H' + s.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Compose the screen for one tick — the ended / waiting / unreadable / live
|
|
61
|
+
* states — and return it as a string (no I/O to stdout). Pure enough to test:
|
|
62
|
+
* the only inputs are the state dir on disk, `now`, and the pane width `cols`.
|
|
63
|
+
*
|
|
64
|
+
* `cols` is the pane's visible column count (process.stdout.columns); every line
|
|
65
|
+
* is clamped to it so a wide row can't soft-wrap and corrupt the cursor-home
|
|
66
|
+
* redraw in a narrow cmd/PowerShell/split pane. Omit it (non-TTY) for no clamp.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} stateDir
|
|
69
|
+
* @param {{ now?: number, cols?: number }} [opts]
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function composeFrame(stateDir, opts = {}) {
|
|
73
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
74
|
+
const cols = opts.cols;
|
|
75
|
+
const clamp = (/** @type {string} */ s) =>
|
|
76
|
+
(typeof cols === 'number' && cols > 0
|
|
77
|
+
? s.split('\n').map((l) => clampVisible(l, cols)).join('\n')
|
|
78
|
+
: s);
|
|
79
|
+
const snapshot = path.join(stateDir, 'last-status.json');
|
|
80
|
+
const exited = path.join(stateDir, 'exited');
|
|
81
|
+
|
|
82
|
+
if (fs.existsSync(exited)) return clamp(bold('ccr') + ' ' + dim('session ended') + '\n');
|
|
62
83
|
let raw = '';
|
|
63
|
-
try { raw = fs.readFileSync(
|
|
64
|
-
if (!raw.trim())
|
|
84
|
+
try { raw = fs.readFileSync(snapshot, 'utf8'); } catch { /* none yet */ }
|
|
85
|
+
if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
|
|
65
86
|
let state;
|
|
66
|
-
try { state = JSON.parse(raw); } catch {
|
|
87
|
+
try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
|
|
67
88
|
let out;
|
|
68
89
|
try {
|
|
69
|
-
out = renderEconomy(normalizeStatus(state), { tick: Math.floor(
|
|
90
|
+
out = renderEconomy(normalizeStatus(state), { tick: Math.floor(now / 1000) % 2 === 0 });
|
|
70
91
|
} catch (e) {
|
|
71
92
|
out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
|
|
72
93
|
}
|
|
73
94
|
// Live tool/skills feed below the panel — best-effort; never break the panel.
|
|
95
|
+
// Its inner width tracks the pane so args truncate cleanly (the clamp below is
|
|
96
|
+
// the hard safety net regardless).
|
|
74
97
|
try {
|
|
75
98
|
const tpath = currentTranscriptPath(state);
|
|
76
99
|
if (tpath) {
|
|
77
|
-
const
|
|
100
|
+
const feedWidth = typeof cols === 'number' && cols > 0 ? Math.max(20, Math.min(48, cols - 2)) : 48;
|
|
101
|
+
const feedStr = renderFeed(updateFeed(tpath), { max: 6, width: feedWidth });
|
|
78
102
|
if (feedStr) out += '\n\n' + feedStr;
|
|
79
103
|
}
|
|
80
104
|
} catch { /* feed is optional */ }
|
|
81
|
-
|
|
105
|
+
return clamp(out.endsWith('\n') ? out : out + '\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function frame() {
|
|
109
|
+
// Read columns each tick so a live resize re-flows on the next frame.
|
|
110
|
+
draw(composeFrame(STATE_DIR, { now: Date.now(), cols: process.stdout.columns }));
|
|
82
111
|
}
|
|
83
112
|
|
|
84
113
|
function run() {
|
|
@@ -89,6 +118,7 @@ function run() {
|
|
|
89
118
|
process.on('SIGTERM', stop);
|
|
90
119
|
}
|
|
91
120
|
|
|
92
|
-
// `updateFeed`
|
|
93
|
-
// reset
|
|
94
|
-
|
|
121
|
+
// `updateFeed` + `composeFrame` are exported for tests (the incremental tail +
|
|
122
|
+
// session-switch reset and the ended/waiting/render states are the subtle
|
|
123
|
+
// parts); the live loop uses `run`.
|
|
124
|
+
module.exports = { run, updateFeed, composeFrame };
|