triflux 8.0.0 → 8.2.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/hub/team/tui.mjs CHANGED
@@ -1,7 +1,34 @@
1
- // hub/team/tui.mjs — Append-only 로그 대시보드 (v8)
2
- // ANSI 색상은 유지하되 커서 이동/화면 덮어쓰기는 사용하지 않는다.
1
+ // hub/team/tui.mjs — Alternate-screen diff renderer (v11)
2
+ // virtual row buffer 기반. dirty-row만 갱신. isTTY 아닐 때 append-only fallback.
3
+ // Tier1(상단 고정) / Tier2(worker rail) / Tier3(focus pane) 3단 계층.
3
4
 
4
- import { RESET, FG, color, dim, STATUS_ICON } from "./ansi.mjs";
5
+ import {
6
+ RESET,
7
+ FG,
8
+ BG,
9
+ MOCHA,
10
+ color,
11
+ dim,
12
+ bold,
13
+ box,
14
+ padRight,
15
+ truncate,
16
+ clip,
17
+ stripAnsi,
18
+ wcswidth,
19
+ progressBar,
20
+ statusBadge,
21
+ STATUS_ICON,
22
+ altScreenOn,
23
+ altScreenOff,
24
+ clearScreen,
25
+ cursorHome,
26
+ cursorHide,
27
+ cursorShow,
28
+ moveTo,
29
+ clearLine,
30
+ clearToEnd,
31
+ } from "./ansi.mjs";
5
32
 
6
33
  // package.json에서 동적 로드 (실패 시 fallback)
7
34
  let VERSION = "7.x";
@@ -11,120 +38,843 @@ try {
11
38
  VERSION = require("../../package.json").version;
12
39
  } catch { /* fallback */ }
13
40
 
14
- const PREFIX_WIDTH = 48;
15
- const FALLBACK_COLUMNS = 80;
41
+ const FALLBACK_COLUMNS = 100;
42
+ const FALLBACK_ROWS = 30;
43
+ const MIN_CARD_WIDTH = 28;
16
44
 
