claude-rpc 0.20.3 → 0.20.4
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/package.json +1 -1
- package/src/tui.js +387 -275
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/tui.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
// Interactive terminal dashboard
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Interactive terminal dashboard — full-screen framed panels, keyboard tabs,
|
|
2
|
+
// live refresh. Uses the alternate screen buffer (restored on quit) and box
|
|
3
|
+
// drawing, so it targets modern terminals (Windows Terminal, iTerm, kitty,
|
|
4
|
+
// etc.). NO_COLOR is honored: color drops out, the box structure stays.
|
|
5
|
+
//
|
|
6
|
+
// renderFrame() is pure (data in → string out) so the layout is testable and
|
|
7
|
+
// previewable without a live TTY; startTui() owns the IO/loop.
|
|
6
8
|
|
|
7
9
|
import process from 'node:process';
|
|
8
10
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
11
|
import { readActiveState } from './state.js';
|
|
10
|
-
import { readAggregate, findLiveSessions,
|
|
11
|
-
import {
|
|
12
|
-
import { buildVars, applyIdle, humanProject } from './format.js';
|
|
12
|
+
import { readAggregate, findLiveSessions, dayKey } from './scanner.js';
|
|
13
|
+
import { buildVars, applyIdle, humanProject, fmtNum } from './format.js';
|
|
13
14
|
import { loadConfig } from './config.js';
|
|
14
15
|
import { PID_PATH } from './paths.js';
|
|
15
16
|
import { fmtCost } from './pricing.js';
|
|
@@ -17,25 +18,32 @@ import { heat } from './ui.js';
|
|
|
17
18
|
|
|
18
19
|
// ── ANSI ────────────────────────────────────────────────────────────────────
|
|
19
20
|
const ESC = '\x1b[';
|
|
20
|
-
const RESET = ESC + '0m';
|
|
21
21
|
const CLEAR = ESC + '2J' + ESC + 'H';
|
|
22
22
|
const HIDE_CURSOR = ESC + '?25l';
|
|
23
23
|
const SHOW_CURSOR = ESC + '?25h';
|
|
24
|
+
const ALT_ON = ESC + '?1049h'; // alternate screen buffer — restored on quit
|
|
25
|
+
const ALT_OFF = ESC + '?1049l';
|
|
24
26
|
|
|
27
|
+
// Color drops out entirely under NO_COLOR; box-drawing (not color) stays.
|
|
28
|
+
const COLOR = !process.env.NO_COLOR;
|
|
29
|
+
const sgr = (code) => (COLOR ? ESC + code : '');
|
|
25
30
|
const C = {
|
|
26
|
-
reset:
|
|
27
|
-
dim:
|
|
28
|
-
bold:
|
|
29
|
-
red:
|
|
30
|
-
green:
|
|
31
|
-
yellow:
|
|
32
|
-
magenta:
|
|
33
|
-
cyan:
|
|
34
|
-
gray:
|
|
31
|
+
reset: sgr('0m'),
|
|
32
|
+
dim: sgr('2m'),
|
|
33
|
+
bold: sgr('1m'),
|
|
34
|
+
red: sgr('31m'),
|
|
35
|
+
green: sgr('32m'),
|
|
36
|
+
yellow: sgr('33m'),
|
|
37
|
+
magenta: sgr('35m'),
|
|
38
|
+
cyan: sgr('36m'),
|
|
39
|
+
gray: sgr('90m'),
|
|
35
40
|
};
|
|
36
41
|
const ansiRe = /\x1b\[[0-9;]*m/g;
|
|
37
42
|
const visLen = (s) => String(s).replace(ansiRe, '').length;
|
|
38
43
|
|
|
44
|
+
// Box-drawing — rounded corners for the "serious" look.
|
|
45
|
+
const BX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', sl: '├', sr: '┤' };
|
|
46
|
+
|
|
39
47
|
// ── Tabs ────────────────────────────────────────────────────────────────────
|
|
40
48
|
const TABS = [
|
|
41
49
|
{ key: 'now', label: 'Now' },
|
|
@@ -50,15 +58,124 @@ let currentTab = 0;
|
|
|
50
58
|
let refreshTimer = null;
|
|
51
59
|
let exiting = false;
|
|
52
60
|
|
|
53
|
-
// ──
|
|
54
|
-
|
|
61
|
+
// ── Width-aware string helpers ────────────────────────────────────────────────
|
|
62
|
+
// Truncate/pad a (possibly ANSI-coloured) string to exactly `w` visible columns.
|
|
63
|
+
// Padding resets colour first so trailing spaces never inherit a fill; truncation
|
|
64
|
+
// appends an ellipsis and a reset so colour can't bleed past the cut.
|
|
65
|
+
function fit(s, w) {
|
|
66
|
+
s = String(s);
|
|
67
|
+
const len = visLen(s);
|
|
68
|
+
if (len === w) return s;
|
|
69
|
+
if (len < w) return s + C.reset + ' '.repeat(w - len);
|
|
70
|
+
let out = '', vis = 0, i = 0;
|
|
71
|
+
while (i < s.length && vis < w - 1) {
|
|
72
|
+
if (s[i] === '\x1b') {
|
|
73
|
+
const m = s.indexOf('m', i);
|
|
74
|
+
if (m !== -1) { out += s.slice(i, m + 1); i = m + 1; continue; }
|
|
75
|
+
}
|
|
76
|
+
out += s[i]; vis++; i++;
|
|
77
|
+
}
|
|
78
|
+
return out + '…' + C.reset;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Heat-graded fill bar. `ratio` in 0..1 (or value/max). NO_COLOR-safe via heat().
|
|
82
|
+
function bar(ratio, w) {
|
|
83
|
+
const r = Math.max(0, Math.min(1, ratio || 0));
|
|
84
|
+
const filled = Math.max(0, Math.min(w, Math.round(r * w)));
|
|
85
|
+
return `${heat(r)}${'█'.repeat(filled)}${C.reset}${' '.repeat(w - filled)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Centre a (possibly ANSI) string within width `w` (left pad only; row() right-pads).
|
|
89
|
+
function center(s, w) {
|
|
90
|
+
const len = visLen(s);
|
|
91
|
+
if (len >= w) return s;
|
|
92
|
+
return ' '.repeat(Math.floor((w - len) / 2)) + s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Panels & columns ──────────────────────────────────────────────────────────
|
|
96
|
+
// A bordered panel of exact total width `w`, title embedded in the top border.
|
|
97
|
+
// Returns an array of lines (height = body.length + 2).
|
|
98
|
+
function panel(title, body, w) {
|
|
99
|
+
const out = [];
|
|
100
|
+
if (title) {
|
|
101
|
+
const fill = Math.max(0, w - 5 - visLen(title));
|
|
102
|
+
out.push(`${C.gray}${BX.tl}${BX.h} ${C.bold}${title}${C.reset}${C.gray} ${BX.h.repeat(fill)}${BX.tr}${C.reset}`);
|
|
103
|
+
} else {
|
|
104
|
+
out.push(`${C.gray}${BX.tl}${BX.h.repeat(w - 2)}${BX.tr}${C.reset}`);
|
|
105
|
+
}
|
|
106
|
+
for (const line of body) {
|
|
107
|
+
out.push(`${C.gray}${BX.v}${C.reset} ${fit(line, w - 4)} ${C.gray}${BX.v}${C.reset}`);
|
|
108
|
+
}
|
|
109
|
+
out.push(`${C.gray}${BX.bl}${BX.h.repeat(w - 2)}${BX.br}${C.reset}`);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Lay panel arrays side by side, padding shorter ones with blanks of their width.
|
|
114
|
+
function columns(panels, gap = 3) {
|
|
115
|
+
const hgt = Math.max(...panels.map((p) => p.length));
|
|
116
|
+
const ws = panels.map((p) => visLen(p[0] || ''));
|
|
117
|
+
const spacer = ' '.repeat(gap);
|
|
118
|
+
const out = [];
|
|
119
|
+
for (let i = 0; i < hgt; i++) {
|
|
120
|
+
out.push(panels.map((p, j) => (p[i] !== undefined ? p[i] : ' '.repeat(ws[j]))).join(spacer));
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Split a content width into two column widths (with a gap between).
|
|
126
|
+
function split2(cw, gap = 3) {
|
|
127
|
+
const tot = cw - gap;
|
|
128
|
+
const l = Math.floor(tot / 2);
|
|
129
|
+
return [l, tot - l];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// "label ............ value" justified to width `w` (panel content width).
|
|
133
|
+
function statRows(pairs, w) {
|
|
134
|
+
return pairs.map(([label, val]) => {
|
|
135
|
+
const pad = Math.max(1, w - visLen(label) - visLen(val));
|
|
136
|
+
return `${C.dim}${label}${C.reset}${' '.repeat(pad)}${val}`;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// "name ████████ value" justified to width `w` (panel content width).
|
|
141
|
+
function barRow(label, valStr, ratio, w) {
|
|
142
|
+
const labelW = Math.max(8, Math.min(16, Math.floor(w * 0.42)));
|
|
143
|
+
const valW = Math.max(5, Math.min(11, visLen(valStr)));
|
|
144
|
+
const barW = Math.max(3, w - labelW - valW - 2);
|
|
145
|
+
const name = String(label).length > labelW ? String(label).slice(0, labelW - 1) + '…' : String(label).padEnd(labelW);
|
|
146
|
+
return `${C.dim}${name}${C.reset} ${bar(ratio, barW)} ${C.cyan}${valStr.padStart(valW)}${C.reset}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hms(ms) {
|
|
150
|
+
const h = (ms || 0) / 3_600_000;
|
|
151
|
+
if (h < 1) return `${Math.round(h * 60)}m`;
|
|
152
|
+
return h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function statusColor(s) {
|
|
156
|
+
return s === 'working' ? C.green
|
|
157
|
+
: s === 'thinking' ? C.yellow
|
|
158
|
+
: s === 'notification' ? C.magenta
|
|
159
|
+
: s === 'shipped' ? C.green
|
|
160
|
+
: s === 'stale' ? C.dim
|
|
161
|
+
: C.cyan;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function headline(title, value, sub) {
|
|
165
|
+
const v = value ? ` ${C.bold}${C.cyan}${value}${C.reset}` : '';
|
|
166
|
+
const s = sub ? ` ${C.dim}${sub}${C.reset}` : '';
|
|
167
|
+
return [`${C.bold}${title.toUpperCase()}${C.reset}${v}${s}`, ''];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Data ──────────────────────────────────────────────────────────────────────
|
|
171
|
+
export function loadSnapshot() {
|
|
55
172
|
let state = readActiveState();
|
|
56
173
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
57
174
|
const config = loadConfig();
|
|
58
175
|
state = applyIdle(state, config);
|
|
59
176
|
const aggregate = readAggregate() || {};
|
|
60
177
|
const vars = buildVars(state, config, aggregate);
|
|
61
|
-
return { state, config, aggregate, vars };
|
|
178
|
+
return { state, config, aggregate, vars, pid: daemonPid() };
|
|
62
179
|
}
|
|
63
180
|
|
|
64
181
|
function daemonPid() {
|
|
@@ -70,293 +187,298 @@ function daemonPid() {
|
|
|
70
187
|
} catch { return null; }
|
|
71
188
|
}
|
|
72
189
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
function drawHeader(w) {
|
|
92
|
-
const pid = daemonPid();
|
|
93
|
-
const status = pid
|
|
94
|
-
? `${C.green}● running${C.reset} ${C.dim}pid ${pid}${C.reset}`
|
|
95
|
-
: `${C.red}○ not running${C.reset}`;
|
|
96
|
-
|
|
97
|
-
const title = `${C.bold}Claude RPC${C.reset}`;
|
|
98
|
-
// First line: title left, status right
|
|
99
|
-
const left = title;
|
|
100
|
-
const right = status;
|
|
101
|
-
const innerWidth = w - 4;
|
|
102
|
-
const padCount = Math.max(1, innerWidth - visLen(left) - visLen(right));
|
|
103
|
-
const line1 = ' ' + left + ' '.repeat(padCount) + right;
|
|
104
|
-
|
|
105
|
-
// Tabs line
|
|
106
|
-
const tabBits = TABS.map((t, i) => {
|
|
107
|
-
if (i === currentTab) return `${C.bold}${C.cyan}${t.label}${C.reset}`;
|
|
108
|
-
return `${C.dim}${t.label}${C.reset}`;
|
|
109
|
-
});
|
|
110
|
-
const tabs = tabBits.join(` ${C.gray}·${C.reset} `);
|
|
111
|
-
const line2 = ' ' + tabs;
|
|
112
|
-
|
|
113
|
-
return [line1, line2, ' ' + rule(w)];
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function drawFooter(w) {
|
|
117
|
-
const keys = `${C.dim}1-7 jump${C.reset} ${C.gray}·${C.reset} ${C.dim}←→ h l${C.reset} ${C.gray}·${C.reset} ${C.dim}r refresh${C.reset} ${C.gray}·${C.reset} ${C.dim}q quit${C.reset}`;
|
|
118
|
-
return [' ' + rule(w), ' ' + keys];
|
|
190
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
191
|
+
|
|
192
|
+
// Rolling last-7-days window (today + the 6 prior days), summed from byDay.
|
|
193
|
+
// Anchored at local noon so a day subtraction can't slip across a DST boundary.
|
|
194
|
+
function last7Days(byDay = {}) {
|
|
195
|
+
const days = [];
|
|
196
|
+
let totMs = 0, prompts = 0, tools = 0, sessions = 0, tokens = 0, cost = 0;
|
|
197
|
+
for (let i = 6; i >= 0; i--) {
|
|
198
|
+
const d = new Date(); d.setHours(12, 0, 0, 0); d.setDate(d.getDate() - i);
|
|
199
|
+
const key = dayKey(d.getTime());
|
|
200
|
+
const e = byDay[key] || {};
|
|
201
|
+
const ms = e.activeMs || 0;
|
|
202
|
+
days.push({ label: `${WD[d.getDay()]} ${key.slice(5)}`, ms, isToday: i === 0 });
|
|
203
|
+
totMs += ms; prompts += e.userMessages || 0; tools += e.toolCalls || 0; sessions += e.sessions || 0;
|
|
204
|
+
tokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.cacheReadTokens || 0) + (e.cacheWriteTokens || 0);
|
|
205
|
+
cost += e.cost || 0;
|
|
206
|
+
}
|
|
207
|
+
return { days, maxMs: Math.max(0, ...days.map((d) => d.ms)), totMs, prompts, tools, sessions, tokens, cost };
|
|
119
208
|
}
|
|
120
209
|
|
|
121
|
-
// ── Tab renderers
|
|
122
|
-
function tabNow(
|
|
210
|
+
// ── Tab renderers — each returns content lines (row() fits them to width) ──────
|
|
211
|
+
function tabNow(data, cw) {
|
|
123
212
|
const v = data.vars;
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
213
|
+
const sc = statusColor(v.status);
|
|
214
|
+
const head = [
|
|
215
|
+
`${C.bold}${sc}${String(v.statusVerbose).toUpperCase()}${C.reset} ${C.dim}in${C.reset} ${C.bold}${v.project}${C.reset}`,
|
|
216
|
+
`${C.dim}${v.modelPretty} · ${v.duration} elapsed${C.reset}`,
|
|
217
|
+
'',
|
|
218
|
+
];
|
|
219
|
+
const [lw, rw] = split2(cw);
|
|
220
|
+
const session = panel('session', statRows([
|
|
221
|
+
['prompts', `${C.yellow}${v.messages}${C.reset}`],
|
|
222
|
+
['tool calls', `${C.yellow}${v.tools}${C.reset}`],
|
|
223
|
+
['files', `${C.cyan}${v.filesOpened}${C.reset} ${C.dim}open · ${v.filesEdited} edit · ${v.filesRead} read${C.reset}`],
|
|
224
|
+
['tokens', `${C.bold}${v.tokensFmt}${C.reset}`],
|
|
225
|
+
['', `${C.dim}${v.inputTokens} in · ${v.outputTokens} out · ${v.cacheTokens} cache${C.reset}`],
|
|
226
|
+
], lw - 4), lw);
|
|
227
|
+
const liveBody = [];
|
|
228
|
+
if (v.currentTool) liveBody.push(...statRows([['doing', `${C.bold}${v.currentToolPretty}${C.reset}`]], rw - 4));
|
|
229
|
+
if (v.currentFilePretty) liveBody.push(`${C.dim}file${C.reset} ${C.cyan}${v.currentFilePretty}${C.reset}`);
|
|
230
|
+
liveBody.push(...statRows([['model', `${C.bold}${v.modelPretty}${C.reset}`]], rw - 4));
|
|
231
|
+
if (Number(v.concurrent) > 1) {
|
|
232
|
+
liveBody.push('');
|
|
233
|
+
liveBody.push(`${C.magenta}${v.concurrentLabel}${C.reset}`);
|
|
234
|
+
liveBody.push(`${C.dim}${v.concurrentListPretty}${C.reset}`);
|
|
235
|
+
} else {
|
|
236
|
+
liveBody.push(`${C.dim}single session${C.reset}`);
|
|
140
237
|
}
|
|
141
|
-
return
|
|
238
|
+
return [...head, ...columns([session, panel('live', liveBody, rw)])];
|
|
142
239
|
}
|
|
143
240
|
|
|
144
|
-
function tabToday(
|
|
241
|
+
function tabToday(data, cw) {
|
|
145
242
|
const v = data.vars;
|
|
146
243
|
const agg = data.aggregate;
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
244
|
+
const head = headline('today', v.todayHours, 'active');
|
|
245
|
+
const [lw, rw] = split2(cw);
|
|
246
|
+
const stats = panel('today', statRows([
|
|
247
|
+
['prompts', `${C.yellow}${v.todayPrompts}${C.reset}`],
|
|
248
|
+
['tool calls', `${C.yellow}${v.todayToolsFmt}${C.reset}`],
|
|
249
|
+
['sessions', `${C.cyan}${v.todaySessions}${C.reset}`],
|
|
250
|
+
['spend', `${C.green}${v.todayCostFmt}${C.reset}`],
|
|
251
|
+
], lw - 4), lw);
|
|
252
|
+
const toks = panel('tokens', statRows([
|
|
253
|
+
['total', `${C.bold}${v.todayTokensFmt}${C.reset}`],
|
|
254
|
+
['fresh', `${C.cyan}${v.todayTokensRealFmt}${C.reset}`],
|
|
255
|
+
['cache', `${C.dim}${v.todayCacheTokensFmt}${C.reset}`],
|
|
256
|
+
['lines', `${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`],
|
|
257
|
+
], rw - 4), rw);
|
|
258
|
+
const out = [...head, ...columns([stats, toks])];
|
|
259
|
+
|
|
260
|
+
// Full-width hour-of-day histogram.
|
|
261
|
+
const heightChars = ' ▁▂▃▄▅▆▇█';
|
|
262
|
+
let max = 0;
|
|
263
|
+
for (let h = 0; h < 24; h++) max = Math.max(max, agg.byHour?.[h]?.activeMs || 0);
|
|
264
|
+
if (max > 0) {
|
|
265
|
+
const bars = [];
|
|
266
|
+
for (let h = 0; h < 24; h++) {
|
|
267
|
+
const ms = agg.byHour?.[h]?.activeMs || 0;
|
|
268
|
+
const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
|
|
269
|
+
const ch = heightChars[idx];
|
|
270
|
+
bars.push(h === v.peakHourNum ? `${C.bold}${heat(1)}${ch}${C.reset}` : `${heat(ms / max)}${ch}${C.reset}`);
|
|
171
271
|
}
|
|
272
|
+
// Two cells per hour so the 24h strip reads wide.
|
|
273
|
+
out.push('');
|
|
274
|
+
out.push(...panel('when you code · hour of day', [
|
|
275
|
+
bars.map((b) => b + b).join(''),
|
|
276
|
+
`${C.dim}00 03 06 09 12 15 18 21${C.reset}`,
|
|
277
|
+
], cw));
|
|
172
278
|
}
|
|
173
279
|
return out;
|
|
174
280
|
}
|
|
175
281
|
|
|
176
|
-
function tabWeek(
|
|
177
|
-
const v = data.vars;
|
|
282
|
+
function tabWeek(data, cw) {
|
|
178
283
|
const agg = data.aggregate;
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return
|
|
284
|
+
const wk = last7Days(agg.byDay || {});
|
|
285
|
+
const head = headline('last 7 days', hms(wk.totMs), 'active');
|
|
286
|
+
const [lw, rw] = split2(cw);
|
|
287
|
+
|
|
288
|
+
const cwBody = lw - 4;
|
|
289
|
+
const dailyBody = wk.days.map(({ label, ms, isToday }) => {
|
|
290
|
+
const marker = isToday ? `${C.bold}${C.cyan} ‹today${C.reset}` : '';
|
|
291
|
+
const lbl = isToday ? `${C.bold}${label}${C.reset}` : `${C.dim}${label}${C.reset}`;
|
|
292
|
+
// Reserve room for the value (1 space + 5) and, on today's row, the marker
|
|
293
|
+
// (' ‹today' = 7) so the bar never pushes the value off the panel edge.
|
|
294
|
+
const barW = Math.max(3, cwBody - 11 - 6 - (isToday ? 7 : 0));
|
|
295
|
+
return `${lbl}${' '.repeat(Math.max(1, 11 - visLen(label)))}${bar(wk.maxMs ? ms / wk.maxMs : 0, barW)} ${C.cyan}${hms(ms).padStart(5)}${C.reset}${marker}`;
|
|
296
|
+
});
|
|
297
|
+
const daily = panel('daily', dailyBody, lw);
|
|
298
|
+
|
|
299
|
+
const totals = panel('last 7 days', statRows([
|
|
300
|
+
['active', `${C.green}${hms(wk.totMs)}${C.reset}`],
|
|
301
|
+
['prompts', `${C.yellow}${fmtNum(wk.prompts)}${C.reset}`],
|
|
302
|
+
['tool calls', `${C.yellow}${fmtNum(wk.tools)}${C.reset}`],
|
|
303
|
+
['sessions', `${C.cyan}${fmtNum(wk.sessions)}${C.reset}`],
|
|
304
|
+
['tokens', `${C.bold}${fmtNum(wk.tokens)}${C.reset}`],
|
|
305
|
+
['spend', `${C.green}${fmtCost(wk.cost)}${C.reset}`],
|
|
306
|
+
], rw - 4), rw);
|
|
307
|
+
|
|
308
|
+
return [...head, ...columns([daily, totals])];
|
|
204
309
|
}
|
|
205
310
|
|
|
206
|
-
function tabStreak(
|
|
311
|
+
function tabStreak(data, cw) {
|
|
207
312
|
const v = data.vars;
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
if (v.peakHour) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (v.topEditedFile) {
|
|
223
|
-
out.push('');
|
|
224
|
-
out.push(`${C.dim}hotspot${C.reset} ${C.bold}${v.topEditedFile}${C.reset} ${C.dim}${v.topEditedCountLabel}${C.reset}`);
|
|
225
|
-
}
|
|
226
|
-
return out;
|
|
313
|
+
const [lw, rw] = split2(cw);
|
|
314
|
+
const head = headline('streak', `${v.streak}d`, `longest ${v.longestStreak}d`);
|
|
315
|
+
const left = panel('streak', statRows([
|
|
316
|
+
['current', `${C.bold}${C.magenta}${v.streak}${C.reset} ${C.dim}days${C.reset}`],
|
|
317
|
+
['longest', `${C.cyan}${v.longestStreak}${C.reset} ${C.dim}days${C.reset}`],
|
|
318
|
+
['days on Claude', `${C.cyan}${v.daysSinceFirst}${C.reset}`],
|
|
319
|
+
], lw - 4), lw);
|
|
320
|
+
const rb = [];
|
|
321
|
+
if (v.bestDayDate) rb.push(...statRows([['best day', `${C.bold}${v.bestDayHours}${C.reset} ${C.dim}${v.bestDayDate}${C.reset}`]], rw - 4));
|
|
322
|
+
if (v.bestDayDate) rb.push(`${C.dim}${v.bestDayPrompts} prompts · ${v.bestDayTokensFmt} tokens${C.reset}`);
|
|
323
|
+
if (v.peakHour) rb.push(...statRows([['peak hour', `${C.bold}${v.peakHour}${C.reset} ${C.dim}${v.peakHourActiveLabel}${C.reset}`]], rw - 4));
|
|
324
|
+
if (v.topEditedFile) rb.push(...statRows([['hotspot', `${C.bold}${v.topEditedFile}${C.reset}`]], rw - 4));
|
|
325
|
+
if (!rb.length) rb.push(`${C.dim}keep going to unlock records${C.reset}`);
|
|
326
|
+
return [...head, ...columns([left, panel('records', rb, rw)])];
|
|
227
327
|
}
|
|
228
328
|
|
|
229
|
-
function tabLifetime(
|
|
329
|
+
function tabLifetime(data, cw) {
|
|
230
330
|
const v = data.vars;
|
|
231
331
|
const agg = data.aggregate;
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
out.push(`${C.dim}top projects${C.reset}`);
|
|
249
|
-
const maxMs = top[0][1].activeMs;
|
|
250
|
-
for (const [name, p] of top) {
|
|
251
|
-
const h = p.activeMs / 3_600_000;
|
|
252
|
-
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
253
|
-
const pretty = humanProject(name).slice(0, 18).padEnd(20);
|
|
254
|
-
out.push(`${pretty} ${bar(p.activeMs, maxMs, 16)} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return out;
|
|
332
|
+
const [lw, rw] = split2(cw);
|
|
333
|
+
const head = headline('all-time', v.allHours, `active · ${v.allWallHours} wall`);
|
|
334
|
+
const stats = panel('all-time', statRows([
|
|
335
|
+
['sessions', `${C.yellow}${v.allSessions}${C.reset} ${C.dim}+${v.allSubagentRuns} sub${C.reset}`],
|
|
336
|
+
['prompts', `${C.yellow}${v.allMessagesFmt}${C.reset}`],
|
|
337
|
+
['tool calls', `${C.yellow}${v.allToolsFmt}${C.reset}`],
|
|
338
|
+
['files', `${C.cyan}${v.allFilesFmt}${C.reset}`],
|
|
339
|
+
['tokens', `${C.bold}${C.magenta}${v.allTokensFmt}${C.reset}`],
|
|
340
|
+
['spend', `${C.green}${v.allCostFmt}${C.reset}`],
|
|
341
|
+
], lw - 4), lw);
|
|
342
|
+
const top = Object.entries(agg.projects || {}).sort((a, b) => b[1].activeMs - a[1].activeMs).slice(0, 6);
|
|
343
|
+
const maxMs = top[0] ? top[0][1].activeMs : 1;
|
|
344
|
+
const projBody = top.length
|
|
345
|
+
? top.map(([name, p]) => barRow(humanProject(name), hms(p.activeMs), p.activeMs / maxMs, rw - 4))
|
|
346
|
+
: [`${C.dim}no projects yet${C.reset}`];
|
|
347
|
+
return [...head, ...columns([stats, panel('top projects · by time', projBody, rw)])];
|
|
258
348
|
}
|
|
259
349
|
|
|
260
|
-
function tabCost(
|
|
350
|
+
function tabCost(data, cw) {
|
|
261
351
|
const v = data.vars;
|
|
262
352
|
const agg = data.aggregate;
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
353
|
+
const [lw, rw] = split2(cw);
|
|
354
|
+
const head = headline('spend', v.allCostFmt, 'all-time · approximate');
|
|
355
|
+
|
|
356
|
+
// Per-project spend — the headline ask.
|
|
357
|
+
const projs = Object.entries(agg.projects || {})
|
|
358
|
+
.map(([k, p]) => [humanProject(k), p.cost || 0]).filter(([, c]) => c > 0)
|
|
359
|
+
.sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
360
|
+
const maxP = projs[0] ? projs[0][1] : 1;
|
|
361
|
+
const projBody = projs.length
|
|
362
|
+
? projs.map(([name, c]) => barRow(name, fmtCost(c), c / maxP, lw - 4))
|
|
363
|
+
: [`${C.dim}no spend recorded${C.reset}`];
|
|
364
|
+
const byProject = panel('by project', projBody, lw);
|
|
365
|
+
|
|
366
|
+
const models = Object.entries(agg.costByModel || {}).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
367
|
+
const maxM = models[0] ? models[0][1] : 1;
|
|
368
|
+
const modelBody = models.length
|
|
369
|
+
? models.map(([m, c]) => barRow(m, fmtCost(c), c / maxM, rw - 4))
|
|
370
|
+
: [`${C.dim}no model data${C.reset}`];
|
|
371
|
+
const byModel = panel('by model', modelBody, rw);
|
|
372
|
+
|
|
373
|
+
const out = [...head, ...columns([byProject, byModel])];
|
|
374
|
+
|
|
375
|
+
// Month-to-date + forecast strip.
|
|
283
376
|
const now = new Date();
|
|
284
377
|
const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
285
378
|
let mtd = 0;
|
|
286
|
-
for (const [k, day] of Object.entries(agg.byDay || {}))
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const daysIn = now.getDate();
|
|
291
|
-
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
292
|
-
const forecast = (mtd / daysIn) * daysInMonth;
|
|
293
|
-
out.push('');
|
|
294
|
-
out.push(`${C.dim}month-to-date${C.reset} ${C.cyan}${fmtCost(mtd).padStart(8)}${C.reset}`);
|
|
295
|
-
out.push(`${C.dim}forecast${C.reset} ${C.bold}${fmtCost(forecast).padStart(8)}${C.reset}`);
|
|
296
|
-
}
|
|
379
|
+
for (const [k, day] of Object.entries(agg.byDay || {})) if (k.startsWith(ym)) mtd += day.cost || 0;
|
|
380
|
+
const forecast = mtd > 0 ? (mtd / now.getDate()) * new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() : 0;
|
|
381
|
+
out.push('');
|
|
382
|
+
out.push(`${C.dim}today${C.reset} ${C.cyan}${v.todayCostFmt}${C.reset} ${C.dim}week${C.reset} ${C.cyan}${v.weekCostFmt}${C.reset} ${C.dim}month-to-date${C.reset} ${C.cyan}${fmtCost(mtd)}${C.reset} ${C.dim}forecast${C.reset} ${C.bold}${fmtCost(forecast)}${C.reset}`);
|
|
297
383
|
return out;
|
|
298
384
|
}
|
|
299
385
|
|
|
300
|
-
function tabCode(
|
|
386
|
+
function tabCode(data, cw) {
|
|
301
387
|
const v = data.vars;
|
|
302
388
|
const agg = data.aggregate;
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
out.push(`${C.bold}Code churn${C.reset} ${C.green}+${v.linesAddedFmt}${C.reset} / ${C.red}−${v.linesRemovedFmt}${C.reset} ${C.dim}net ${v.linesNetFmt}${C.reset}`);
|
|
306
|
-
out.push('');
|
|
307
|
-
out.push(`${C.dim}today${C.reset} ${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`);
|
|
308
|
-
out.push(`${C.dim}this week${C.reset} ${C.green}+${v.weekLinesAddedFmt}${C.reset} ${C.dim}(${v.weekLinesNetFmt} net)${C.reset}`);
|
|
309
|
-
|
|
389
|
+
const [lw, rw] = split2(cw);
|
|
390
|
+
const head = headline('code churn', `+${v.linesAddedFmt} / −${v.linesRemovedFmt}`, `net ${v.linesNetFmt}`);
|
|
310
391
|
const langs = Object.entries(agg.languages || {}).sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0)).slice(0, 6);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
return out;
|
|
392
|
+
const maxL = langs[0] ? (langs[0][1].edits || 1) : 1;
|
|
393
|
+
const langBody = [
|
|
394
|
+
...statRows([
|
|
395
|
+
['today', `${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`],
|
|
396
|
+
['this week', `${C.green}+${v.weekLinesAddedFmt}${C.reset} ${C.dim}(${v.weekLinesNetFmt} net)${C.reset}`],
|
|
397
|
+
], lw - 4),
|
|
398
|
+
'',
|
|
399
|
+
...(langs.length ? langs.map(([n, l]) => barRow(n, fmtNum(l.edits || 0), (l.edits || 0) / maxL, lw - 4)) : [`${C.dim}no edits yet${C.reset}`]),
|
|
400
|
+
];
|
|
401
|
+
const churn = panel('languages · by edits', langBody, lw);
|
|
402
|
+
|
|
403
|
+
const bash = Object.entries(agg.bashCommands || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
404
|
+
const dom = Object.entries(agg.webDomains || {}).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
405
|
+
const rb = [];
|
|
406
|
+
if (bash.length) { rb.push(`${C.dim}top bash${C.reset}`); for (const [k, n] of bash) rb.push(`${String(k).slice(0, rw - 12).padEnd(rw - 12)} ${C.cyan}${String(n).padStart(6)}${C.reset}`); }
|
|
407
|
+
if (dom.length) { rb.push(''); rb.push(`${C.dim}web domains${C.reset}`); for (const [k, n] of dom) rb.push(`${String(k).slice(0, rw - 12).padEnd(rw - 12)} ${C.cyan}${String(n).padStart(6)}${C.reset}`); }
|
|
408
|
+
if (!rb.length) rb.push(`${C.dim}no shell/web activity${C.reset}`);
|
|
409
|
+
return [...head, ...columns([churn, panel('shell & web', rb, rw)])];
|
|
330
410
|
}
|
|
331
411
|
|
|
332
412
|
const TAB_RENDERERS = [tabNow, tabToday, tabWeek, tabStreak, tabLifetime, tabCost, tabCode];
|
|
333
413
|
|
|
334
|
-
// ──
|
|
335
|
-
function
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
414
|
+
// ── Frame composition ─────────────────────────────────────────────────────────
|
|
415
|
+
function topBorder(w, title, statusColored) {
|
|
416
|
+
const fill = Math.max(0, w - title.length - visLen(statusColored) - 8);
|
|
417
|
+
return `${C.gray}${BX.tl}${BX.h} ${C.bold}${title}${C.reset}${C.gray} ${BX.h.repeat(fill)} ${C.reset}${statusColored}${C.gray} ${BX.h}${BX.tr}${C.reset}`;
|
|
418
|
+
}
|
|
419
|
+
function bottomBorder(w) { return `${C.gray}${BX.bl}${BX.h.repeat(w - 2)}${BX.br}${C.reset}`; }
|
|
420
|
+
function sepLine(w) { return `${C.gray}${BX.sl}${BX.h.repeat(w - 2)}${BX.sr}${C.reset}`; }
|
|
421
|
+
function row(content, w) { return `${C.gray}${BX.v}${C.reset} ${fit(content, w - 4)} ${C.gray}${BX.v}${C.reset}`; }
|
|
422
|
+
|
|
423
|
+
export function renderFrame(data, { width, height, tab }) {
|
|
424
|
+
const w = Math.max(64, Math.min(120, width || 100));
|
|
425
|
+
const h = Math.max(20, Math.min(32, height || 28));
|
|
426
|
+
const cw = w - 4;
|
|
427
|
+
|
|
428
|
+
const status = data.pid
|
|
429
|
+
? `${C.green}●${C.reset} ${C.dim}running · pid ${data.pid}${C.reset}`
|
|
430
|
+
: `${C.red}○${C.reset} ${C.dim}daemon not running${C.reset}`;
|
|
431
|
+
const tabBar = TABS.map((t, i) => (i === tab
|
|
432
|
+
? `${C.bold}${C.cyan}‹ ${t.label} ›${C.reset}`
|
|
433
|
+
: `${C.dim}${t.label}${C.reset}`)).join(' ');
|
|
434
|
+
const footer = `${C.dim}1-7${C.reset} jump ${C.gray}·${C.reset} ${C.dim}←→ h l${C.reset} tab ${C.gray}·${C.reset} ${C.dim}r${C.reset} refresh ${C.gray}·${C.reset} ${C.dim}q${C.reset} quit`;
|
|
435
|
+
|
|
436
|
+
// Header breathes: blank line, centred tab bar, blank line, then the rule —
|
|
437
|
+
// so the top reads as a header band with air, not a squished edge strip.
|
|
438
|
+
const B = Math.max(1, h - 8); // header(border+blank+tabs+blank+rule=5) + footer(rule+keys+border=3)
|
|
439
|
+
const bodyContent = TAB_RENDERERS[tab](data, cw);
|
|
440
|
+
const body = bodyContent.slice(0, B);
|
|
441
|
+
while (body.length < B) body.push('');
|
|
442
|
+
|
|
443
|
+
const lines = [
|
|
444
|
+
topBorder(w, 'claude-rpc', status),
|
|
445
|
+
row('', w),
|
|
446
|
+
row(center(tabBar, cw), w),
|
|
447
|
+
row('', w),
|
|
448
|
+
sepLine(w),
|
|
449
|
+
];
|
|
450
|
+
for (const b of body) lines.push(row(b, w));
|
|
451
|
+
lines.push(sepLine(w), row(center(footer, cw), w), bottomBorder(w));
|
|
452
|
+
return lines.join('\n');
|
|
453
|
+
}
|
|
343
454
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
455
|
+
// ── Live render / input / lifecycle ───────────────────────────────────────────
|
|
456
|
+
// Frame size + centring margins. The frame caps out (max 120×32) and centres
|
|
457
|
+
// in the terminal, so a big full-screen window reads as a centred dashboard with
|
|
458
|
+
// breathing room instead of a frame glued to the top-left edge.
|
|
459
|
+
function frameLayout(termW, termH) {
|
|
460
|
+
const w = Math.max(64, Math.min(120, termW - 4));
|
|
461
|
+
const h = Math.max(20, Math.min(28, termH - 2));
|
|
462
|
+
return { w, h, left: Math.max(0, (termW - w) >> 1), top: Math.max(0, (termH - h) >> 1) };
|
|
463
|
+
}
|
|
348
464
|
|
|
349
|
-
|
|
465
|
+
function render() {
|
|
466
|
+
const termW = process.stdout.columns || 100;
|
|
467
|
+
const termH = process.stdout.rows || 30;
|
|
468
|
+
const { w, h, left, top } = frameLayout(termW, termH);
|
|
469
|
+
const pad = ' '.repeat(left);
|
|
470
|
+
const frame = renderFrame(loadSnapshot(), { width: w, height: h, tab: currentTab })
|
|
471
|
+
.split('\n').map((l) => pad + l).join('\n');
|
|
472
|
+
process.stdout.write(CLEAR + '\n'.repeat(top) + frame);
|
|
350
473
|
}
|
|
351
474
|
|
|
352
|
-
// ── Input / lifecycle ───────────────────────────────────────────────────────
|
|
353
475
|
function cleanup() {
|
|
354
476
|
if (exiting) return;
|
|
355
477
|
exiting = true;
|
|
356
478
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
357
479
|
try { process.stdin.setRawMode(false); } catch { /* not a tty (CI, pipe) — no-op */ }
|
|
358
480
|
process.stdin.pause();
|
|
359
|
-
process.stdout.write(SHOW_CURSOR +
|
|
481
|
+
process.stdout.write(SHOW_CURSOR + ALT_OFF);
|
|
360
482
|
process.exit(0);
|
|
361
483
|
}
|
|
362
484
|
|
|
@@ -364,18 +486,9 @@ function handleKey(buf) {
|
|
|
364
486
|
const key = buf.toString();
|
|
365
487
|
if (key === '\x03' || key.toLowerCase() === 'q') return cleanup();
|
|
366
488
|
if (key.toLowerCase() === 'r') return render();
|
|
367
|
-
if (key.length === 1 && key >= '1' && key <= String(TABS.length)) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
if (key === '\x1b[C' || key === '\x1b[B' || key === '\t' || key === 'l' || key === 'j') {
|
|
372
|
-
currentTab = (currentTab + 1) % TABS.length;
|
|
373
|
-
return render();
|
|
374
|
-
}
|
|
375
|
-
if (key === '\x1b[D' || key === '\x1b[A' || key === 'h' || key === 'k') {
|
|
376
|
-
currentTab = (currentTab - 1 + TABS.length) % TABS.length;
|
|
377
|
-
return render();
|
|
378
|
-
}
|
|
489
|
+
if (key.length === 1 && key >= '1' && key <= String(TABS.length)) { currentTab = Number(key) - 1; return render(); }
|
|
490
|
+
if (key === '\x1b[C' || key === '\x1b[B' || key === '\t' || key === 'l' || key === 'j') { currentTab = (currentTab + 1) % TABS.length; return render(); }
|
|
491
|
+
if (key === '\x1b[D' || key === '\x1b[A' || key === 'h' || key === 'k') { currentTab = (currentTab - 1 + TABS.length) % TABS.length; return render(); }
|
|
379
492
|
}
|
|
380
493
|
|
|
381
494
|
export function startTui() {
|
|
@@ -383,8 +496,7 @@ export function startTui() {
|
|
|
383
496
|
console.error('claude-rpc status: not a TTY. Use `claude-rpc status --dump` for plain output.');
|
|
384
497
|
process.exit(1);
|
|
385
498
|
}
|
|
386
|
-
process.stdout.write(HIDE_CURSOR);
|
|
387
|
-
|
|
499
|
+
process.stdout.write(ALT_ON + HIDE_CURSOR);
|
|
388
500
|
try { process.stdin.setRawMode(true); } catch { /* not a tty — TUI will print once and exit */ }
|
|
389
501
|
process.stdin.resume();
|
|
390
502
|
process.stdin.on('data', handleKey);
|
|
@@ -394,7 +506,7 @@ export function startTui() {
|
|
|
394
506
|
process.on('SIGHUP', cleanup);
|
|
395
507
|
process.on('exit', () => {
|
|
396
508
|
try { process.stdin.setRawMode(false); } catch { /* not a tty (CI, pipe) — no-op */ }
|
|
397
|
-
process.stdout.write(SHOW_CURSOR);
|
|
509
|
+
process.stdout.write(SHOW_CURSOR + ALT_OFF);
|
|
398
510
|
});
|
|
399
511
|
process.stdout.on('resize', () => render());
|
|
400
512
|
|