claude-usage-limits 1.9.2 → 1.11.2

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.
@@ -0,0 +1,731 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The panel: the session, weekly and per-model limits as live bars in a pane
5
+ // beside the Claude Code chat.
6
+ //
7
+ // node scripts/panel.js the live panel, q to quit
8
+ // node scripts/panel.js --open open it in a split pane to the right
9
+ // node scripts/panel.js --once one frame, for a pipe or a screenshot
10
+ // node scripts/panel.js --json the same frame as fields
11
+ // node scripts/panel.js --no-fetch never use the network
12
+ //
13
+ // Where the numbers come from, in order of freshness: the rate-limit headers
14
+ // on Claude's own API responses (recorded by the status line), the usage
15
+ // endpoint Claude Code calls for /usage (polled here), and Claude Code's own
16
+ // cache. Whichever is newest wins, and the footer says how old it is.
17
+ //
18
+ // Where the animation comes from: the hooks. A prompt marks the session as
19
+ // working, every tool call keeps it so, the Stop hook marks it idle. While
20
+ // Claude works the title shimmers and the spinner turns, in Claude's colours;
21
+ // under ultracode it turns rainbow, which is what Claude Code does too.
22
+
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const readline = require('readline');
27
+ const { spawn } = require('child_process');
28
+
29
+ const usage = require('./usage.js');
30
+ const host = require('./host.js');
31
+ const codex = require('./codex.js');
32
+ const bars = require('./bars.js');
33
+ const view = require('./view.js');
34
+ const live = require('./live.js');
35
+ const feed = require('./feed.js');
36
+ const activity = require('./activity.js');
37
+ const brief = require('./brief.js');
38
+
39
+ const SECOND = 1000;
40
+ const MINUTE = 60 * SECOND;
41
+ // How often to take a reading: quickly while Claude works, slowly while it
42
+ // waits. The status line feeds per-response figures in between.
43
+ const POLL_WORKING_MS = 30 * SECOND;
44
+ const POLL_IDLE_MS = 2 * MINUTE;
45
+ const POLL_FLOOR_MS = 15 * SECOND;
46
+ const FILE_CHECK_MS = SECOND;
47
+ const FRAME_MS = 100;
48
+ const MIN_COLUMNS = 24;
49
+ const TITLE = 'Claude usage';
50
+
51
+ const HELP = `claude-usage-limits panel - live limits in a pane beside the chat
52
+
53
+ panel the live panel; q or Esc quits, r takes a fresh reading
54
+ panel --open open the panel in a split pane to the right of this one
55
+ panel --once print one frame and exit
56
+ panel --json print one frame as JSON and exit
57
+ panel --no-fetch never use the network: show the reading already on disk
58
+ panel --poll 45 seconds between readings (default 30 working, 120 idle)
59
+ panel --width 40 draw for this many columns instead of the terminal's
60
+ panel --ascii plain characters instead of block glyphs
61
+
62
+ Shows the current session (5-hour) window, the current week, and the week for
63
+ the model in use when the account caps that model on its own. Bars turn yellow
64
+ at 80 percent and red at 90. Readings come from the same call Claude Code
65
+ makes for /usage, plus the rate-limit headers on Claude's own responses when
66
+ the status line is installed. USAGE_LIMITS_FETCH=off is the same as --no-fetch.
67
+ `;
68
+
69
+ function configDir() {
70
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
71
+ }
72
+
73
+ function readJson(file) {
74
+ try {
75
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
76
+ } catch (err) {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function parseArgs(argv) {
82
+ const args = {
83
+ once: false,
84
+ json: false,
85
+ open: false,
86
+ fetch: true,
87
+ poll: null,
88
+ width: null,
89
+ help: false,
90
+ ascii: String(process.env.USAGE_LIMITS_ASCII || '') === '1',
91
+ hostName: null,
92
+ };
93
+ const list = argv || [];
94
+ for (let i = 0; i < list.length; i += 1) {
95
+ const arg = list[i];
96
+ if (arg === '--once') args.once = true;
97
+ else if (arg === '--json') args.json = true;
98
+ else if (arg === '--open') args.open = true;
99
+ else if (arg === '--no-fetch') args.fetch = false;
100
+ else if (arg === '--ascii') args.ascii = true;
101
+ else if (arg === '--help' || arg === '-h') args.help = true;
102
+ else if (arg === '--poll') args.poll = Number(list[++i]);
103
+ else if (arg.indexOf('--poll=') === 0) args.poll = Number(arg.slice('--poll='.length));
104
+ else if (arg === '--width') args.width = Number(list[++i]);
105
+ else if (arg.indexOf('--width=') === 0) args.width = Number(arg.slice('--width='.length));
106
+ else if (arg === '--host') args.hostName = list[++i] || null;
107
+ }
108
+ return args;
109
+ }
110
+
111
+ function settingsFor() {
112
+ return readJson(path.join(configDir(), 'settings.json')) || {};
113
+ }
114
+
115
+ function accountUuid() {
116
+ const account = readJson(usage.accountFile());
117
+ return account && account.oauthAccount && account.oauthAccount.accountUuid ? account.oauthAccount.accountUuid : null;
118
+ }
119
+
120
+ // One frame's worth of facts. Reads every file the plugin keeps, takes a
121
+ // fresh reading when asked, and hands the lot to the display model.
122
+ async function snapshot(options) {
123
+ const opts = options || {};
124
+ const env = opts.env || process.env;
125
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
126
+ let outcome = opts.outcome || null;
127
+ const onCodex = usage.isCodex();
128
+
129
+ let collected;
130
+ if (onCodex) {
131
+ // Codex keeps its meter in its session rollouts and answers a live reading
132
+ // through its own app-server, which is what /status shows. That is the
133
+ // system Codex uses, so it is the one read here.
134
+ let meter = null;
135
+ if (opts.fetch) {
136
+ try {
137
+ meter = await codex.refresh();
138
+ outcome = { ok: true };
139
+ } catch (err) {
140
+ outcome = { ok: false, kind: 'offline', message: (err && err.code) || 'codex did not answer' };
141
+ }
142
+ }
143
+ collected = codex.collect(now, meter ? { meter } : undefined);
144
+ } else {
145
+ if (opts.fetch) {
146
+ const result = await live.refresh({ now, accountUuid: accountUuid(), env, timeoutMs: opts.timeoutMs });
147
+ outcome = result.outcome;
148
+ }
149
+ collected = usage.collect(now);
150
+ }
151
+
152
+ const slots = onCodex ? {} : feed.readFeed();
153
+ const slot = feed.newest(slots);
154
+ const seen = onCodex ? { working: codexWorking(now), ultracode: false, model: null } : activity.summarise(activity.read(), now);
155
+ const settings = onCodex ? {} : settingsFor();
156
+
157
+ const built = view.build({
158
+ now,
159
+ utilization: collected.utilization,
160
+ fetchedAtMs: collected.snapshotFetchedAt,
161
+ source: onCodex ? (outcome && outcome.ok ? 'api' : 'cache') : collected.snapshotSource,
162
+ windowSpecs: collected.windowSpecs || null,
163
+ headers: slot ? slot.rateLimits : null,
164
+ headersAt: slot ? slot.headersAt : null,
165
+ model: (slot && slot.model) || seen.model || null,
166
+ modelName: slot ? slot.modelName : null,
167
+ effort: slot ? slot.effort : (onCodex && collected.settings && collected.settings.effortLevel !== 'default' ? collected.settings.effortLevel : null),
168
+ working: seen.working || feed.isWorking(slot, now),
169
+ ultracode: seen.ultracode || settings.ultracode === true,
170
+ settingsModel: collected.settings ? collected.settings.model : null,
171
+ outcome,
172
+ env,
173
+ });
174
+ built.now = now;
175
+ built.host = onCodex ? 'codex' : 'claude';
176
+ built.title = onCodex ? 'Codex usage' : TITLE;
177
+ built.plan = collected.plan || null;
178
+ if (onCodex && collected.windowless && !built.note) built.note = 'this plan meters no rolling window';
179
+ // Every Claude on this machine, and which of them are working. Two windows
180
+ // share one limit, so the other one's state is part of this one's picture.
181
+ // Codex leaves no marks, so under Codex the list is empty rather than wrong.
182
+ built.sessionsList = onCodex ? [] : loadSessions(now);
183
+ built.sessions = onCodex ? 0 : Math.max(built.sessionsList.length, brief.liveSessions(brief.readCache(), now, brief.LIVE_WINDOW_MS, null));
184
+ built.othersWorking = built.sessionsList.filter((row) => row.state === 'working').length;
185
+ // Whether the panel is allowed the network at all, which is what the footer
186
+ // reports. A frame rebuilt from disk between readings is not "network off".
187
+ built.fetch = opts.network !== undefined ? Boolean(opts.network) : Boolean(opts.fetch);
188
+ built.outcome = outcome;
189
+ return built;
190
+ }
191
+
192
+ // Codex has no hooks to say when it is working, but it appends to its rollout
193
+ // file as it goes, so a rollout touched in the last few seconds is a turn in
194
+ // progress.
195
+ function codexWorking(now) {
196
+ try {
197
+ const recent = codex.rolloutFiles(now - 5 * SECOND);
198
+ return recent.some((entry) => !Number.isFinite(entry.at) || entry.at >= now - 5 * SECOND);
199
+ } catch (err) {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ // The sessions this machine has heard from lately, from every file the hooks
205
+ // and the status line keep. The tally is required lazily: it requires usage.js
206
+ // back, and this module is loaded by the VS Code extension too.
207
+ function loadSessions(now) {
208
+ let tallyList = [];
209
+ try {
210
+ const tally = require('./tally.js');
211
+ tallyList = tally.sessions(tally.readState());
212
+ } catch (err) {
213
+ tallyList = [];
214
+ }
215
+ return activity.combine(
216
+ { marks: activity.read(), feed: feed.readFeed(), tally: tallyList, brief: brief.readCache() },
217
+ now,
218
+ brief.LIVE_WINDOW_MS
219
+ );
220
+ }
221
+
222
+ // Where a session is working, as short as it can be said.
223
+ function whereLabel(row) {
224
+ if (row.cwd) return path.basename(String(row.cwd)) || String(row.cwd);
225
+ if (row.project) return usage.shortenProject(row.project, 18);
226
+ return '';
227
+ }
228
+
229
+ // Cut a painted line to a width without leaving an escape open.
230
+ function fit(text, width) {
231
+ if (bars.visibleWidth(text) <= width) return text;
232
+ let out = '';
233
+ let shown = 0;
234
+ let i = 0;
235
+ const s = String(text);
236
+ while (i < s.length) {
237
+ if (s[i] === '\x1b') {
238
+ const match = s.slice(i).match(/^\x1b\[[0-9;?]*[ -/]*[@-~]/);
239
+ if (match) {
240
+ out += match[0];
241
+ i += match[0].length;
242
+ continue;
243
+ }
244
+ }
245
+ if (shown >= width) break;
246
+ const point = s.codePointAt(i);
247
+ const ch = String.fromCodePoint(point);
248
+ out += ch;
249
+ shown += 1;
250
+ i += ch.length;
251
+ }
252
+ return out + '\x1b[0m';
253
+ }
254
+
255
+ function subline(row, mode, opts) {
256
+ if (row.stale) return bars.dim('window rolled over, taking a fresh reading', mode);
257
+ if (row.unreported) return bars.dim('not reported yet, run /usage in Claude Code', mode);
258
+ if (row.idle) return bars.dim('nothing in this window yet', mode);
259
+ if (row.percent === null) return bars.dim('no reading yet', mode);
260
+ const reset = bars.formatReset(row.msToReset, row.resetsAtMs, opts.now, { clock: opts.clock });
261
+ return reset ? bars.dim(reset, mode) : '';
262
+ }
263
+
264
+ // Seconds while it is seconds; the report's formatter rounds to minutes, which
265
+ // reads as "0m ago" for a reading taken just now.
266
+ function since(ms) {
267
+ if (!Number.isFinite(ms)) return '';
268
+ if (ms < 60 * SECOND) return Math.max(0, Math.round(ms / SECOND)) + 's';
269
+ return usage.formatDuration(ms);
270
+ }
271
+
272
+ function stateLine(built, mode) {
273
+ const bits = [];
274
+ if (built.state === 'none') bits.push('no reading yet');
275
+ else if (built.state === 'live') bits.push('live' + (Number.isFinite(built.ageMs) ? ', updated ' + since(built.ageMs) + ' ago' : ''));
276
+ else bits.push('cached' + (Number.isFinite(built.ageMs) ? ', reading from ' + since(built.ageMs) + ' ago' : ''));
277
+ if (built.fetch === false) bits.push('network off');
278
+ return bars.dim(bits.join(' · '), mode);
279
+ }
280
+
281
+ function noteColour(built) {
282
+ const kind = built.outcome && !built.outcome.ok ? built.outcome.kind : null;
283
+ if (kind === 'unauthorized' || kind === 'forbidden' || kind === 'no_credentials') return bars.THEME.error;
284
+ if (kind === 'offline' || kind === 'expired' || kind === 'rate_limited' || kind === 'server' || kind === 'http' || kind === 'bad_response') {
285
+ return bars.THEME.warning;
286
+ }
287
+ return null;
288
+ }
289
+
290
+ // The frame, as lines. Fits any width from MIN_COLUMNS up and any height,
291
+ // dropping breathing room first and footers second.
292
+ function render(built, options) {
293
+ const opts = options || {};
294
+ // Laid out for at least MIN_COLUMNS, but cut to the width that really
295
+ // exists: a line wider than the pane wraps, and a wrapped frame scrolls.
296
+ const real = Number.isFinite(opts.columns) ? Math.max(1, Math.floor(opts.columns)) : 40;
297
+ const columns = Math.max(MIN_COLUMNS, real);
298
+ const height = Number.isFinite(opts.rows) ? Math.floor(opts.rows) : null;
299
+ const mode = opts.mode || 'none';
300
+ const tick = Number.isFinite(opts.tick) ? opts.tick : 0;
301
+ const reduced = Boolean(opts.reduced);
302
+ const ascii = Boolean(opts.ascii);
303
+ const now = Number.isFinite(opts.now) ? opts.now : built.now || Date.now();
304
+ const clock = opts.clock || '12h';
305
+ const barWidth = Math.max(8, Math.min(50, columns - 6));
306
+ const animate = built.working && !reduced;
307
+
308
+ const glyph = built.working
309
+ ? built.ultracode
310
+ ? bars.rainbow(bars.spinner(tick, { ascii, reduced }), tick, { mode, reduced })
311
+ : bars.paint(bars.spinner(tick, { ascii, reduced }), bars.THEME.claude, mode)
312
+ : bars.paint(ascii ? '*' : '✻', bars.THEME.claude, mode);
313
+ const titleText = built.title || TITLE;
314
+ const title = built.ultracode
315
+ ? bars.rainbow(titleText, tick, { mode, reduced })
316
+ : animate
317
+ ? bars.shimmer(titleText, tick, bars.THEME.claude, bars.THEME.claudeShimmer, { mode, reduced })
318
+ : bars.paint(titleText, bars.THEME.claude, mode);
319
+
320
+ const effortName = built.ultracode ? 'ultracode' : built.effort;
321
+ const effort = effortName ? bars.effortColour(effortName) : null;
322
+ const effortText = !effortName
323
+ ? ''
324
+ : effort.rainbow
325
+ ? bars.rainbow(effortName, tick, { mode, reduced })
326
+ : effort.shimmer && animate
327
+ ? bars.shimmer(effortName, tick, effort.rgb, effort.shimmer, { mode, reduced })
328
+ : bars.paint(effortName, effort.rgb, mode);
329
+ const status = built.working ? 'working' : 'idle';
330
+ const who = [built.modelLabel, effortText, bars.dim(status, mode)].filter(Boolean).join(bars.dim(' · ', mode));
331
+
332
+ const head = [glyph + ' ' + bars.bold(title, mode), who];
333
+ const body = [];
334
+ for (const row of built.rows) {
335
+ const percent =
336
+ row.level === 'fill' ? row.percentText : bars.paint(row.percentText, bars.levelColour(row.level), mode);
337
+ body.push({
338
+ lines: [
339
+ bars.bold(row.title, mode),
340
+ (row.percent === null ? bars.paint((ascii ? '-' : '░').repeat(barWidth), bars.THEME.empty, mode) : bars.bar(row.percent, barWidth, { mode, level: row.level, ascii })) +
341
+ ' ' +
342
+ percent,
343
+ subline(row, mode, { now, clock }),
344
+ ].filter((line) => line !== ''),
345
+ });
346
+ }
347
+
348
+ // The other Claudes. One row each: what it runs, where, and whether it is
349
+ // working right now, with its own spinner when it is.
350
+ const list = Array.isArray(built.sessionsList) ? built.sessionsList : [];
351
+ if (list.length) {
352
+ const working = list.filter((row) => row.state === 'working').length;
353
+ const idle = list.length - working;
354
+ const summary = [working ? working + ' working' : null, idle ? idle + ' idle' : null].filter(Boolean).join(', ');
355
+ const lines = [bars.bold('Sessions', mode) + bars.dim(' · ' + summary, mode)];
356
+ const shown = list.slice(0, 5);
357
+ for (const row of shown) {
358
+ const busy = row.state === 'working';
359
+ const glyph = busy
360
+ ? row.ultracode
361
+ ? bars.rainbow(bars.spinner(tick, { ascii, reduced }), tick, { mode, reduced })
362
+ : bars.paint(bars.spinner(tick, { ascii, reduced }), bars.THEME.claude, mode)
363
+ : bars.dim(ascii ? '.' : '·', mode);
364
+ // A session the status line has not described is still a Claude.
365
+ const name = row.modelName || (row.model ? bars.prettyModel(row.model) : 'Claude');
366
+ const where = whereLabel(row);
367
+ const state = busy
368
+ ? bars.paint('working', bars.THEME.claude, mode)
369
+ : bars.dim('idle ' + since(now - row.lastAt) + ' ago', mode);
370
+ const parts = [glyph + ' ' + name];
371
+ if (where && columns >= 36) parts.push(bars.dim(where, mode));
372
+ parts.push(state);
373
+ lines.push(parts.join(' '));
374
+ }
375
+ if (list.length > shown.length) lines.push(bars.dim('+' + (list.length - shown.length) + ' more', mode));
376
+ body.push({ lines });
377
+ }
378
+
379
+ const footer = [];
380
+ if (!list.length && built.sessions > 1) footer.push(bars.dim(built.sessions + ' sessions sharing this budget', mode));
381
+ if (built.note) {
382
+ const colour = noteColour(built);
383
+ footer.push(colour ? bars.paint(built.note, colour, mode) : bars.dim(built.note, mode));
384
+ }
385
+ footer.push(stateLine(built, mode));
386
+ if (opts.interactive !== false) footer.push(bars.dim('q quit · r refresh', mode));
387
+
388
+ // Full layout first, then without spacers, then without the footer.
389
+ const compose = (spacers, withFooter) => {
390
+ const lines = head.slice();
391
+ for (const section of body) {
392
+ if (spacers) lines.push('');
393
+ for (const line of section.lines) lines.push(line);
394
+ }
395
+ if (withFooter) {
396
+ if (spacers) lines.push('');
397
+ for (const line of footer) lines.push(line);
398
+ }
399
+ return lines;
400
+ };
401
+ let lines = compose(true, true);
402
+ if (height !== null && lines.length > height) lines = compose(false, true);
403
+ if (height !== null && lines.length > height) lines = compose(false, false);
404
+ if (height !== null && lines.length > height) lines = lines.slice(0, height);
405
+ return lines.map((line) => fit(line, real));
406
+ }
407
+
408
+ function quote(value) {
409
+ return '"' + String(value).replace(/"/g, '\\"') + '"';
410
+ }
411
+
412
+ // How to put the panel in a pane to the right of the current one, for the
413
+ // terminals that can be told to. Pure, so the table can be tested.
414
+ function openCommand(env, panelPath, nodePath, platform, extraArgs, options) {
415
+ const e = env || process.env;
416
+ const os = platform || process.platform;
417
+ const node = nodePath || process.execPath;
418
+ const panel = panelPath || __filename;
419
+ const extra = Array.isArray(extraArgs) ? extraArgs.map(String) : [];
420
+ const cmd = [quote(node), quote(panel)].concat(extra.map(quote)).join(' ');
421
+
422
+ if (e.TMUX) {
423
+ // A percentage on -l arrived in tmux 3.1; older ones want the old -p.
424
+ const version = options && options.tmuxVersion ? String(options.tmuxVersion).match(/(\d+)\.(\d+)/) : null;
425
+ const old = version && (Number(version[1]) < 3 || (Number(version[1]) === 3 && Number(version[2]) < 1));
426
+ return {
427
+ program: 'tmux',
428
+ args: ['split-window', '-h', '-d'].concat(old ? ['-p', '32'] : ['-l', '32%'], [cmd]),
429
+ note: 'opened a pane to the right in tmux',
430
+ };
431
+ }
432
+ if (e.WEZTERM_PANE) {
433
+ return {
434
+ program: 'wezterm',
435
+ args: ['cli', 'split-pane', '--right', '--percent', '32', '--', node, panel].concat(extra),
436
+ note: 'opened a pane to the right in WezTerm',
437
+ };
438
+ }
439
+ if (e.KITTY_WINDOW_ID) {
440
+ return {
441
+ program: 'kitten',
442
+ args: ['@', 'launch', '--location=vsplit', '--bias=32', '--cwd=current', node, panel].concat(extra),
443
+ note: 'opened a pane to the right in kitty (needs allow_remote_control)',
444
+ };
445
+ }
446
+ if (e.ZELLIJ) {
447
+ return {
448
+ program: 'zellij',
449
+ args: ['action', 'new-pane', '-d', 'right', '--', node, panel].concat(extra),
450
+ note: 'opened a pane to the right in zellij',
451
+ };
452
+ }
453
+ if (os === 'win32') {
454
+ if (e.WT_SESSION) {
455
+ return {
456
+ command:
457
+ 'start "" wt.exe -w 0 sp -V --size 0.32 --title "Claude usage" --suppressApplicationTitle ' + cmd,
458
+ shell: true,
459
+ note: 'opened a pane to the right in Windows Terminal',
460
+ };
461
+ }
462
+ return {
463
+ command: 'start "Claude usage" ' + cmd,
464
+ shell: true,
465
+ note: 'opened the panel in a new terminal window (run this from inside Windows Terminal to get a split pane)',
466
+ };
467
+ }
468
+ if (String(e.TERM_PROGRAM || '') === 'iTerm.app') {
469
+ const script =
470
+ 'tell application "iTerm2" to tell current session of current window to split vertically with default profile command ' +
471
+ quote(cmd);
472
+ return { program: 'osascript', args: ['-e', script], note: 'opened a pane to the right in iTerm2' };
473
+ }
474
+ return null;
475
+ }
476
+
477
+ function tmuxVersion() {
478
+ try {
479
+ const result = require('child_process').spawnSync('tmux', ['-V'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
480
+ return result && result.stdout ? String(result.stdout).trim() : null;
481
+ } catch (err) {
482
+ return null;
483
+ }
484
+ }
485
+
486
+ function openPanel(env, extraArgs) {
487
+ const e = env || process.env;
488
+ const plan = openCommand(e, __filename, process.execPath, process.platform, extraArgs, {
489
+ tmuxVersion: e.TMUX ? tmuxVersion() : null,
490
+ });
491
+ if (!plan) {
492
+ process.stdout.write(
493
+ 'This terminal cannot be told to split. Open a second pane to the right and run:\n ' +
494
+ quote(process.execPath) +
495
+ ' ' +
496
+ quote(__filename) +
497
+ '\n'
498
+ );
499
+ return 0;
500
+ }
501
+ return new Promise((resolve) => {
502
+ let child;
503
+ try {
504
+ child = spawn(plan.command || plan.program, plan.args || [], {
505
+ shell: Boolean(plan.shell),
506
+ detached: true,
507
+ stdio: 'ignore',
508
+ windowsHide: false,
509
+ });
510
+ } catch (err) {
511
+ process.stderr.write('panel: could not start ' + (plan.program || 'the terminal') + ': ' + err.message + '\n');
512
+ return resolve(1);
513
+ }
514
+ child.on('error', (err) => {
515
+ process.stderr.write('panel: could not start ' + (plan.program || 'the terminal') + ': ' + err.message + '\n');
516
+ resolve(1);
517
+ });
518
+ // Give a launcher that fails fast a moment to say so; otherwise get out of
519
+ // its way.
520
+ setTimeout(() => {
521
+ child.unref();
522
+ process.stdout.write(plan.note + '\n');
523
+ resolve(0);
524
+ }, 300);
525
+ });
526
+ }
527
+
528
+ function pollBase(built, args, env) {
529
+ const configured = Number.isFinite(args.poll) && args.poll > 0 ? args.poll * SECOND : Number((env || process.env).USAGE_LIMITS_POLL) * SECOND;
530
+ if (Number.isFinite(configured) && configured > 0) return Math.max(POLL_FLOOR_MS, configured);
531
+ return built && built.working ? POLL_WORKING_MS : POLL_IDLE_MS;
532
+ }
533
+
534
+ async function interactive(args) {
535
+ const out = process.stdout;
536
+ const env = process.env;
537
+ const settings = settingsFor();
538
+ const reduced = feed.motionOff(settings, env);
539
+ const clock = feed.clockFor(settings, env);
540
+ const mode = bars.colourMode(env, out.isTTY);
541
+ const fetch = args.fetch && !live.fetchDisabled(env);
542
+
543
+ const state = {
544
+ built: null,
545
+ outcome: null,
546
+ lastFetchAt: 0,
547
+ delayMs: 0,
548
+ fetching: false,
549
+ lastFrame: '',
550
+ lastTick: -1,
551
+ lastCheck: 0,
552
+ dirty: true,
553
+ stopped: false,
554
+ };
555
+
556
+ const leave = () => {
557
+ if (state.stopped) return;
558
+ state.stopped = true;
559
+ try {
560
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
561
+ } catch (err) {
562
+ // Not a TTY any more; nothing to restore.
563
+ }
564
+ out.write('\x1b[0m\x1b[?25h\x1b[?1049l');
565
+ };
566
+ out.write('\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J');
567
+ process.on('exit', leave);
568
+ process.on('SIGINT', () => {
569
+ leave();
570
+ process.exit(0);
571
+ });
572
+ process.on('SIGTERM', () => {
573
+ leave();
574
+ process.exit(0);
575
+ });
576
+ out.on('resize', () => {
577
+ state.dirty = true;
578
+ });
579
+
580
+ if (process.stdin.isTTY) {
581
+ readline.emitKeypressEvents(process.stdin);
582
+ process.stdin.setRawMode(true);
583
+ process.stdin.resume();
584
+ process.stdin.on('keypress', (ch, key) => {
585
+ const name = key && key.name;
586
+ if (name === 'q' || name === 'escape' || (key && key.ctrl && name === 'c')) {
587
+ leave();
588
+ process.exit(0);
589
+ }
590
+ if (name === 'r') {
591
+ state.lastFetchAt = 0;
592
+ state.delayMs = 0;
593
+ state.dirty = true;
594
+ }
595
+ });
596
+ }
597
+
598
+ const draw = (now) => {
599
+ const width = Number.isFinite(args.width) && args.width > 0 ? args.width : out.columns || 40;
600
+ const lines = render(state.built, {
601
+ columns: width,
602
+ rows: out.rows || null,
603
+ tick: Math.floor(now / bars.TICK_MS),
604
+ mode,
605
+ reduced,
606
+ ascii: args.ascii,
607
+ now,
608
+ clock,
609
+ });
610
+ const frame = lines.map((line) => '\x1b[2K' + line).join('\n') + '\x1b[J';
611
+ if (frame === state.lastFrame && !state.dirty) return;
612
+ state.lastFrame = frame;
613
+ state.dirty = false;
614
+ out.write('\x1b[H' + frame);
615
+ };
616
+
617
+ const step = async () => {
618
+ if (state.stopped) return;
619
+ const now = Date.now();
620
+ const due = fetch && !state.fetching && now - state.lastFetchAt >= state.delayMs;
621
+ if (due) {
622
+ // The reading runs beside the frames, never in front of them: a slow
623
+ // network must not freeze the spinner or the countdown.
624
+ state.fetching = true;
625
+ snapshot({ fetch: true, network: fetch, env, now })
626
+ .then((built) => {
627
+ state.built = built;
628
+ state.outcome = built.outcome;
629
+ })
630
+ .catch((err) => {
631
+ state.outcome = { ok: false, kind: 'bad_response', message: err && err.message ? err.message : String(err) };
632
+ })
633
+ .then(() => {
634
+ state.lastFetchAt = Date.now();
635
+ state.delayMs = live.nextDelayMs(state.outcome, state.delayMs, {
636
+ baseMs: pollBase(state.built, args, env),
637
+ maxMs: POLL_IDLE_MS,
638
+ });
639
+ state.fetching = false;
640
+ state.dirty = true;
641
+ });
642
+ }
643
+ if (!state.built || now - state.lastCheck >= FILE_CHECK_MS) {
644
+ state.lastCheck = now;
645
+ try {
646
+ state.built = await snapshot({ fetch: false, network: fetch, env, now, outcome: state.outcome });
647
+ } catch (err) {
648
+ // Keep the last frame; a transient read error is not worth a blank.
649
+ }
650
+ // A window that rolled over deserves a reading sooner than the timer.
651
+ if (fetch && state.built && state.built.rows.some((row) => row.stale) && state.delayMs > 5 * SECOND) {
652
+ state.delayMs = 5 * SECOND;
653
+ }
654
+ }
655
+ if (state.built) {
656
+ const tick = Math.floor(Date.now() / bars.TICK_MS);
657
+ const animating = (state.built.working || state.built.ultracode) && !reduced;
658
+ if (state.dirty || (animating && tick !== state.lastTick) || now - state.lastCheck < FRAME_MS) {
659
+ state.lastTick = tick;
660
+ draw(Date.now());
661
+ }
662
+ }
663
+ if (!state.stopped) setTimeout(step, FRAME_MS);
664
+ };
665
+ await step();
666
+ return new Promise(() => {});
667
+ }
668
+
669
+ async function main(argv) {
670
+ const args = parseArgs(argv);
671
+ if (args.help) {
672
+ process.stdout.write(HELP);
673
+ return 0;
674
+ }
675
+ usage.setHost(host.detect(argv || [], process.env));
676
+ if (args.open) return openPanel(process.env, args.hostName ? ['--host', args.hostName] : []);
677
+
678
+ const once = args.once || args.json || !process.stdout.isTTY;
679
+ if (once) {
680
+ const network = args.fetch && !live.fetchDisabled(process.env);
681
+ const built = await snapshot({ fetch: network, network, env: process.env });
682
+ if (args.json) {
683
+ process.stdout.write(JSON.stringify(built, null, 2) + '\n');
684
+ return 0;
685
+ }
686
+ const settings = settingsFor();
687
+ const lines = render(built, {
688
+ columns: Number.isFinite(args.width) && args.width > 0 ? args.width : process.stdout.columns || 40,
689
+ tick: 0,
690
+ mode: bars.colourMode(process.env, process.stdout.isTTY),
691
+ reduced: true,
692
+ ascii: args.ascii,
693
+ clock: feed.clockFor(settings, process.env),
694
+ interactive: false,
695
+ });
696
+ process.stdout.write(lines.join('\n') + '\n');
697
+ return 0;
698
+ }
699
+ return interactive(args);
700
+ }
701
+
702
+ module.exports = {
703
+ HELP,
704
+ TITLE,
705
+ MIN_COLUMNS,
706
+ POLL_WORKING_MS,
707
+ POLL_IDLE_MS,
708
+ parseArgs,
709
+ settingsFor,
710
+ snapshot,
711
+ loadSessions,
712
+ whereLabel,
713
+ fit,
714
+ since,
715
+ render,
716
+ openCommand,
717
+ pollBase,
718
+ main,
719
+ };
720
+
721
+ if (require.main === module) {
722
+ main(process.argv.slice(2)).then(
723
+ (code) => {
724
+ if (Number.isFinite(code)) process.exitCode = code;
725
+ },
726
+ (err) => {
727
+ process.stderr.write('panel: ' + (err && err.message ? err.message : String(err)) + '\n');
728
+ process.exitCode = 1;
729
+ }
730
+ );
731
+ }