45
+ // ✻ heartbeat — Claude Code 리버스 엔지니어링 기반 breathing animation
46
+ // 프레임: ["·","✢","✳","✶","✻","✽"] + 역재생 = 12프레임 왕복
47
+ // 타이밍: 2000ms/cycle, RGB truecolor 보간
48
+ const SPINNER_FRAMES_RAW = ["·", "✢", "✳", "✶", "✻", "✽"];
49
+ const SPINNER_FRAMES = [...SPINNER_FRAMES_RAW, ...[...SPINNER_FRAMES_RAW].reverse()];
50
+ const SPINNER_CYCLE_MS = 2000;
51
+ const SPINNER_BASE_COLOR = { r: 203, g: 166, b: 247 }; // Catppuccin Mocha mauve
52
+ const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
53
+ let spinnerStart = Date.now();
54
+ let spinnerTick = 0;
55
+
56
+ function lerpRgb(a, b, t) {
57
+ return {
58
+ r: Math.round(a.r + (b.r - a.r) * t),
59
+ g: Math.round(a.g + (b.g - a.g) * t),
60
+ b: Math.round(a.b + (b.b - a.b) * t),
61
+ };
62
+ }
63
+
64
+ function heartbeat(status, shimmerIntensity = 0) {
65
+ if (status === "done" || status === "completed") return color("✓", MOCHA.ok);
66
+ if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
67
+ if (status !== "running") return dim("○");
68
+ const elapsed = Date.now() - spinnerStart;
69
+ const idx = Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) % SPINNER_FRAMES.length;
70
+ const c = shimmerIntensity > 0
71
+ ? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
72
+ : SPINNER_BASE_COLOR;
73
+ return `\x1b[38;2;${c.r};${c.g};${c.b}m${SPINNER_FRAMES[idx]}${RESET}`;
74
+ }
75
+ const GRID_GAP = 2;
76
+ const DEFAULT_DETAIL_LINES = 10;
77
+ // Tier1 상단 고정 행 수
78
+ const TIER1_ROWS = 2;
79
+
80
+ const SUMMARY_KEYS = [
81
+ "status", "lead_action", "verdict", "files_changed",
82
+ "confidence", "risk", "detail", "error_stage", "retryable", "partial_output",
83
+ ];
84
+
85
+ // ── 레이아웃 브레이크포인트 ──────────────────────────────────────────────
86
+ // 80-119: 28col rail, 120-159: 36col rail, 160+: 균등
87
+ function resolveRailWidth(totalCols, columnCount) {
88
+ if (columnCount <= 1) return totalCols;
89
+ if (totalCols >= 160) return Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount);
90
+ if (totalCols >= 120) return Math.min(36, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
91
+ return Math.min(28, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
92
+ }
93
+
94
+ function autoColumnCount(totalCols, workerCount) {
95
+ if (workerCount <= 1) return 1;
96
+ if (totalCols >= 160) return Math.min(workerCount, 3);
97
+ if (totalCols >= 120) return Math.min(workerCount, 2);
98
+ return 1;
99
+ }
100
+
101
+ // ── 문자열 유틸 ──────────────────────────────────────────────────────────
102
+ function clamp(value, min, max) {
103
+ return Math.min(max, Math.max(min, value));
104
+ }
105
+
106
+ function stripCodeBlocks(text) {
107
+ return String(text || "")
108
+ .replace(/\r/g, "")
109
+ // fenced code blocks
110
+ .replace(/```[\s\S]*?(?:```|$)/g, "\n")
111
+ .replace(/^\s*```.*$/gm, "")
112
+ // indented code blocks (4+ spaces or tab at line start)
113
+ .replace(/^(?: |\t).+$/gm, "")
114
+ // shell prompts: PS C:\...>, >, $
115
+ .replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "")
116
+ .trim();
117
+ }
118
+
119
+ function sanitizeTextBlock(text, rawMode = false) {
120
+ const normalized = rawMode ? String(text || "").replace(/\r/g, "") : stripCodeBlocks(text);
121
+ return normalized
122
+ .split("\n")
123
+ .map((line) => line.trim())
124
+ .filter(Boolean)
125
+ .filter((line) => line !== "--- HANDOFF ---")
126
+ .join("\n")
127
+ .trim();
128
+ }
129
+
130
+ function sanitizeOneLine(text, fallback = "") {
131
+ const normalized = sanitizeTextBlock(text).replace(/\s+/g, " ").trim();
132
+ return normalized || fallback;
133
+ }
134
+
135
+ function sanitizeFiles(files) {
136
+ if (!files) return [];
137
+ const raw = Array.isArray(files) ? files : String(files).split(",");
138
+ return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
139
+ }
140
+
141
+ function sanitizeFindings(findings) {
142
+ if (!findings) return [];
143
+ const raw = Array.isArray(findings)
144
+ ? findings
145
+ : sanitizeTextBlock(findings).split("\n");
146
+ return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
147
+ }
148
+
149
+ function normalizeTokens(tokens) {
150
+ if (tokens === null || tokens === undefined) return "";
151
+ if (typeof tokens === "number" && Number.isFinite(tokens)) return tokens;
152
+ const raw = sanitizeOneLine(tokens);
153
+ if (!raw) return "";
154
+ const match = raw.match(/(\d+(?:[.,]\d+)?\s*[kKmM]?)/);
155
+ return match ? match[1].replace(/\s+/g, "").toLowerCase() : raw;
156
+ }
157
+
158
+ function formatTokens(tokens) {
159
+ if (tokens === null || tokens === undefined || tokens === "") return "n/a";
160
+ if (typeof tokens === "number" && Number.isFinite(tokens)) {
161
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
162
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
163
+ return `${tokens}`;
164
+ }
165
+ return String(tokens);
166
+ }
167
+
168
+ // ── 색상 헬퍼 ─────────────────────────────────────────────────────────────
169
+ function cliColor(cli) {
170
+ if (cli === "gemini") return FG.gemini;
171
+ if (cli === "claude") return FG.claude;
172
+ if (cli === "codex") return FG.codex;
173
+ return FG.white;
174
+ }
175
+
176
+ function runtimeStatus(st) {
177
+ return st?.handoff?.status || st?.status || "pending";
178
+ }
179
+
180
+ function statusColor(status) {
181
+ if (status === "ok" || status === "completed") return MOCHA.ok;
182
+ if (status === "partial") return MOCHA.partial;
183
+ if (status === "failed") return MOCHA.fail;
184
+ if (status === "running" || status === "in_progress") return MOCHA.executing;
185
+ return FG.muted;
186
+ }
187
+
188
+ // ── MOCHA RGB (gradual fade 보간용) ──
189
+ const MOCHA_RGB = {
190
+ ok: { r: 166, g: 227, b: 161 },
191
+ partial: { r: 250, g: 179, b: 135 },
192
+ fail: { r: 243, g: 139, b: 168 },
193
+ executing: { r: 116, g: 199, b: 236 },
194
+ muted: { r: 147, g: 153, b: 178 },
195
+ border: { r: 69, g: 71, b: 90 },
196
+ };
197
+
198
+ function statusToRgb(status) {
199
+ if (status === "ok" || status === "completed") return MOCHA_RGB.ok;
200
+ if (status === "partial") return MOCHA_RGB.partial;
201
+ if (status === "failed") return MOCHA_RGB.fail;
202
+ if (status === "running" || status === "in_progress") return MOCHA_RGB.executing;
203
+ return MOCHA_RGB.muted;
204
+ }
205
+
206
+ const FADE_DURATION_MS = 1500;
207
+
208
+ function fadeBorderColor(currentStatus, prevStatus, changedAt) {
209
+ const elapsed = Date.now() - (changedAt || 0);
210
+ if (elapsed >= FADE_DURATION_MS || !prevStatus) return MOCHA.border;
211
+ const t = Math.min(1, elapsed / FADE_DURATION_MS);
212
+ const from = statusToRgb(currentStatus);
213
+ const to = MOCHA_RGB.border;
214
+ const c = lerpRgb(from, to, t);
215
+ return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
216
+ }
217
+
218
+ // ── 텍스트 래핑 ──────────────────────────────────────────────────────────
219
+ function wrapLine(text, width) {
220
+ const limit = Math.max(8, width);
221
+ const source = String(text || "").trim();
222
+ if (!source) return [""];
223
+ const words = source.split(/\s+/);
224
+ const lines = [];
225
+ let current = "";
226
+ for (const word of words) {
227
+ const candidate = current ? `${current} ${word}` : word;
228
+ if (wcswidth(candidate) <= limit) { current = candidate; continue; }
229
+ if (current) { lines.push(current); current = ""; }
230
+ if (wcswidth(word) <= limit) { current = word; continue; }
231
+ let offset = 0;
232
+ while (offset < word.length) {
233
+ lines.push(word.slice(offset, offset + limit));
234
+ offset += limit;
235
+ }
236
+ }
237
+ if (current) lines.push(current);
238
+ return lines.length > 0 ? lines : [source.slice(0, limit)];
239
+ }
240
+
241
+ function wrapText(text, width, maxLines = DEFAULT_DETAIL_LINES, rawMode = false) {
242
+ if (maxLines <= 0) return [];
243
+ const input = sanitizeTextBlock(text, rawMode);
244
+ if (!input) return [];
245
+ const wrapped = input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
246
+ if (wrapped.length <= maxLines) return wrapped;
247
+ return [...wrapped.slice(0, maxLines - 1), truncate(wrapped[wrapped.length - 1], width)];
248
+ }
249
+
250
+ // 스크롤 없이 전체 줄 반환 (focus pane용)
251
+ function wrapTextAll(text, width, rawMode = false) {
252
+ const input = sanitizeTextBlock(text, rawMode);
253
+ if (!input) return [];
254
+ return input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
255
+ }
256
+
257
+ // ── virtual row buffer ────────────────────────────────────────────────────
258
+ class RowBuffer {
259
+ constructor() {
260
+ this._rows = [];
261
+ this._prev = [];
262
+ }
263
+
264
+ set(rows) {
265
+ this._rows = rows.map(String);
266
+ }
267
+
268
+ /** 변경된 row 인덱스 목록 반환 */
269
+ diff() {
270
+ const dirty = [];
271
+ const len = Math.max(this._rows.length, this._prev.length);
272
+ for (let i = 0; i < len; i++) {
273
+ if (this._rows[i] !== this._prev[i]) dirty.push(i);
274
+ }
275
+ return dirty;
276
+ }
277
+
278
+ commit() {
279
+ this._prev = [...this._rows];
280
+ }
281
+
282
+ get rows() { return this._rows; }
283
+ get prevLen() { return this._prev.length; }
284
+ }
285
+
286
+ // ── 상태 집계 ─────────────────────────────────────────────────────────────
287
+ function countStatuses(names, workers) {
288
+ let ok = 0, partial = 0, failed = 0, running = 0;
289
+ for (const name of names) {
290
+ const st = workers.get(name);
291
+ const s = runtimeStatus(st);
292
+ if (s === "ok" || s === "completed") ok++;
293
+ else if (s === "partial") partial++;
294
+ else if (s === "failed") failed++;
295
+ else if (s === "running" || s === "in_progress") running++;
296
+ }
297
+ return { ok, partial, failed, running };
298
+ }
299
+
300
+ // ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
301
+ function phaseColor(phase) {
302
+ if (phase === "exec" || phase === "executing") return MOCHA.blue;
303
+ if (phase === "verify" || phase === "verifying") return MOCHA.yellow;
304
+ if (phase === "fix" || phase === "fixing") return MOCHA.red;
305
+ return FG.accent;
306
+ }
307
+
308
+ function buildTier1(names, workers, pipeline, elapsed, width, version) {
309
+ const { ok, partial, failed, running } = countStatuses(names, workers);
310
+ const phase = pipeline.phase || "exec";
311
+ const row1 = truncate(
312
+ `${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
313
+ `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)} ${color("Tab/j/k:nav • f:follow • r:raw • 1-9:jump", MOCHA.subtext)}`,
314
+ width,
315
+ );
316
+ return [row1];
317
+ }
318
+
319
+ // ── 카드 렌더러 (Tier2 worker rail) ─────────────────────────────────────
320
+ function detailText(st) {
321
+ if (st.detail) return st.detail;
322
+ const lines = [];
323
+ for (const key of SUMMARY_KEYS) {
324
+ const value = st.handoff?.[key];
325
+ if (Array.isArray(value) && value.length > 0) lines.push(`${key}: ${value.join(", ")}`);
326
+ else if (value) lines.push(`${key}: ${value}`);
327
+ }
328
+ if (st.snapshot) lines.unshift(st.snapshot);
329
+ return lines.join("\n");
330
+ }
331
+
332
+ function detailHighlights(st) {
333
+ if (Array.isArray(st.findings) && st.findings.length > 0) return st.findings;
334
+ const verdict = sanitizeOneLine(st.handoff?.verdict);
335
+ return sanitizeTextBlock(detailText(st))
336
+ .split("\n")
337
+ .map((line) => line.replace(/^verdict\s*:\s*/i, "").trim())
338
+ .filter(Boolean)
339
+ .filter((line) => line !== verdict)
340
+ .filter((line) => !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)))
341
+ .slice(0, 2);
342
+ }
343
+
344
+ function buildWorkerRail(name, st, opts = {}) {
345
+ const {
346
+ width,
347
+ selected = false,
348
+ focused = false, // rail 포커스 여부
349
+ rawMode = false,
350
+ compact = false,
351
+ } = opts;
352
+ const innerWidth = Math.max(12, width - 4);
353
+ const cli = st.cli || "codex";
354
+ const role = sanitizeOneLine(st.role);
355
+ const status = runtimeStatus(st);
356
+ const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
357
+
358
+ // Tier2 행 1: 이름 + CLI + role
359
+ const selMark = selected ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux)) : " ";
360
+ const hb = heartbeat(status);
361
+ const title = truncate(
362
+ `${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${role ? ` ${color(`(${role})`, MOCHA.overlay)}` : ""}`,
363
+ innerWidth,
364
+ );
365
+
366
+ // status-specific border: running→blue, completed→green, failed→red, partial→yellow
367
+ const statusBorderColor = (() => {
368
+ if (focused) return MOCHA.thinking;
369
+ if (selected) {
370
+ if (status === "running" || status === "in_progress") return MOCHA.blue;
371
+ if (status === "ok" || status === "completed") return MOCHA.ok;
372
+ if (status === "failed") return MOCHA.fail;
373
+ if (status === "partial") return MOCHA.yellow;
374
+ }
375
+ return fadeBorderColor(status, st._prevStatus, st._statusChangedAt);
376
+ })();
377
+
378
+ if (compact) {
379
+ // compact 2-line 카드
380
+ const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
381
+ const percent = Math.round(progress * 100);
382
+ const compactLine1 = truncate(
383
+ `${selMark} ${hb} ${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
384
+ innerWidth,
385
+ );
386
+ const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
387
+ const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
388
+ const framed = box([compactLine1, compactLine2], Math.max(MIN_CARD_WIDTH, width), statusBorderColor);
389
+ return [framed.top, ...framed.body, framed.bot];
390
+ }
391
+
392
+ // Tier2 행 2: 상태 배지 + elapsed + tokens + conf
393
+ const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
394
+ const statusLine = truncate(
395
+ `${statusBadge(status)} ${color("•", MOCHA.overlay)} ${color(`${sec}s`, MOCHA.subtext)} ${color("•", MOCHA.overlay)} ${color(`tok ${formatTokens(st.tokens)}`, MOCHA.subtext)} ${color("•", MOCHA.overlay)} ${color(`conf ${confidence}`, MOCHA.subtext)}`,
396
+ innerWidth,
397
+ );
398
+
399
+ // Tier2 행 3: progress bar
400
+ const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
401
+ const percent = Math.round(progress * 100);
402
+ const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
403
+ const progressLine = truncate(
404
+ `${progressBar(percent, barWidth)} ${color(`${String(percent).padStart(3)}%`, MOCHA.text)}`,
405
+ innerWidth,
406
+ );
407
+
408
+ // Tier2 행 4-6: verdict / findings / files
409
+ const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
410
+ const findings = detailHighlights(st).join(" / ") || "no notable findings yet";
411
+ const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") || "none";
412
+
413
+ const verdictClr = statusColor(status);
414
+ const lines = [
415
+ title,
416
+ statusLine,
417
+ progressLine,
418
+ truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`, innerWidth),
419
+ truncate(`${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`, innerWidth),
420
+ truncate(`${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`, innerWidth),
421
+ ];
422
+
423
+ const framed = box(lines, Math.max(MIN_CARD_WIDTH, width), statusBorderColor);
424
+ return [framed.top, ...framed.body, framed.bot];
425
+ }
426
+
427
+ // ── Tier3: focus pane (우측 detail) ─────────────────────────────────────
428
+ function buildFocusPane(name, st, opts = {}) {
429
+ const {
430
+ width,
431
+ height = 20,
432
+ scrollOffset = 0,
433
+ followTail = false,
434
+ rawMode = false,
435
+ focused = false,
436
+ } = opts;
437
+ const innerWidth = Math.max(12, width - 4);
438
+
439
+ // verdict sticky 4행
440
+ const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, "—");
441
+ const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
442
+ const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
443
+ const status = runtimeStatus(st);
444
+
445
+ // Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
446
+ const tabLog = `${MOCHA.blue}${bold("[Log]")}`;
447
+ const tabDetail = color("[Detail]", MOCHA.overlay);
448
+ const tabFiles = color(`[Files ${files.length}]`, MOCHA.overlay);
449
+ const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
450
+
451
+ const stickyLines = [
452
+ truncate(`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`, innerWidth),
453
+ tabBar,
454
+ truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`, innerWidth),
455
+ truncate(`${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`, innerWidth),
456
+ color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
457
+ ];
458
+
459
+ // 본문 스크롤 영역
460
+ const bodyAvail = Math.max(0, height - stickyLines.length - 2); // top+bot border
461
+ const allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
462
+
463
+ let startIdx;
464
+ if (followTail) {
465
+ startIdx = Math.max(0, allBodyLines.length - bodyAvail);
466
+ } else {
467
+ startIdx = clamp(scrollOffset, 0, Math.max(0, allBodyLines.length - bodyAvail));
468
+ }
469
+
470
+ const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
471
+ if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
472
+
473
+ // scroll indicator — MOCHA.overlay for position
474
+ const scrollInfo = allBodyLines.length > bodyAvail
475
+ ? color(`${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`, MOCHA.overlay)
476
+ : color(`${allBodyLines.length} lines`, MOCHA.overlay);
477
+
478
+ const contentLines = [
479
+ ...stickyLines,
480
+ ...bodySlice.map((l) => truncate(l, innerWidth)),
481
+ truncate(scrollInfo, innerWidth),
482
+ ];
483
+
484
+ // focused pane gets bright border, unfocused gets dim
485
+ const borderColor = focused ? MOCHA.blue : MOCHA.border;
486
+ const framed = box(contentLines, Math.max(MIN_CARD_WIDTH, width), borderColor);
487
+ return [framed.top, ...framed.body, framed.bot];
488
+ }
489
+
490
+ // ── summary bar (≥4 workers) ──────────────────────────────────────────────
491
+ function buildSummaryBar(names, workers, selectedWorker, pipeline, width, version) {
492
+ const maxChipWidth = clamp(Math.floor((width - 6) / Math.min(names.length, 4)), 16, 26);
493
+ const chips = names.map((name, idx) => {
494
+ const st = workers.get(name);
495
+ const status = runtimeStatus(st);
496
+ const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
497
+ const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
498
+ return padRight(truncate(label, maxChipWidth), maxChipWidth);
499
+ });
500
+ const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
501
+ const keysLine = truncate(color("Tab:focus • j/k:scroll • f:follow • r:raw • 1-9:jump", MOCHA.subtext), width - 4);
502
+ const framed = box([chipsLine, keysLine], width);
503
+ return [framed.top, ...framed.body, framed.bot];
504
+ }
505
+
506
+ // ── joinColumns ───────────────────────────────────────────────────────────
507
+ function joinColumns(blocks, gap = GRID_GAP) {
508
+ const maxHeight = Math.max(...blocks.map((b) => b.length));
509
+ return Array.from({ length: maxHeight }, (_, rowIdx) =>
510
+ blocks
511
+ .map((block) => block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))))
512
+ .join(" ".repeat(gap)),
513
+ );
514
+ }
515
+
516
+ // ── normalizeWorkerState ──────────────────────────────────────────────────
517
+ function normalizeWorkerState(existing, state) {
518
+ const nextHandoff = state.handoff === undefined
519
+ ? existing.handoff
520
+ : {
521
+ ...(existing.handoff || {}),
522
+ ...(state.handoff || {}),
523
+ verdict: state.handoff?.verdict !== undefined
524
+ ? sanitizeOneLine(state.handoff.verdict)
525
+ : existing.handoff?.verdict,
526
+ files_changed: state.handoff?.files_changed !== undefined
527
+ ? sanitizeFiles(state.handoff.files_changed)
528
+ : existing.handoff?.files_changed,
529
+ confidence: state.handoff?.confidence !== undefined
530
+ ? sanitizeOneLine(state.handoff.confidence)
531
+ : existing.handoff?.confidence,
532
+ status: state.handoff?.status !== undefined
533
+ ? sanitizeOneLine(state.handoff.status)
534
+ : existing.handoff?.status,
535
+ };
536
+
537
+ return {
538
+ ...existing,
539
+ ...state,
540
+ cli: state.cli !== undefined ? sanitizeOneLine(state.cli, existing.cli || "codex") : (existing.cli || "codex"),
541
+ role: state.role !== undefined ? sanitizeOneLine(state.role) : existing.role,
542
+ status: state.status !== undefined ? sanitizeOneLine(state.status, existing.status || "pending") : (existing.status || "pending"),
543
+ snapshot: state.snapshot !== undefined ? sanitizeTextBlock(state.snapshot) : existing.snapshot,
544
+ summary: state.summary !== undefined ? sanitizeTextBlock(state.summary) : existing.summary,
545
+ detail: state.detail !== undefined ? sanitizeTextBlock(state.detail) : existing.detail,
546
+ findings: state.findings !== undefined ? sanitizeFindings(state.findings) : existing.findings,
547
+ files_changed: state.files_changed !== undefined ? sanitizeFiles(state.files_changed) : existing.files_changed,
548
+ confidence: state.confidence !== undefined ? sanitizeOneLine(state.confidence) : existing.confidence,
549
+ tokens: state.tokens !== undefined ? normalizeTokens(state.tokens) : existing.tokens,
550
+ progress: state.progress !== undefined ? clamp(Number(state.progress) || 0, 0, 1) : existing.progress,
551
+ handoff: nextHandoff,
552
+ _prevStatus: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
553
+ ? existing.status : existing._prevStatus,
554
+ _statusChangedAt: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
555
+ ? Date.now() : (existing._statusChangedAt || 0),
556
+ };
557
+ }
558
+
559
+ // ── createLogDashboard ────────────────────────────────────────────────────
17
560
  /**
18
- * 로그 스트림 대시보드 생성 (append-only)
561
+ * alternate-screen diff renderer (Tier1/2/3)
19
562
  * @param {object} [opts]
20
563
  * @param {NodeJS.WriteStream} [opts.stream=process.stdout]
21
- * @param {number} [opts.refreshMs=1000] — 자동 렌더 주기 (0=수동만)
564
+ * @param {NodeJS.ReadStream} [opts.input=process.stdin]
565
+ * @param {number} [opts.refreshMs=1000]
566
+ * @param {number} [opts.columns] — 터미널 폭 override (테스트/뷰어용)
567
+ * @param {string} [opts.layout] — "single"|"split-2col"|"split-3col"|"summary+detail"|"auto"
22
568
  * @returns {LogDashboardHandle}
23
569
  */
24
570
  export function createLogDashboard(opts = {}) {
25
571
  const {
26
572
  stream = process.stdout,
573
+ input = process.stdin,
27
574
  refreshMs = 1000,
575
+ columns,
576
+ layout: layoutHint = "auto",
577
+ forceTTY = false,
28
578
  } = opts;
29
579
 
580
+ const isTTY = forceTTY || !!stream?.isTTY;
581
+
30
582
  const workers = new Map();
31
583
  let pipeline = { phase: "exec", fix_attempt: 0 };
32
584
  let startedAt = Date.now();
33
585
  let timer = null;
34
586
  let closed = false;
35
587
  let frameCount = 0;
36
- const lastLineByWorker = new Map();
588
+ let selectedWorker = null;
589
+ // focus: "rail" | "detail"
590
+ let focus = "rail";
591
+ let detailScrollOffset = 0;
592
+ let followTail = false;
593
+ let rawMode = false;
594
+ let inputAttached = false;
595
+ let rawModeEnabled = false;
37
596
 
38
- function out(text) { if (!closed) stream.write(`${text}\n`); }
597
+ // virtual row buffer (altScreen 전용)
598
+ const rowBuf = new RowBuffer();
599
+
600
+ // ── TTY 출력 헬퍼 ────────────────────────────────────────────────────
601
+ function write(text) {
602
+ if (!closed) stream.write(text);
603
+ }
604
+
605
+ function writeln(text) {
606
+ if (!closed) stream.write(`${text}\n`);
607
+ }
39
608
 
40
609
  function nowElapsedSec() {
41
610
  return Math.max(0, Math.round((Date.now() - startedAt) / 1000));
42
611
  }
43
612
 
44
- function elapsedLabel(sec) {
45
- return dim(`[${sec}s]`);
613
+ function getViewportColumns() {
614
+ const v = Number.isFinite(columns)
615
+ ? columns
616
+ : (Number.isFinite(stream?.columns)
617
+ ? stream.columns
618
+ : (Number.isFinite(process.stdout?.columns) ? process.stdout.columns : FALLBACK_COLUMNS));
619
+ return Math.max(48, v || FALLBACK_COLUMNS);
620
+ }
621
+
622
+ function getViewportRows() {
623
+ const v = Number.isFinite(stream?.rows)
624
+ ? stream.rows
625
+ : (Number.isFinite(process.stdout?.rows) ? process.stdout.rows : FALLBACK_ROWS);
626
+ return Math.max(10, v || FALLBACK_ROWS);
46
627
  }
47
628
 
48
- function getMessageWidth() {
49
- const columns = Number.isFinite(process.stdout.columns)
50
- ? process.stdout.columns
51
- : FALLBACK_COLUMNS;
52
- return Math.max(3, columns - PREFIX_WIDTH);
629
+ function visibleWorkerNames() {
630
+ return [...workers.keys()].sort();
53
631
  }
54
632
 
55
- function oneLine(text, fallback = "n/a") {
56
- const normalized = String(text || "").replace(/\s+/g, " ").trim();
57
- if (!normalized) return fallback;
58
- const max = getMessageWidth();
59
- return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized;
633
+ function ensureSelectedWorker(names) {
634
+ if (names.length === 0) { selectedWorker = null; return; }
635
+ if (!selectedWorker || !workers.has(selectedWorker)) selectedWorker = names[0];
60
636
  }
61
637
 
62
- function cliColor(cli) {
63
- if (cli === "gemini") return FG.gemini;
64
- if (cli === "claude") return FG.claude;
65
- if (cli === "codex") return FG.codex;
66
- return FG.white;
638
+ function selectRelative(offset) {
639
+ const names = visibleWorkerNames();
640
+ if (names.length === 0) return;
641
+ ensureSelectedWorker(names);
642
+ const idx = Math.max(0, names.indexOf(selectedWorker));
643
+ selectedWorker = names[(idx + offset + names.length) % names.length];
644
+ detailScrollOffset = 0;
645
+ render();
67
646
  }
68
647
 
69
- function statusLabel(status) {
70
- if (status === "completed") return color("completed", FG.green);
71
- if (status === "failed") return color("failed", FG.red);
72
- if (status === "running") return color("running", FG.blue);
73
- return dim(status || "pending");
648
+ function scrollDetail(delta) {
649
+ followTail = false;
650
+ detailScrollOffset = Math.max(0, detailScrollOffset + delta);
651
+ render();
74
652
  }
75
653
 
76
- function messageLabel(st) {
77
- if (st.handoff?.verdict) return oneLine(st.handoff.verdict, "completed");
78
- if (st.snapshot) return oneLine(st.snapshot, st.status || "running");
79
- if (st.status === "failed" && st.handoff?.lead_action) {
80
- return oneLine(`action=${st.handoff.lead_action}`, "failed");
654
+ // ── 키 입력 ──────────────────────────────────────────────────────────
655
+ function handleInput(chunk) {
656
+ const key = String(chunk);
657
+ if (key === "\u0003") return; // Ctrl-C
658
+
659
+ // Tab: rail ↔ detail 포커스 전환
660
+ if (key === "\t") {
661
+ focus = focus === "rail" ? "detail" : "rail";
662
+ render();
663
+ return;
81
664
  }
82
- return st.status || "pending";
665
+
666
+ // Shift+Arrow: 포커스 이동 + 워커 선택
667
+ if (key === "\x1b[1;2A") { selectRelative(-1); return; } // Shift+Up → 워커 위
668
+ if (key === "\x1b[1;2B") { selectRelative(1); return; } // Shift+Down → 워커 아래
669
+ if (key === "\x1b[1;2D") { focus = "rail"; render(); return; } // Shift+Left → rail
670
+ if (key === "\x1b[1;2C") { focus = "detail"; render(); return; } // Shift+Right → detail
671
+
672
+ if (focus === "detail") {
673
+ // detail 포커스: j/k/ArrowDown/Up = 스크롤
674
+ if (key === "j" || key === "\u001b[B") { scrollDetail(1); return; }
675
+ if (key === "k" || key === "\u001b[A") { scrollDetail(-1); return; }
676
+ } else {
677
+ // rail 포커스: j/k = 워커 선택
678
+ if (key === "j" || key === "\u001b[B") { selectRelative(1); return; }
679
+ if (key === "k" || key === "\u001b[A") { selectRelative(-1); return; }
680
+ }
681
+
682
+ // f: follow-tail 토글
683
+ if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
684
+ // r: raw mode 토글
685
+ if (key === "r") { rawMode = !rawMode; render(); return; }
686
+ // 1-9: 워커 직접 선택
687
+ if (/^[1-9]$/.test(key)) {
688
+ const names = visibleWorkerNames();
689
+ const target = names[Number.parseInt(key, 10) - 1];
690
+ if (target) { selectedWorker = target; detailScrollOffset = 0; render(); }
691
+ return;
692
+ }
693
+ }
694
+
695
+ function attachInput() {
696
+ if (inputAttached) return;
697
+ if (!isTTY || (!forceTTY && !input?.isTTY) || typeof input?.on !== "function") return;
698
+ inputAttached = true;
699
+ if (typeof input.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
700
+ if (typeof input.resume === "function") input.resume();
701
+ input.on("data", handleInput);
702
+ }
703
+
704
+ // ── altScreen 진입/퇴장 ───────────────────────────────────────────────
705
+ function enterAltScreen() {
706
+ if (!isTTY) return;
707
+ write(altScreenOn + cursorHide + clearScreen + cursorHome);
83
708
  }
84
709
 
85
- function workerLine(name, st) {
86
- const status = st.status || "pending";
87
- const icon = STATUS_ICON[status] || STATUS_ICON.pending;
88
- const cli = st.cli || "codex";
89
- const cliLabel = `${cliColor(cli)}${cli}${RESET}`;
90
- const workerLabel = color(name, FG.triflux);
91
- const statusText = statusLabel(status);
92
- const message = messageLabel(st);
93
- const sec = Number.isFinite(st._logSec) ? st._logSec : nowElapsedSec();
94
- return `${elapsedLabel(sec)} ${icon} ${workerLabel} (${cliLabel}) ${statusText} ${dim("—")} ${message}`;
710
+ function exitAltScreen() {
711
+ if (!isTTY) return;
712
+ write(cursorShow + altScreenOff);
95
713
  }
96
714
 
97
- // 현재 상태와 마지막으로 출력한 라인을 비교해 변경분만 append
715
+ // ── 프레임 빌드 ───────────────────────────────────────────────────────
716
+ function buildRows() {
717
+ const names = visibleWorkerNames();
718
+ if (names.length === 0) return [];
719
+
720
+ ensureSelectedWorker(names);
721
+ attachInput();
722
+
723
+ const totalCols = getViewportColumns();
724
+ const totalRows = getViewportRows();
725
+ const elapsed = nowElapsedSec();
726
+
727
+ // Tier1: 상단 고정 2행
728
+ const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION);
729
+
730
+ // 레이아웃 결정
731
+ let effectiveLayout = layoutHint;
732
+ if (effectiveLayout === "auto") {
733
+ if (names.length >= 4) effectiveLayout = "summary+detail";
734
+ else if (names.length === 3) effectiveLayout = "split-3col";
735
+ else if (names.length === 2) effectiveLayout = "split-2col";
736
+ else effectiveLayout = "single";
737
+ }
738
+
739
+ // summary+detail: summaryBar + focus pane
740
+ if (effectiveLayout === "summary+detail") {
741
+ const summaryBar = buildSummaryBar(names, workers, selectedWorker, pipeline, totalCols, VERSION);
742
+ const selectedState = workers.get(selectedWorker);
743
+ const focusPaneHeight = Math.max(8, totalRows - tier1.length - summaryBar.length);
744
+ const focusPane = buildFocusPane(selectedWorker, selectedState, {
745
+ width: totalCols,
746
+ height: focusPaneHeight,
747
+ scrollOffset: detailScrollOffset,
748
+ followTail,
749
+ rawMode,
750
+ focused: focus === "detail",
751
+ });
752
+ return [...tier1, ...summaryBar, ...focusPane];
753
+ }
754
+
755
+ // 좌우 분할: Left Rail (30%) | Right Focus (70%)
756
+ // 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
757
+ const GAP = 1; // rail과 focus 사이 구분선
758
+ const railWidth = Math.max(MIN_CARD_WIDTH, Math.floor(totalCols * 0.25));
759
+ const focusWidth = totalCols - railWidth - GAP;
760
+ const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
761
+
762
+ // compact 자동 적용: viewport 행이 20 미만이면 2-line 카드
763
+ const useCompact = totalRows < 20;
764
+
765
+ // Left Rail: 워커 카드 세로 스택
766
+ const railLines = [];
767
+ for (const name of names) {
768
+ const card = buildWorkerRail(name, workers.get(name), {
769
+ width: railWidth - 2, // box 테두리 감안
770
+ selected: name === selectedWorker,
771
+ focused: focus === "rail" && name === selectedWorker,
772
+ rawMode,
773
+ compact: useCompact,
774
+ });
775
+ railLines.push(...card);
776
+ }
777
+ // rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
778
+ while (railLines.length < bodyHeight) railLines.push(padRight("", railWidth));
779
+ if (railLines.length > bodyHeight) railLines.length = bodyHeight;
780
+
781
+ // Right Focus: 선택된 워커 상세
782
+ let focusLines = [];
783
+ if (selectedWorker && workers.has(selectedWorker)) {
784
+ focusLines = buildFocusPane(selectedWorker, workers.get(selectedWorker), {
785
+ width: focusWidth,
786
+ height: bodyHeight,
787
+ scrollOffset: detailScrollOffset,
788
+ followTail,
789
+ rawMode,
790
+ focused: focus === "detail",
791
+ });
792
+ }
793
+ while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
794
+ if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
795
+
796
+ // 좌우 합성: rail[i] + separator + focus[i]
797
+ const separator = dim("│");
798
+ const composedRows = [];
799
+ for (let i = 0; i < bodyHeight; i++) {
800
+ const left = clip(railLines[i] || "", railWidth);
801
+ const right = focusLines[i] || "";
802
+ composedRows.push(`${left}${separator}${right}`);
803
+ }
804
+
805
+ // 하단 상태바
806
+ const statusBar = truncate(
807
+ color(` 세션 종료됨 — 아무 키나 누르면 닫힘`, MOCHA.subtext),
808
+ totalCols,
809
+ );
810
+
811
+ return [...tier1, ...composedRows, statusBar];
812
+ }
813
+
814
+ // ── altScreen diff render ─────────────────────────────────────────────
815
+ function renderAltScreen() {
816
+ const newRows = buildRows();
817
+ rowBuf.set(newRows);
818
+ const dirty = rowBuf.diff();
819
+ const prevLen = rowBuf.prevLen;
820
+
821
+ if (dirty.length === 0 && newRows.length === prevLen) return;
822
+
823
+ const toErase = prevLen > newRows.length
824
+ ? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
825
+ : [];
826
+
827
+ for (const i of dirty) {
828
+ write(moveTo(i + 1, 1) + clearLine + (newRows[i] || ""));
829
+ }
830
+ for (const i of toErase) {
831
+ write(moveTo(i + 1, 1) + clearLine);
832
+ }
833
+
834
+ rowBuf.commit();
835
+ }
836
+
837
+ // ── append-only render (non-TTY fallback) ────────────────────────────
838
+ function renderAppendOnly() {
839
+ const newRows = buildRows();
840
+ if (newRows.length === 0) return;
841
+ writeln(newRows.join("\n"));
842
+ }
843
+
844
+ // ── public render ─────────────────────────────────────────────────────
98
845
  function render() {
99
846
  if (closed) return;
100
847
  frameCount++;
101
-
102
- const names = [...workers.keys()].sort();
103
- for (const name of names) {
104
- const st = workers.get(name);
105
- const line = workerLine(name, st);
106
- if (line !== lastLineByWorker.get(name)) {
107
- lastLineByWorker.set(name, line);
108
- out(line);
109
- }
848
+ spinnerTick++;
849
+ if (isTTY) {
850
+ renderAltScreen();
851
+ } else {
852
+ renderAppendOnly();
110
853
  }
111
854
  }
112
855
 
856
+ // altScreen 시작
857
+ if (isTTY) {
858
+ enterAltScreen();
859
+ }
860
+
113
861
  if (refreshMs > 0) {
114
862
  timer = setInterval(render, refreshMs);
115
863
  if (timer.unref) timer.unref();
116
864
  }
117
865
 
866
+ // ── 공개 API ─────────────────────────────────────────────────────────
118
867
  return {
119
868
  updateWorker(paneName, state) {
120
869
  const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
121
- const merged = { ...existing, ...state };
122
- const nextSig = [
123
- merged.cli || "",
124
- merged.status || "",
125
- merged.snapshot || "",
126
- merged.handoff?.verdict || "",
127
- ].join("|");
870
+ const merged = normalizeWorkerState(existing, state);
871
+ const nextSig = JSON.stringify({
872
+ cli: merged.cli, status: merged.status, role: merged.role,
873
+ snapshot: merged.snapshot, summary: merged.summary, detail: merged.detail,
874
+ findings: merged.findings, files_changed: merged.files_changed,
875
+ confidence: merged.confidence, tokens: merged.tokens,
876
+ progress: merged.progress, handoff: merged.handoff,
877
+ });
128
878
  const sigChanged = nextSig !== existing._sig;
129
879
  const explicitElapsed = Number.isFinite(state.elapsed) ? Math.max(0, Math.round(state.elapsed)) : null;
130
880
  merged._sig = nextSig;
@@ -132,20 +882,59 @@ export function createLogDashboard(opts = {}) {
132
882
  ? (explicitElapsed ?? nowElapsedSec())
133
883
  : (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
134
884
  workers.set(paneName, merged);
885
+ ensureSelectedWorker(visibleWorkerNames());
886
+ // follow-tail: 새 데이터 → 자동 scroll 재계산
887
+ if (followTail) detailScrollOffset = 0;
135
888
  },
889
+
136
890
  updatePipeline(state) {
137
891
  pipeline = { ...pipeline, ...state };
138
892
  },
893
+
139
894
  setStartTime(ms) {
140
895
  startedAt = ms;
141
896
  },
897
+
898
+ selectWorker(name) {
899
+ if (!workers.has(name)) return;
900
+ selectedWorker = name;
901
+ },
902
+
903
+ toggleDetail(force) {
904
+ // 하위 호환: toggleDetail = focus pane 표시 여부
905
+ const next = typeof force === "boolean" ? force : focus !== "detail";
906
+ focus = next ? "detail" : "rail";
907
+ },
908
+
142
909
  render,
143
- getWorkers() { return new Map(workers); },
144
- getFrameCount() { return frameCount; },
145
- getPipelineState() { return { ...pipeline }; },
910
+
911
+ getWorkers() {
912
+ return new Map(workers);
913
+ },
914
+
915
+ getFrameCount() {
916
+ return frameCount;
917
+ },
918
+
919
+ getPipelineState() {
920
+ return { ...pipeline };
921
+ },
922
+
923
+ getSelectedWorker() {
924
+ return selectedWorker;
925
+ },
926
+
927
+ isDetailExpanded() {
928
+ return focus === "detail";
929
+ },
930
+
146
931
  close() {
147
932
  if (closed) return;
148
933
  if (timer) clearInterval(timer);
934
+ if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
935
+ if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
936
+ if (inputAttached && typeof input?.pause === "function") input.pause();
937
+ exitAltScreen();
149
938
  closed = true;
150
939
  },
151
940
  };