claude-code-runrate 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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/​waiting state. Pure Node, zero dependencies.
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,38 +56,152 @@ 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
- function frame() {
61
- if (fs.existsSync(EXITED)) { draw(bold('ccr') + ' ' + dim('session ended') + '\n'); return; }
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(SNAPSHOT, 'utf8'); } catch { /* none yet */ }
64
- if (!raw.trim()) { draw(dim('ccr · waiting for the first status tick…') + '\n'); return; }
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 { draw(dim('ccr · status unreadable') + '\n'); return; }
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(Date.now() / 1000) % 2 === 0 });
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 feedStr = renderFeed(updateFeed(tpath), { max: 6 });
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
- draw(out.endsWith('\n') ? out : out + '\n');
105
+ return clamp(out.endsWith('\n') ? out : out + '\n');
106
+ }
107
+
108
+ /**
109
+ * Resolve the column budget to clamp the panel to. `process.stdout.columns` is
110
+ * authoritative when present (live resize re-flows on the next frame), but inside
111
+ * the Windows launcher's `cmd /c` conpty pane it is unreliable — often undefined
112
+ * or the FULL window width rather than the narrow split. So the launcher injects
113
+ * the computed pane width as CCR_SIDECAR_COLS; we take the SMALLER of the two,
114
+ * which is safe whichever is wrong: a bogus full-width `columns` can't defeat the
115
+ * hint, and a missing hint (Linux/tmux, standalone `ccr sidecar`) leaves the live
116
+ * value untouched. Returns undefined only when neither is known (no clamp).
117
+ *
118
+ * @returns {number|undefined}
119
+ */
120
+ function resolveCols() {
121
+ const live = process.stdout.columns;
122
+ const haveLive = typeof live === 'number' && live > 0;
123
+ const hint = parseInt(process.env.CCR_SIDECAR_COLS || '', 10);
124
+ const haveHint = Number.isFinite(hint) && hint > 0;
125
+ if (haveLive && haveHint) return Math.min(live, hint);
126
+ if (haveHint) return hint;
127
+ return haveLive ? live : undefined;
128
+ }
129
+
130
+ function frame() {
131
+ // Read columns each tick so a live resize re-flows on the next frame.
132
+ draw(composeFrame(STATE_DIR, { now: Date.now(), cols: resolveCols() }));
82
133
  }
83
134
 
84
- function run() {
85
- frame();
86
- const id = setInterval(frame, 1000);
87
- const stop = () => { clearInterval(id); process.exit(0); };
88
- process.on('SIGINT', stop);
89
- process.on('SIGTERM', stop);
135
+ /**
136
+ * The live loop. With `exitOnEnd` (the Windows launcher passes `--exit-on-end`),
137
+ * the sidecar closes its own pane as soon as the `exited` sentinel appears — so a
138
+ * `cmd /c` pane folds away on session end rather than lingering, matching the tmux
139
+ * launcher's kill-session sweep. Without it (Linux/tmux, standalone `ccr sidecar`)
140
+ * the loop runs until signalled, exactly as before.
141
+ *
142
+ * For the fastest, correctly-ordered close, the sentinel is POLLED faster than the
143
+ * render cadence when `exitOnEnd` (a redraw is ~1s; waiting a full second just to
144
+ * NOTICE the exit would dominate the close time). A single interval ticks at
145
+ * `pollMs`; the expensive redraw is throttled to ~1s, while the cheap sentinel
146
+ * check runs every poll. On exit we paint "session ended" once and close after a
147
+ * short `graceMs`. The launcher's pane 0 then lingers slightly longer (see
148
+ * buildWtArgs) so this RIGHT pane closes first and the border sweeps left→right.
149
+ * Side effects are injectable so the end-sweep is unit-testable.
150
+ *
151
+ * @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number,
152
+ * tick?: () => void, sentinelExists?: () => boolean,
153
+ * setIntervalFn?: Function, setTimeoutFn?: Function,
154
+ * clearIntervalFn?: Function, clearTimeoutFn?: Function,
155
+ * exit?: () => void, onSignal?: (sig: string, handler: () => void) => void }} [opts]
156
+ * @returns {() => void} the stop handler (exposed for tests)
157
+ */
158
+ function run(opts = {}) {
159
+ const stateDir = opts.stateDir || STATE_DIR;
160
+ const exitOnEnd = opts.exitOnEnd != null ? opts.exitOnEnd : (process.env.CCR_SIDECAR_EXIT_ON_END === '1');
161
+ // Tiny grace so the "session ended" frame paints before we close — kept short
162
+ // since this drives the close speed (the launcher tunes pane 0 to outlast it).
163
+ const graceMs = opts.graceMs != null ? opts.graceMs : 200;
164
+ const tick = opts.tick || frame;
165
+ const sentinelExists = opts.sentinelExists || (() => fs.existsSync(path.join(stateDir, 'exited')));
166
+ const setIntervalFn = opts.setIntervalFn || setInterval;
167
+ const setTimeoutFn = opts.setTimeoutFn || setTimeout;
168
+ const clearIntervalFn = opts.clearIntervalFn || clearInterval;
169
+ const clearTimeoutFn = opts.clearTimeoutFn || clearTimeout;
170
+ const exit = opts.exit || (() => process.exit(0));
171
+ const onSignal = opts.onSignal || ((sig, handler) => process.on(sig, handler));
172
+
173
+ // Poll the sentinel fast when we have to detect the end; keep the redraw at ~1s.
174
+ const RENDER_MS = 1000;
175
+ const pollMs = exitOnEnd ? 120 : RENDER_MS;
176
+
177
+ let id = null;
178
+ let endTimer = null;
179
+ let sinceRender = RENDER_MS; // render on the first loop
180
+ const stop = () => {
181
+ if (id != null) clearIntervalFn(id);
182
+ if (endTimer != null) clearTimeoutFn(endTimer);
183
+ exit();
184
+ };
185
+ const checkEnd = () => {
186
+ // Once the session has ended, paint it once then sweep this pane closed.
187
+ if (exitOnEnd && endTimer == null && sentinelExists()) {
188
+ tick();
189
+ endTimer = setTimeoutFn(stop, graceMs);
190
+ }
191
+ };
192
+ const loop = () => {
193
+ sinceRender += pollMs;
194
+ if (sinceRender >= RENDER_MS) { sinceRender = 0; tick(); }
195
+ checkEnd();
196
+ };
197
+ loop();
198
+ id = setIntervalFn(loop, pollMs);
199
+ onSignal('SIGINT', stop);
200
+ onSignal('SIGTERM', stop);
201
+ return stop;
90
202
  }
91
203
 
92
- // `updateFeed` is exported for tests (the incremental tail + session-switch
93
- // reset is the subtle part); the live loop uses `run`.
94
- module.exports = { run, updateFeed };
204
+ // `updateFeed` + `composeFrame` are exported for tests (the incremental tail +
205
+ // session-switch reset and the ended/waiting/render states are the subtle
206
+ // parts); the live loop uses `run`.
207
+ module.exports = { run, updateFeed, composeFrame };