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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/bin/ccw.js +29 -0
- package/hooks/session-tracker.mjs +165 -0
- package/hooks/session-tracker.sh +2 -0
- package/hooks/session-tracker.test.mjs +411 -0
- package/package.json +35 -0
- package/src/commands/help.mjs +39 -0
- package/src/commands/sessions.mjs +8 -0
- package/src/commands/setup.mjs +125 -0
- package/src/commands/sound.mjs +52 -0
- package/src/commands/start.mjs +58 -0
- package/src/commands/status.mjs +56 -0
- package/src/core/config.mjs +26 -0
- package/src/core/paths.mjs +12 -0
- package/src/core/session.mjs +107 -0
- package/src/core/store.mjs +153 -0
- package/src/input/keyboard.mjs +126 -0
- package/src/ui/ansi.mjs +164 -0
- package/src/ui/format.mjs +84 -0
- package/src/ui/layout.mjs +10 -0
- package/src/ui/renderer.mjs +507 -0
|
@@ -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,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
|
+
}
|