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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +196 -25
- package/bin/cli.js +21 -1
- package/commands/panel.md +14 -0
- package/commands/statusline.md +15 -0
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +36 -0
- package/skills/usage-limits/references/how-it-works.md +115 -2
- package/skills/usage-limits/scripts/activity.js +198 -0
- package/skills/usage-limits/scripts/bars.js +340 -0
- package/skills/usage-limits/scripts/brief.js +59 -6
- package/skills/usage-limits/scripts/feed.js +400 -0
- package/skills/usage-limits/scripts/live.js +489 -0
- package/skills/usage-limits/scripts/panel.js +731 -0
- package/skills/usage-limits/scripts/pulse.js +24 -0
- package/skills/usage-limits/scripts/recommend.js +56 -5
- package/skills/usage-limits/scripts/sessionend.js +5 -1
- package/skills/usage-limits/scripts/statusline.js +305 -0
- package/skills/usage-limits/scripts/stop.js +5 -1
- package/skills/usage-limits/scripts/tally.js +4 -10
- package/skills/usage-limits/scripts/usage.js +477 -17
- package/skills/usage-limits/scripts/view.js +222 -0
|
@@ -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
|
+
}
|