claude-code-watcher 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import {
2
+ _segmenter,
3
+ color,
4
+ FG,
5
+ graphemeDisplayWidth,
6
+ visibleLength,
7
+ } from './ansi.mjs';
8
+
9
+ /**
10
+ * Truncate a string to maxLen terminal columns, appending ellipsis if needed.
11
+ * Uses grapheme cluster segmentation to correctly handle emoji and wide characters.
12
+ */
13
+ export function truncate(str, maxLen) {
14
+ if (!str) return '';
15
+ str = str.replace(/\r?\n/g, ' ');
16
+ let width = 0;
17
+ let i = 0;
18
+ for (const { segment } of _segmenter.segment(str)) {
19
+ const cw = graphemeDisplayWidth(segment);
20
+ if (width + cw > maxLen - 1) {
21
+ return `${str.slice(0, i)}…`;
22
+ }
23
+ width += cw;
24
+ i += segment.length;
25
+ }
26
+ return str;
27
+ }
28
+
29
+ /**
30
+ * Pad a string to a fixed visible width (left-aligned).
31
+ */
32
+ export function padEnd(str, width) {
33
+ const visible = visibleLength(str);
34
+ if (visible >= width) return str;
35
+ return str + ' '.repeat(width - visible);
36
+ }
37
+
38
+ /**
39
+ * Pad a string to a fixed visible width (right-aligned).
40
+ */
41
+ export function padStart(str, width) {
42
+ const visible = visibleLength(str);
43
+ if (visible >= width) return str;
44
+ return ' '.repeat(width - visible) + str;
45
+ }
46
+
47
+ /**
48
+ * Format a Date to HH:MM:SS string.
49
+ */
50
+ export function formatTime(date) {
51
+ if (!(date instanceof Date)) return '--:--:--';
52
+ const h = String(date.getHours()).padStart(2, '0');
53
+ const m = String(date.getMinutes()).padStart(2, '0');
54
+ const s = String(date.getSeconds()).padStart(2, '0');
55
+ return `${h}:${m}:${s}`;
56
+ }
57
+
58
+ /**
59
+ * Return ANSI color code for a given session status.
60
+ */
61
+ export function statusColor(status) {
62
+ switch (status) {
63
+ case 'working':
64
+ return FG.YELLOW;
65
+ case 'waiting':
66
+ return FG.GREEN;
67
+ case 'stale':
68
+ return FG.BRIGHT_BLACK;
69
+ case 'notification':
70
+ return FG.CYAN;
71
+ case 'error':
72
+ return FG.RED;
73
+ default:
74
+ return FG.WHITE;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Return a colored status label string.
80
+ */
81
+ export function formatStatus(status) {
82
+ return color(statusColor(status), status);
83
+ }
84
+
@@ -0,0 +1,10 @@
1
+ export const MIN_WIDTH = 60;
2
+
3
+ /**
4
+ * Get current terminal dimensions.
5
+ */
6
+ export function getTermSize() {
7
+ const cols = process.stdout.columns || 80;
8
+ const rows = process.stdout.rows || 24;
9
+ return { cols, rows };
10
+ }
@@ -0,0 +1,507 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readConfig } from '../core/config.mjs';
3
+ import {
4
+ ALT_SCREEN_OFF,
5
+ ALT_SCREEN_ON,
6
+ BOLD,
7
+ CLEAR_LINE,
8
+ CURSOR_HIDE,
9
+ CURSOR_SHOW,
10
+ color,
11
+ DIM,
12
+ FG,
13
+ hyperlink,
14
+ RESET,
15
+ visibleLength,
16
+ } from './ansi.mjs';
17
+ import {
18
+ formatStatus,
19
+ formatTime,
20
+ padEnd,
21
+ padStart,
22
+ statusColor,
23
+ truncate,
24
+ } from './format.mjs';
25
+
26
+ // Apply the status color to an arbitrary label string (e.g. a truncated status).
27
+ function coloredStatus(status, label) {
28
+ return color(statusColor(status), label);
29
+ }
30
+
31
+ import { getTermSize, MIN_WIDTH } from './layout.mjs';
32
+
33
+ const TITLE = 'Claude Code Watcher';
34
+
35
+ // Column widths (fixed)
36
+ const SEL_W = 2; // '❯ ' or ' '
37
+ const NUM_W = 3; // right-aligned row number
38
+ const STAT_W = 12; // status text
39
+ const TIME_W = 5; // HH:MM
40
+ const GAP = 2; // spaces between columns
41
+ const ROW_FIXED = SEL_W + NUM_W + STAT_W + TIME_W + GAP * 4;
42
+
43
+ // Detail view label column width: longest label (7: updated/started/message) + 2-space gap
44
+ const LABEL_W = 9;
45
+
46
+ function calcVarWidths(cols) {
47
+ const varW = Math.max(0, cols - 4 - ROW_FIXED);
48
+ const projW = Math.floor(varW * 0.38);
49
+ const msgW = varW - projW;
50
+ return { projW, msgW };
51
+ }
52
+
53
+ export class Renderer {
54
+ #store;
55
+ #keyboard;
56
+ #selectedIndex = 0;
57
+ #view = 'list'; // 'list' | 'detail'
58
+ #sessions = [];
59
+ #resizeHandler = null;
60
+ #updateHandler = null;
61
+ #tickTimer = null;
62
+ #prevLines = []; // diff-rendering cache: only changed lines are written to stdout
63
+ // last alert timestamp per sessionId — prevents rapid-fire sounds per session
64
+ #lastAlertAts = new Map();
65
+ // previous alertAt per sessionId — alerts when hook writes a new alertAt timestamp
66
+ #prevAlertAts = new Map();
67
+
68
+ constructor(store, keyboard) {
69
+ this.#store = store;
70
+ this.#keyboard = keyboard;
71
+ }
72
+
73
+ start() {
74
+ this.#sessions = this.#store.getSessions();
75
+ process.stdout.write(ALT_SCREEN_ON + CURSOR_HIDE);
76
+
77
+ // Prime prevAlertAts with current values so existing alertAt timestamps
78
+ // don't trigger spurious alerts on the first store update event.
79
+ for (const session of this.#sessions) {
80
+ this.#prevAlertAts.set(session.sessionId, session.alertAt);
81
+ }
82
+
83
+ this.#updateHandler = sessions => {
84
+ this.#sessions = sessions;
85
+ if (this.#selectedIndex >= this.#sessions.length) {
86
+ this.#selectedIndex = Math.max(0, this.#sessions.length - 1);
87
+ }
88
+ this.#checkNotifications();
89
+ this.#render();
90
+ };
91
+ this.#store.on('update', this.#updateHandler);
92
+ this.#keyboard.on('key', key => this.#handleKey(key));
93
+ this.#resizeHandler = () => {
94
+ this.#prevLines = [];
95
+ this.redraw();
96
+ };
97
+ process.stdout.on('resize', this.#resizeHandler);
98
+ this.#tickTimer = setInterval(() => this.#render(), 1000);
99
+ this.#render();
100
+ }
101
+
102
+ #checkNotifications() {
103
+ const currentIds = new Set(this.#sessions.map(s => s.sessionId));
104
+
105
+ for (const id of this.#prevAlertAts.keys()) {
106
+ if (!currentIds.has(id)) this.#prevAlertAts.delete(id);
107
+ }
108
+
109
+ for (const session of this.#sessions) {
110
+ const { sessionId, alertAt, alertEvent } = session;
111
+ const prev = this.#prevAlertAts.get(sessionId);
112
+
113
+ if (alertAt && alertAt !== prev) {
114
+ this.#alert(sessionId, alertEvent);
115
+ }
116
+
117
+ this.#prevAlertAts.set(sessionId, alertAt);
118
+ }
119
+ }
120
+
121
+ #alert(sessionId, alertEvent) {
122
+ const now = Date.now();
123
+ if (now - (this.#lastAlertAts.get(sessionId) ?? 0) < 3000) return; // 3초 이내 중복 사운드 방지
124
+ this.#lastAlertAts.set(sessionId, now);
125
+
126
+ process.stdout.write('\x07');
127
+ if (process.platform === 'darwin') {
128
+ const cfg = readConfig();
129
+ const soundKey = alertEvent === 'Stop' ? 'stop' : 'noti';
130
+ const soundName =
131
+ cfg.sounds?.[soundKey] ?? (alertEvent === 'Stop' ? 'Blow' : 'Funk');
132
+ execFile(
133
+ 'afplay',
134
+ [`/System/Library/Sounds/${soundName}.aiff`],
135
+ { timeout: 3000 },
136
+ () => {
137
+ /* ignore errors */
138
+ },
139
+ );
140
+ }
141
+ }
142
+
143
+ #renderHelpLine(lines, cols, keys) {
144
+ const help = keys
145
+ .map(([k, v]) => `${color(FG.BRIGHT_WHITE, k)} ${DIM}${v}${RESET}`)
146
+ .join(' ');
147
+ const credit = color(FG.BRIGHT_BLACK, '@roy-jung');
148
+ const helpPart = ` ${help}`;
149
+ const gap = Math.max(
150
+ 0,
151
+ cols - visibleLength(helpPart) - visibleLength(credit),
152
+ );
153
+ lines.push(`${helpPart}${' '.repeat(gap)}${credit}`);
154
+ }
155
+
156
+ stop() {
157
+ if (this.#tickTimer) {
158
+ clearInterval(this.#tickTimer);
159
+ this.#tickTimer = null;
160
+ }
161
+ if (this.#resizeHandler)
162
+ process.stdout.removeListener('resize', this.#resizeHandler);
163
+ if (this.#updateHandler)
164
+ this.#store.removeListener('update', this.#updateHandler);
165
+ process.stdout.write(CURSOR_SHOW + ALT_SCREEN_OFF);
166
+ }
167
+
168
+ redraw() {
169
+ this.#render();
170
+ }
171
+
172
+ selectNext() {
173
+ if (this.#sessions.length === 0) return;
174
+ this.#selectedIndex = (this.#selectedIndex + 1) % this.#sessions.length;
175
+ this.#render();
176
+ }
177
+
178
+ selectPrev() {
179
+ if (this.#sessions.length === 0) return;
180
+ this.#selectedIndex =
181
+ (this.#selectedIndex - 1 + this.#sessions.length) % this.#sessions.length;
182
+ this.#render();
183
+ }
184
+
185
+ toggleDetail() {
186
+ if (this.#sessions.length === 0) return;
187
+ this.#view = this.#view === 'list' ? 'detail' : 'list';
188
+ this.#render();
189
+ }
190
+
191
+ #handleKey(key) {
192
+ switch (key.name) {
193
+ case 'q':
194
+ case 'Q':
195
+ this.#keyboard.emit('quit');
196
+ break;
197
+ case 'r':
198
+ this.#store.reload();
199
+ break;
200
+ case 'R':
201
+ this.#keyboard.emit('respawn');
202
+ break;
203
+ case 'up':
204
+ if (this.#view === 'list') this.selectPrev();
205
+ break;
206
+ case 'down':
207
+ if (this.#view === 'list') this.selectNext();
208
+ break;
209
+ case 'return':
210
+ case 'enter':
211
+ this.toggleDetail();
212
+ break;
213
+ case 'escape':
214
+ if (this.#view === 'detail') {
215
+ this.#view = 'list';
216
+ this.#render();
217
+ }
218
+ break;
219
+ }
220
+ }
221
+
222
+ #render() {
223
+ const { cols, rows } = getTermSize();
224
+ const lines = [];
225
+
226
+ if (cols < MIN_WIDTH) {
227
+ lines.push('');
228
+ lines.push(
229
+ color(FG.RED, ` Terminal too narrow (min ${MIN_WIDTH} cols)`),
230
+ );
231
+ lines.push(color(FG.BRIGHT_BLACK, ` Current: ${cols} cols`));
232
+ this.#flush(lines, rows);
233
+ return;
234
+ }
235
+
236
+ // Use cols-1 so the box never touches the terminal's last column,
237
+ // avoiding auto-wrap artifacts in terminals like Ghostty.
238
+ const w = cols - 1;
239
+
240
+ if (this.#view === 'detail' && this.#sessions.length > 0) {
241
+ this.#renderDetail(lines, w, rows);
242
+ } else {
243
+ this.#renderList(lines, w, rows);
244
+ }
245
+ this.#flush(lines, rows);
246
+ }
247
+
248
+ #renderHeader(lines, cols, now) {
249
+ const count = this.#sessions.length;
250
+ const inner = cols - 4;
251
+ const dot = count > 0 ? color(FG.CYAN, '●') : color(FG.BRIGHT_BLACK, '○');
252
+ const stats = `${dot} ${BOLD}${count}${RESET} active ${color(FG.BRIGHT_BLACK, '·')} ${color(FG.BRIGHT_BLACK, formatTime(now))}`;
253
+ const titleTxt = `${color(FG.BRIGHT_MAGENTA, '## ')}${BOLD}${TITLE}${RESET}${color(FG.BRIGHT_MAGENTA, ' ##')}`;
254
+ const pad = Math.max(
255
+ 0,
256
+ inner - visibleLength(titleTxt) - visibleLength(stats),
257
+ );
258
+ lines.push(color(FG.MAGENTA, `╭${'─'.repeat(cols - 2)}╮`));
259
+ lines.push(
260
+ `${color(FG.MAGENTA, '│')} ${titleTxt}${' '.repeat(pad)}${stats} ${color(FG.MAGENTA, '│')}`,
261
+ );
262
+ lines.push(color(FG.MAGENTA, `╰${'─'.repeat(cols - 2)}╯`));
263
+ }
264
+
265
+ #renderList(lines, cols, rows) {
266
+ const now = new Date();
267
+ const inner = cols - 4; // visible content width between '│ ' and ' │'
268
+ const { projW, msgW } = calcVarWidths(cols);
269
+
270
+ // ── Header box ────────────────────────────────────────────────────
271
+ this.#renderHeader(lines, cols, now);
272
+
273
+ // ── Session list box ──────────────────────────────────────────────
274
+ lines.push(color(FG.WHITE, `╭${'─'.repeat(cols - 2)}╮`));
275
+
276
+ const hdr = buildDataLine(
277
+ ' ',
278
+ padStart('#', NUM_W),
279
+ padEnd('Project', projW),
280
+ padEnd('Status', STAT_W),
281
+ padEnd('Last Message', msgW),
282
+ padEnd('Time', TIME_W),
283
+ );
284
+ lines.push(
285
+ `${color(FG.WHITE, '│')} ${BOLD}${hdr}${RESET} ${color(FG.WHITE, '│')}`,
286
+ );
287
+ lines.push(
288
+ `${color(FG.WHITE, '│')} ${color(FG.BRIGHT_BLACK, '─'.repeat(inner - 1))} ${color(FG.WHITE, '│')}`,
289
+ );
290
+
291
+ if (this.#sessions.length === 0) {
292
+ lines.push(
293
+ `${color(FG.WHITE, '│')} ${color(FG.BRIGHT_BLACK, padEnd('No active Claude sessions', inner - 1))} ${color(FG.WHITE, '│')}`,
294
+ );
295
+ } else {
296
+ // fixed lines: header-box(3) + list-top(1) + col-hdr(1) + underline(1) + list-bottom(1) + help(1) = 8
297
+ const maxRows = Math.max(1, rows - 1 - 8);
298
+ this.#sessions.slice(0, maxRows).forEach((session, i) => {
299
+ const isSelected = i === this.#selectedIndex;
300
+ const numStr = String(i + 1);
301
+
302
+ const sel = isSelected ? color(FG.CYAN, '❯ ') : ' ';
303
+ const num = padEnd(
304
+ color(isSelected ? FG.CYAN : FG.BRIGHT_BLACK, numStr.padStart(NUM_W)),
305
+ NUM_W,
306
+ );
307
+ const proj = padEnd(truncate(session.displayName, projW), projW);
308
+ const subagents = this.#store.getSubagents(session.sessionId);
309
+ const activeSubCount = subagents.filter(
310
+ s => s.status === 'working',
311
+ ).length;
312
+ let stat;
313
+ if (activeSubCount > 0) {
314
+ const badge = color(FG.BRIGHT_BLACK, `[${activeSubCount}]`);
315
+ const badgeW = 1 + String(activeSubCount).length + 2; // ' [N]'
316
+ const label = truncate(session.status, STAT_W - badgeW);
317
+ stat = padEnd(
318
+ `${coloredStatus(session.status, label)} ${badge}`,
319
+ STAT_W,
320
+ );
321
+ } else {
322
+ stat = padEnd(formatStatus(session.status), STAT_W);
323
+ }
324
+ const msg = padEnd(
325
+ truncate(session.message || session.lastResponse || '', msgW),
326
+ msgW,
327
+ );
328
+ const time = color(
329
+ FG.BRIGHT_BLACK,
330
+ padEnd(session.sinceLabel || '', TIME_W),
331
+ );
332
+
333
+ const rowLine = buildDataLine(sel, num, proj, stat, msg, time);
334
+ lines.push(
335
+ isSelected
336
+ ? `${color(FG.WHITE, '│')} ${BOLD}${rowLine}${RESET} ${color(FG.WHITE, '│')}`
337
+ : `${color(FG.WHITE, '│')} ${rowLine} ${color(FG.WHITE, '│')}`,
338
+ );
339
+ });
340
+ }
341
+
342
+ lines.push(color(FG.WHITE, `╰${'─'.repeat(cols - 2)}╯`));
343
+
344
+ this.#renderHelpLine(lines, cols, [
345
+ ['q', 'quit'],
346
+ ['↑↓', 'navigate'],
347
+ ['↵', 'detail'],
348
+ ['r', 'refresh'],
349
+ ['R', 'restart'],
350
+ ]);
351
+ }
352
+
353
+ #renderDetail(lines, cols, rows) {
354
+ const session = this.#sessions[this.#selectedIndex];
355
+ if (!session) {
356
+ this.#view = 'list';
357
+ this.#renderList(lines, cols, rows);
358
+ return;
359
+ }
360
+
361
+ const now = new Date();
362
+ const inner = cols - 4;
363
+ const isVSCode = process.env.TERM_PROGRAM === 'vscode';
364
+
365
+ // ── Header box ────────────────────────────────────────────────────
366
+ this.#renderHeader(lines, cols, now);
367
+
368
+ // ── Session detail box ────────────────────────────────────────────
369
+ lines.push(color(FG.WHITE, `╭${'─'.repeat(cols - 2)}╮`));
370
+ lines.push(
371
+ `${color(FG.WHITE, '│')} ${BOLD}${color(FG.BRIGHT_WHITE, padEnd(session.displayName, inner - 1))}${RESET} ${color(FG.WHITE, '│')}`,
372
+ );
373
+ lines.push(
374
+ `${color(FG.WHITE, '│')} ${color(FG.BRIGHT_BLACK, padEnd(session.sessionId, inner - 1))} ${color(FG.WHITE, '│')}`,
375
+ );
376
+ lines.push(
377
+ `${color(FG.WHITE, '│')}${' '.repeat(cols - 2)}${color(FG.WHITE, '│')}`,
378
+ );
379
+
380
+ const row = (label, value) =>
381
+ `${color(FG.WHITE, '│')} ${padEnd(color(FG.BRIGHT_BLACK, label), LABEL_W)}${padEnd(value, inner - 10)} ${color(FG.WHITE, '│')}`;
382
+
383
+ const cwdValue =
384
+ isVSCode && session.cwd
385
+ ? hyperlink(
386
+ `vscode://file/${session.cwd}`,
387
+ truncate(session.cwd, inner - 10),
388
+ )
389
+ : color(FG.BRIGHT_WHITE, truncate(session.cwd || '-', inner - 10));
390
+ lines.push(row('cwd', cwdValue));
391
+ lines.push(row('status', formatStatus(session.status)));
392
+ lines.push(row('updated', color(FG.BRIGHT_WHITE, session.sinceLabel)));
393
+ lines.push(
394
+ row(
395
+ 'started',
396
+ color(
397
+ FG.BRIGHT_WHITE,
398
+ session.startedAt instanceof Date
399
+ ? session.startedAt.toLocaleString()
400
+ : session.startedAt || '-',
401
+ ),
402
+ ),
403
+ );
404
+ lines.push(
405
+ `${color(FG.WHITE, '│')}${' '.repeat(cols - 2)}${color(FG.WHITE, '│')}`,
406
+ );
407
+
408
+ if (session.message) {
409
+ lines.push(
410
+ row(
411
+ 'message',
412
+ color(FG.BRIGHT_WHITE, truncate(session.message, inner - 10)),
413
+ ),
414
+ );
415
+ }
416
+
417
+ if (session.lastResponse) {
418
+ lines.push(
419
+ `${color(FG.WHITE, '│')} ${color(FG.BRIGHT_BLACK, padEnd('last response', inner - 1))} ${color(FG.WHITE, '│')}`,
420
+ );
421
+ wrapText(session.lastResponse, inner - 3).forEach(l => {
422
+ lines.push(color(FG.WHITE, `│ ${padEnd(l, inner - 2)} │`));
423
+ });
424
+ }
425
+
426
+ const subagents = this.#store
427
+ .getSubagents(session.sessionId)
428
+ .filter(s => s.status === 'working');
429
+ if (subagents.length > 0) {
430
+ lines.push(
431
+ `${color(FG.WHITE, '│')}${' '.repeat(cols - 2)}${color(FG.WHITE, '│')}`,
432
+ );
433
+ lines.push(
434
+ `${color(FG.WHITE, '│')} ${color(FG.BRIGHT_BLACK, padEnd('subagents', inner - 1))} ${color(FG.WHITE, '│')}`,
435
+ );
436
+ for (const sub of subagents) {
437
+ const subStatus = padEnd(color(FG.YELLOW, sub.status), STAT_W);
438
+ const subDescW = inner - STAT_W - 11;
439
+ const subDesc = truncate(sub.agentType || sub.agentId, subDescW);
440
+ const subTime = color(
441
+ FG.BRIGHT_BLACK,
442
+ sub.startedAt ? sub.startedAt.slice(11, 16) : '',
443
+ );
444
+ lines.push(
445
+ `${color(FG.WHITE, '│')} ${subStatus} ${padEnd(subDesc, subDescW)} ${subTime} ${color(FG.WHITE, '│')}`,
446
+ );
447
+ }
448
+ }
449
+
450
+ lines.push(color(FG.BRIGHT_WHITE, `╰${'─'.repeat(cols - 2)}╯`));
451
+
452
+ this.#renderHelpLine(lines, cols, [
453
+ ['esc/↵', 'back'],
454
+ ['q', 'quit'],
455
+ ]);
456
+ }
457
+
458
+ #flush(lines, rows) {
459
+ const limit = rows - 1;
460
+ let output = '';
461
+
462
+ for (let i = 0; i < limit; i++) {
463
+ const newLine = i < lines.length ? lines[i] : '';
464
+ if (newLine !== (this.#prevLines[i] ?? '')) {
465
+ // \x1b[R;CH — move cursor to row R (1-indexed), col 1, then write + clear to EOL
466
+ output += `\x1b[${i + 1};1H${newLine}${CLEAR_LINE}`;
467
+ this.#prevLines[i] = newLine;
468
+ }
469
+ }
470
+ this.#prevLines.length = limit;
471
+
472
+ if (output) process.stdout.write(output);
473
+ }
474
+ }
475
+
476
+ /** Build a data row with consistent column spacing (no vertical separators). */
477
+ function buildDataLine(sel, num, proj, stat, msg, time) {
478
+ return `${sel}${num} ${proj} ${stat} ${msg} ${time}`;
479
+ }
480
+
481
+ /** Word-wrap text to fit within maxWidth visible columns, preserving newlines. */
482
+ function wrapText(text, maxWidth) {
483
+ if (!text) return [];
484
+ const result = [];
485
+ for (const paragraph of text.split(/\r?\n/)) {
486
+ if (!paragraph) {
487
+ result.push('');
488
+ continue;
489
+ }
490
+ let current = '';
491
+ let currentWidth = 0;
492
+ for (const word of paragraph.split(' ')) {
493
+ const wordWidth = visibleLength(word);
494
+ const needed = currentWidth + (current ? 1 : 0) + wordWidth;
495
+ if (current && needed > maxWidth) {
496
+ result.push(current);
497
+ current = word;
498
+ currentWidth = wordWidth;
499
+ } else {
500
+ current = current ? `${current} ${word}` : word;
501
+ currentWidth = needed;
502
+ }
503
+ }
504
+ if (current) result.push(current);
505
+ }
506
+ return result;
507
+ }