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/bin/triflux.mjs +40 -1
- package/hub/lib/process-utils.mjs +123 -0
- package/hub/server.mjs +48 -1
- package/hub/team/ansi.mjs +161 -19
- package/hub/team/backend.mjs +1 -1
- package/hub/team/cli/commands/start/index.mjs +3 -2
- package/hub/team/cli/commands/start/parse-args.mjs +9 -0
- package/hub/team/cli/commands/start/start-headless.mjs +6 -3
- package/hub/team/cli/help.mjs +2 -0
- package/hub/team/dashboard-layout.mjs +31 -0
- package/hub/team/headless.mjs +146 -33
- package/hub/team/psmux.mjs +174 -7
- package/hub/team/tui-viewer.mjs +354 -90
- package/hub/team/tui.mjs +856 -67
- package/package.json +1 -1
- package/scripts/remote-spawn.mjs +92 -12
- package/scripts/tfx-route.sh +17 -8
package/hub/team/tui.mjs
CHANGED
|
@@ -1,7 +1,34 @@
|
|
|
1
|
-
// hub/team/tui.mjs —
|
|
2
|
-
//
|
|
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 {
|
|
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
|
|
15
|
-
const
|
|
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
|
-
*
|
|
561
|
+
* alternate-screen diff renderer (Tier1/2/3)
|
|
19
562
|
* @param {object} [opts]
|
|
20
563
|
* @param {NodeJS.WriteStream} [opts.stream=process.stdout]
|
|
21
|
-
* @param {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
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
|
|
49
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
if (!
|
|
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
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 =
|
|
122
|
-
const nextSig =
|
|
123
|
-
merged.cli
|
|
124
|
-
merged.
|
|
125
|
-
merged.
|
|
126
|
-
merged.
|
|
127
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
};
|