@triflux/remote 10.0.0-alpha.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/hub/pipe.mjs +579 -0
- package/hub/public/dashboard.html +355 -0
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/server.mjs +1124 -0
- package/hub/store-adapter.mjs +851 -0
- package/hub/store.mjs +897 -0
- package/hub/team/agent-map.json +11 -0
- package/hub/team/ansi.mjs +379 -0
- package/hub/team/backend.mjs +90 -0
- package/hub/team/cli/commands/attach.mjs +37 -0
- package/hub/team/cli/commands/control.mjs +43 -0
- package/hub/team/cli/commands/debug.mjs +74 -0
- package/hub/team/cli/commands/focus.mjs +53 -0
- package/hub/team/cli/commands/interrupt.mjs +36 -0
- package/hub/team/cli/commands/kill.mjs +37 -0
- package/hub/team/cli/commands/list.mjs +24 -0
- package/hub/team/cli/commands/send.mjs +37 -0
- package/hub/team/cli/commands/start/index.mjs +106 -0
- package/hub/team/cli/commands/start/parse-args.mjs +130 -0
- package/hub/team/cli/commands/start/start-headless.mjs +109 -0
- package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
- package/hub/team/cli/commands/start/start-mux.mjs +73 -0
- package/hub/team/cli/commands/start/start-wt.mjs +69 -0
- package/hub/team/cli/commands/status.mjs +87 -0
- package/hub/team/cli/commands/stop.mjs +31 -0
- package/hub/team/cli/commands/task.mjs +30 -0
- package/hub/team/cli/commands/tasks.mjs +13 -0
- package/hub/team/cli/help.mjs +42 -0
- package/hub/team/cli/index.mjs +41 -0
- package/hub/team/cli/manifest.mjs +29 -0
- package/hub/team/cli/render.mjs +30 -0
- package/hub/team/cli/services/attach-fallback.mjs +54 -0
- package/hub/team/cli/services/hub-client.mjs +208 -0
- package/hub/team/cli/services/member-selector.mjs +30 -0
- package/hub/team/cli/services/native-control.mjs +117 -0
- package/hub/team/cli/services/runtime-mode.mjs +62 -0
- package/hub/team/cli/services/state-store.mjs +48 -0
- package/hub/team/cli/services/task-model.mjs +30 -0
- package/hub/team/dashboard-anchor.mjs +14 -0
- package/hub/team/dashboard-layout.mjs +33 -0
- package/hub/team/dashboard-open.mjs +153 -0
- package/hub/team/dashboard.mjs +274 -0
- package/hub/team/handoff.mjs +303 -0
- package/hub/team/headless.mjs +1149 -0
- package/hub/team/native-supervisor.mjs +392 -0
- package/hub/team/native.mjs +649 -0
- package/hub/team/nativeProxy.mjs +681 -0
- package/hub/team/orchestrator.mjs +161 -0
- package/hub/team/pane.mjs +153 -0
- package/hub/team/psmux.mjs +1354 -0
- package/hub/team/routing.mjs +223 -0
- package/hub/team/session.mjs +611 -0
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +361 -0
- package/hub/team/tui-lite.mjs +380 -0
- package/hub/team/tui-viewer.mjs +463 -0
- package/hub/team/tui.mjs +1245 -0
- package/hub/tools.mjs +554 -0
- package/hub/tray.mjs +376 -0
- package/hub/workers/claude-worker.mjs +475 -0
- package/hub/workers/codex-mcp.mjs +504 -0
- package/hub/workers/delegator-mcp.mjs +1076 -0
- package/hub/workers/factory.mjs +21 -0
- package/hub/workers/gemini-worker.mjs +373 -0
- package/hub/workers/interface.mjs +52 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/package.json +31 -0
package/hub/team/tui.mjs
ADDED
|
@@ -0,0 +1,1245 @@
|
|
|
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단 계층.
|
|
4
|
+
|
|
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";
|
|
32
|
+
|
|
33
|
+
// package.json에서 동적 로드 (실패 시 fallback)
|
|
34
|
+
let VERSION = "7.x";
|
|
35
|
+
try {
|
|
36
|
+
const { createRequire } = await import("node:module");
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
VERSION = require("../../package.json").version;
|
|
39
|
+
} catch { /* fallback */ }
|
|
40
|
+
|
|
41
|
+
const FALLBACK_COLUMNS = 100;
|
|
42
|
+
const FALLBACK_ROWS = 30;
|
|
43
|
+
const MIN_CARD_WIDTH = 28;
|
|
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 rgbSeq(rgb, mode = 38) {
|
|
65
|
+
return `\x1b[${mode};2;${rgb.r};${rgb.g};${rgb.b}m`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pseudoRandomFrame(step, seed) {
|
|
69
|
+
return Math.abs(Math.imul(step + seed, 2654435761)) % SPINNER_FRAMES.length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Date.now()) {
|
|
73
|
+
const transitionElapsed = statusChangedAt ? Math.max(0, time - statusChangedAt) : Number.POSITIVE_INFINITY;
|
|
74
|
+
if (transitionElapsed < 500) {
|
|
75
|
+
const step = Math.floor(transitionElapsed / 50);
|
|
76
|
+
const idx = pseudoRandomFrame(step, statusChangedAt % 997);
|
|
77
|
+
const targetColor = status === "failed" || status === "error"
|
|
78
|
+
? MOCHA.fail
|
|
79
|
+
: status === "done" || status === "completed"
|
|
80
|
+
? MOCHA.ok
|
|
81
|
+
: shimmerIntensity > 0
|
|
82
|
+
? rgbSeq(lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity))
|
|
83
|
+
: MOCHA.executing;
|
|
84
|
+
return `${targetColor}${SPINNER_FRAMES[idx]}${RESET}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (status === "done" || status === "completed") return color("✓", MOCHA.ok);
|
|
88
|
+
if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
|
|
89
|
+
if (status !== "running") return dim("○");
|
|
90
|
+
const elapsed = time - spinnerStart;
|
|
91
|
+
const idx = Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) % SPINNER_FRAMES.length;
|
|
92
|
+
const c = shimmerIntensity > 0
|
|
93
|
+
? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
|
|
94
|
+
: SPINNER_BASE_COLOR;
|
|
95
|
+
return `${rgbSeq(c)}${SPINNER_FRAMES[idx]}${RESET}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function currentShimmer(time = Date.now()) {
|
|
99
|
+
const elapsed = time - spinnerStart;
|
|
100
|
+
const quantized = Math.floor(elapsed / 80) * 80;
|
|
101
|
+
const t = (quantized % SPINNER_CYCLE_MS) / SPINNER_CYCLE_MS;
|
|
102
|
+
return 0.5 * (1 + Math.sin(t * Math.PI * 2));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── activity wave — Tier1 헤더용 미니 파형 ──
|
|
106
|
+
const WAVE_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
107
|
+
function activityWave(tick, count = 4) {
|
|
108
|
+
let wave = "";
|
|
109
|
+
for (let i = 0; i < count; i++) {
|
|
110
|
+
const phase = tick * 0.3 + i * 1.5;
|
|
111
|
+
const idx = Math.floor((Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1));
|
|
112
|
+
wave += WAVE_CHARS[idx];
|
|
113
|
+
}
|
|
114
|
+
return `${MOCHA.executing}${wave}${RESET}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const GRID_GAP = 2;
|
|
118
|
+
const DEFAULT_DETAIL_LINES = 10;
|
|
119
|
+
// Tier1 상단 고정 행 수
|
|
120
|
+
const TIER1_ROWS = 2;
|
|
121
|
+
|
|
122
|
+
const SUMMARY_KEYS = [
|
|
123
|
+
"status", "lead_action", "verdict", "files_changed",
|
|
124
|
+
"confidence", "risk", "detail", "error_stage", "retryable", "partial_output",
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
// ── 레이아웃 브레이크포인트 ──────────────────────────────────────────────
|
|
128
|
+
// 80-119: 28col rail, 120-159: 36col rail, 160+: 균등
|
|
129
|
+
function resolveRailWidth(totalCols, columnCount) {
|
|
130
|
+
if (columnCount <= 1) return totalCols;
|
|
131
|
+
if (totalCols >= 160) return Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount);
|
|
132
|
+
if (totalCols >= 120) return Math.min(36, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
|
|
133
|
+
return Math.min(28, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function autoColumnCount(totalCols, workerCount) {
|
|
137
|
+
if (workerCount <= 1) return 1;
|
|
138
|
+
if (totalCols >= 160) return Math.min(workerCount, 3);
|
|
139
|
+
if (totalCols >= 120) return Math.min(workerCount, 2);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── 문자열 유틸 ──────────────────────────────────────────────────────────
|
|
144
|
+
function clamp(value, min, max) {
|
|
145
|
+
return Math.min(max, Math.max(min, value));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stripCodeBlocks(text) {
|
|
149
|
+
return String(text || "")
|
|
150
|
+
.replace(/\r/g, "")
|
|
151
|
+
// fenced code blocks
|
|
152
|
+
.replace(/```[\s\S]*?(?:```|$)/g, "\n")
|
|
153
|
+
.replace(/^\s*```.*$/gm, "")
|
|
154
|
+
// indented code blocks (4+ spaces or tab at line start)
|
|
155
|
+
.replace(/^(?: |\t).+$/gm, "")
|
|
156
|
+
// shell prompts: PS C:\...>, >, $
|
|
157
|
+
.replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "")
|
|
158
|
+
.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function sanitizeTextBlock(text, rawMode = false) {
|
|
162
|
+
const normalized = rawMode ? String(text || "").replace(/\r/g, "") : stripCodeBlocks(text);
|
|
163
|
+
return normalized
|
|
164
|
+
.split("\n")
|
|
165
|
+
.map((line) => line.trim())
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.filter((line) => line !== "--- HANDOFF ---")
|
|
168
|
+
.join("\n")
|
|
169
|
+
.trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sanitizeOneLine(text, fallback = "") {
|
|
173
|
+
const normalized = sanitizeTextBlock(text).replace(/\s+/g, " ").trim();
|
|
174
|
+
return normalized || fallback;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sanitizeFiles(files) {
|
|
178
|
+
if (!files) return [];
|
|
179
|
+
const raw = Array.isArray(files) ? files : String(files).split(",");
|
|
180
|
+
return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sanitizeFindings(findings) {
|
|
184
|
+
if (!findings) return [];
|
|
185
|
+
const raw = Array.isArray(findings)
|
|
186
|
+
? findings
|
|
187
|
+
: sanitizeTextBlock(findings).split("\n");
|
|
188
|
+
return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeTokens(tokens) {
|
|
192
|
+
if (tokens === null || tokens === undefined) return "";
|
|
193
|
+
if (typeof tokens === "number" && Number.isFinite(tokens)) return tokens;
|
|
194
|
+
const raw = sanitizeOneLine(tokens);
|
|
195
|
+
if (!raw) return "";
|
|
196
|
+
const match = raw.match(/(\d+(?:[.,]\d+)?\s*[kKmM]?)/);
|
|
197
|
+
return match ? match[1].replace(/\s+/g, "").toLowerCase() : raw;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatTokens(tokens) {
|
|
201
|
+
if (tokens === null || tokens === undefined || tokens === "") return "n/a";
|
|
202
|
+
if (typeof tokens === "number" && Number.isFinite(tokens)) {
|
|
203
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
|
|
204
|
+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
205
|
+
return `${tokens}`;
|
|
206
|
+
}
|
|
207
|
+
return String(tokens);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 색상 헬퍼 ─────────────────────────────────────────────────────────────
|
|
211
|
+
function cliColor(cli) {
|
|
212
|
+
if (cli === "gemini") return FG.gemini;
|
|
213
|
+
if (cli === "claude") return FG.claude;
|
|
214
|
+
if (cli === "codex") return FG.codex;
|
|
215
|
+
return FG.white;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function runtimeStatus(st) {
|
|
219
|
+
return st?.handoff?.status || st?.status || "pending";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function statusColor(status) {
|
|
223
|
+
if (status === "ok" || status === "completed") return MOCHA.ok;
|
|
224
|
+
if (status === "partial") return MOCHA.partial;
|
|
225
|
+
if (status === "failed") return MOCHA.fail;
|
|
226
|
+
if (status === "running" || status === "in_progress") return MOCHA.executing;
|
|
227
|
+
return FG.muted;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── MOCHA RGB (gradual fade 보간용) ──
|
|
231
|
+
const MOCHA_RGB = {
|
|
232
|
+
ok: { r: 166, g: 227, b: 161 },
|
|
233
|
+
partial: { r: 250, g: 179, b: 135 },
|
|
234
|
+
fail: { r: 243, g: 139, b: 168 },
|
|
235
|
+
executing: { r: 116, g: 199, b: 236 },
|
|
236
|
+
muted: { r: 147, g: 153, b: 178 },
|
|
237
|
+
border: { r: 69, g: 71, b: 90 },
|
|
238
|
+
blue: { r: 137, g: 180, b: 250 },
|
|
239
|
+
sky: { r: 116, g: 199, b: 236 },
|
|
240
|
+
yellow: { r: 249, g: 226, b: 175 },
|
|
241
|
+
peach: { r: 250, g: 179, b: 135 },
|
|
242
|
+
maroon: { r: 235, g: 160, b: 172 },
|
|
243
|
+
surface0: { r: 49, g: 50, b: 68 },
|
|
244
|
+
thinking: { r: 203, g: 166, b: 247 },
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
function statusToRgb(status) {
|
|
248
|
+
if (status === "ok" || status === "completed") return MOCHA_RGB.ok;
|
|
249
|
+
if (status === "partial") return MOCHA_RGB.partial;
|
|
250
|
+
if (status === "failed") return MOCHA_RGB.fail;
|
|
251
|
+
if (status === "running" || status === "in_progress") return MOCHA_RGB.executing;
|
|
252
|
+
return MOCHA_RGB.muted;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const FADE_DURATION_MS = 1500;
|
|
256
|
+
const FLASH_PHASE_MS = 250;
|
|
257
|
+
const CARD_GLOW_MS = 3000;
|
|
258
|
+
|
|
259
|
+
// Effect 1: Pulse border — running 워커 보더가 heartbeat 동기 breathing
|
|
260
|
+
function pulseBorderColor(statusRgb, time = Date.now()) {
|
|
261
|
+
const intensity = 0.3 + 0.7 * currentShimmer(time);
|
|
262
|
+
const c = lerpRgb(MOCHA_RGB.border, statusRgb, intensity);
|
|
263
|
+
return rgbSeq(c);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Effect 2: Gradient border — focus pane 보더 상단→하단 그라데이션
|
|
267
|
+
function gradientBorderFn(topRgb, bottomRgb) {
|
|
268
|
+
return (row, totalRows) => {
|
|
269
|
+
const t = totalRows <= 1 ? 0 : row / (totalRows - 1);
|
|
270
|
+
const c = lerpRgb(topRgb, bottomRgb, t);
|
|
271
|
+
return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Effect 3: Flash-fade border — 상태 변경 시 백색 플래시 → 페이드아웃
|
|
276
|
+
function flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
|
|
277
|
+
const elapsed = Date.now() - (changedAt || 0);
|
|
278
|
+
if (elapsed >= FADE_DURATION_MS || !prevStatus) return null;
|
|
279
|
+
const statusRgb = statusToRgb(currentStatus);
|
|
280
|
+
if (elapsed < FLASH_PHASE_MS) {
|
|
281
|
+
const t = elapsed / FLASH_PHASE_MS;
|
|
282
|
+
const bright = { r: 255, g: 255, b: 255 };
|
|
283
|
+
const c = lerpRgb(bright, statusRgb, t);
|
|
284
|
+
return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
|
|
285
|
+
}
|
|
286
|
+
const t = (elapsed - FLASH_PHASE_MS) / (FADE_DURATION_MS - FLASH_PHASE_MS);
|
|
287
|
+
const c = lerpRgb(statusRgb, MOCHA_RGB.border, t);
|
|
288
|
+
return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function easeOutCubic(t) {
|
|
292
|
+
return 1 - ((1 - t) ** 3);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function borderHighlightPosition(width, bodyLines, time = Date.now()) {
|
|
296
|
+
const totalRows = bodyLines + 2;
|
|
297
|
+
const perimeter = 2 * (width - 2) + 2 * totalRows;
|
|
298
|
+
if (perimeter <= 0) return undefined;
|
|
299
|
+
return Math.floor(time / 120) % perimeter;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function titleFlash(status, changeElapsed) {
|
|
303
|
+
const isCompleted = status === "completed" || status === "done" || status === "ok";
|
|
304
|
+
const isFailed = status === "failed" || status === "error" || status === "fail";
|
|
305
|
+
if ((!isCompleted && !isFailed) || changeElapsed > 800) return null;
|
|
306
|
+
const flashRgb = isCompleted ? MOCHA_RGB.ok : MOCHA_RGB.fail;
|
|
307
|
+
const bgRgb = changeElapsed <= 300
|
|
308
|
+
? flashRgb
|
|
309
|
+
: lerpRgb(flashRgb, MOCHA_RGB.surface0, clamp((changeElapsed - 300) / 500, 0, 1));
|
|
310
|
+
return rgbSeq(bgRgb, 48);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function dedupeRole(role, name, cli) {
|
|
314
|
+
if (!role) return "";
|
|
315
|
+
let r = role;
|
|
316
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
317
|
+
r = r.replace(new RegExp(esc(cli), "gi"), "").trim();
|
|
318
|
+
r = r.replace(new RegExp(esc(name), "gi"), "").trim();
|
|
319
|
+
// CLI indicator emojis 제거
|
|
320
|
+
r = r.replace(/[⚪⚫🔴🟠🟡🟢🔵🟣🟤⭕🔘]/g, "").trim();
|
|
321
|
+
// 빈 괄호 제거 + 중첩 괄호 정리
|
|
322
|
+
r = r.replace(/\(\s*\)/g, "").trim();
|
|
323
|
+
r = r.replace(/^\(([^()]+)\)$/, "$1").trim();
|
|
324
|
+
r = r.replace(/^\s*[•·\-]\s*/, "").trim();
|
|
325
|
+
return r;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── 텍스트 래핑 ──────────────────────────────────────────────────────────
|
|
329
|
+
function wrapLine(text, width) {
|
|
330
|
+
const limit = Math.max(8, width);
|
|
331
|
+
const source = String(text || "").trim();
|
|
332
|
+
if (!source) return [""];
|
|
333
|
+
const words = source.split(/\s+/);
|
|
334
|
+
const lines = [];
|
|
335
|
+
let current = "";
|
|
336
|
+
for (const word of words) {
|
|
337
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
338
|
+
if (wcswidth(candidate) <= limit) { current = candidate; continue; }
|
|
339
|
+
if (current) { lines.push(current); current = ""; }
|
|
340
|
+
if (wcswidth(word) <= limit) { current = word; continue; }
|
|
341
|
+
let offset = 0;
|
|
342
|
+
while (offset < word.length) {
|
|
343
|
+
lines.push(word.slice(offset, offset + limit));
|
|
344
|
+
offset += limit;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (current) lines.push(current);
|
|
348
|
+
return lines.length > 0 ? lines : [source.slice(0, limit)];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function wrapText(text, width, maxLines = DEFAULT_DETAIL_LINES, rawMode = false) {
|
|
352
|
+
if (maxLines <= 0) return [];
|
|
353
|
+
const input = sanitizeTextBlock(text, rawMode);
|
|
354
|
+
if (!input) return [];
|
|
355
|
+
const wrapped = input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
|
|
356
|
+
if (wrapped.length <= maxLines) return wrapped;
|
|
357
|
+
return [...wrapped.slice(0, maxLines - 1), truncate(wrapped[wrapped.length - 1], width)];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 스크롤 없이 전체 줄 반환 (focus pane용)
|
|
361
|
+
function wrapTextAll(text, width, rawMode = false) {
|
|
362
|
+
const input = sanitizeTextBlock(text, rawMode);
|
|
363
|
+
if (!input) return [];
|
|
364
|
+
return input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── virtual row buffer ────────────────────────────────────────────────────
|
|
368
|
+
class RowBuffer {
|
|
369
|
+
constructor() {
|
|
370
|
+
this._rows = [];
|
|
371
|
+
this._prev = [];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
set(rows) {
|
|
375
|
+
this._rows = rows.map(String);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 변경된 row 인덱스 목록 반환 */
|
|
379
|
+
diff() {
|
|
380
|
+
const dirty = [];
|
|
381
|
+
const len = Math.max(this._rows.length, this._prev.length);
|
|
382
|
+
for (let i = 0; i < len; i++) {
|
|
383
|
+
if (this._rows[i] !== this._prev[i]) dirty.push(i);
|
|
384
|
+
}
|
|
385
|
+
return dirty;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
commit() {
|
|
389
|
+
this._prev = [...this._rows];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
get rows() { return this._rows; }
|
|
393
|
+
get prevLen() { return this._prev.length; }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── 상태 집계 ─────────────────────────────────────────────────────────────
|
|
397
|
+
function countStatuses(names, workers) {
|
|
398
|
+
let ok = 0, partial = 0, failed = 0, running = 0;
|
|
399
|
+
for (const name of names) {
|
|
400
|
+
const st = workers.get(name);
|
|
401
|
+
const s = runtimeStatus(st);
|
|
402
|
+
if (s === "ok" || s === "completed") ok++;
|
|
403
|
+
else if (s === "partial") partial++;
|
|
404
|
+
else if (s === "failed") failed++;
|
|
405
|
+
else if (s === "running" || s === "in_progress") running++;
|
|
406
|
+
}
|
|
407
|
+
return { ok, partial, failed, running };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
|
|
411
|
+
function phaseColor(phase, time = Date.now()) {
|
|
412
|
+
const shimmer = currentShimmer(time);
|
|
413
|
+
if (phase === "exec" || phase === "executing") return rgbSeq(lerpRgb(MOCHA_RGB.blue, MOCHA_RGB.sky, shimmer));
|
|
414
|
+
if (phase === "verify" || phase === "verifying") return rgbSeq(lerpRgb(MOCHA_RGB.yellow, MOCHA_RGB.peach, shimmer));
|
|
415
|
+
if (phase === "fix" || phase === "fixing") return rgbSeq(lerpRgb(MOCHA_RGB.fail, MOCHA_RGB.maroon, shimmer));
|
|
416
|
+
return FG.accent;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function buildTier1(names, workers, pipeline, elapsed, width, version, time = Date.now()) {
|
|
420
|
+
const { ok, partial, failed, running } = countStatuses(names, workers);
|
|
421
|
+
const phase = pipeline.phase || "exec";
|
|
422
|
+
const row1 = truncate(
|
|
423
|
+
`${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase, time))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
|
|
424
|
+
`${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)}${running > 0 ? ` ${activityWave(spinnerTick)}` : ""}`,
|
|
425
|
+
width,
|
|
426
|
+
);
|
|
427
|
+
const keysHint = color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext);
|
|
428
|
+
const hintWidth = wcswidth(stripAnsi(keysHint));
|
|
429
|
+
const row2 = hintWidth >= width
|
|
430
|
+
? truncate(keysHint, width)
|
|
431
|
+
: padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
|
|
432
|
+
return [row1, row2];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── 카드 렌더러 (Tier2 worker rail) ─────────────────────────────────────
|
|
436
|
+
function detailText(st) {
|
|
437
|
+
if (st.detail) return st.detail;
|
|
438
|
+
const lines = [];
|
|
439
|
+
for (const key of SUMMARY_KEYS) {
|
|
440
|
+
const value = st.handoff?.[key];
|
|
441
|
+
if (Array.isArray(value) && value.length > 0) lines.push(`${key}: ${value.join(", ")}`);
|
|
442
|
+
else if (value) lines.push(`${key}: ${value}`);
|
|
443
|
+
}
|
|
444
|
+
if (st.snapshot) lines.unshift(st.snapshot);
|
|
445
|
+
return lines.join("\n");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function detailHighlights(st) {
|
|
449
|
+
if (Array.isArray(st.findings) && st.findings.length > 0) return st.findings;
|
|
450
|
+
const verdict = sanitizeOneLine(st.handoff?.verdict);
|
|
451
|
+
return sanitizeTextBlock(detailText(st))
|
|
452
|
+
.split("\n")
|
|
453
|
+
.map((line) => line.replace(/^verdict\s*:\s*/i, "").trim())
|
|
454
|
+
.filter(Boolean)
|
|
455
|
+
.filter((line) => line !== verdict)
|
|
456
|
+
.filter((line) => !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)))
|
|
457
|
+
.slice(0, 2);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function buildWorkerRail(name, st, opts = {}) {
|
|
461
|
+
const {
|
|
462
|
+
width,
|
|
463
|
+
selected = false,
|
|
464
|
+
focused = false, // rail 포커스 여부
|
|
465
|
+
previousSelected = false,
|
|
466
|
+
rawMode = false,
|
|
467
|
+
compact = false,
|
|
468
|
+
time = Date.now(),
|
|
469
|
+
} = opts;
|
|
470
|
+
const innerWidth = Math.max(12, width - 4);
|
|
471
|
+
const cli = st.cli || "codex";
|
|
472
|
+
const role = sanitizeOneLine(st.role);
|
|
473
|
+
const status = runtimeStatus(st);
|
|
474
|
+
const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
|
|
475
|
+
const changeElapsed = st._statusChangedAt ? Math.max(0, time - st._statusChangedAt) : Number.POSITIVE_INFINITY;
|
|
476
|
+
|
|
477
|
+
// Tier2 행 1: 이름 + CLI + role
|
|
478
|
+
const selMark = selected
|
|
479
|
+
? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux))
|
|
480
|
+
: previousSelected
|
|
481
|
+
? dim("~")
|
|
482
|
+
: " ";
|
|
483
|
+
const hb = heartbeat(status, status === "running" ? currentShimmer(time) : 0, st._statusChangedAt, time);
|
|
484
|
+
const displayRole = dedupeRole(role, name, cli);
|
|
485
|
+
const title = truncate(
|
|
486
|
+
`${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
|
|
487
|
+
innerWidth,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const cardWidth = Math.max(MIN_CARD_WIDTH, width);
|
|
491
|
+
const borderHighlight = focused ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time) : undefined;
|
|
492
|
+
const titleFlashBg = titleFlash(status, changeElapsed);
|
|
493
|
+
|
|
494
|
+
// status-specific border: focused→mauve, selected→bright, non-selected→glow decay
|
|
495
|
+
const statusBorderColor = (() => {
|
|
496
|
+
if (focused) return MOCHA.thinking;
|
|
497
|
+
if (selected && (status === "running" || status === "in_progress")) {
|
|
498
|
+
return pulseBorderColor(statusToRgb(status), time);
|
|
499
|
+
}
|
|
500
|
+
if (selected) return statusColor(status);
|
|
501
|
+
const from = statusToRgb(status);
|
|
502
|
+
const decayBase = st._statusChangedAt ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1) : 1;
|
|
503
|
+
const decayT = easeOutCubic(decayBase);
|
|
504
|
+
return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + (0.5 * decayT)));
|
|
505
|
+
})();
|
|
506
|
+
|
|
507
|
+
if (compact) {
|
|
508
|
+
// compact 2-line 카드
|
|
509
|
+
const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
|
|
510
|
+
const percent = Math.round(progress * 100);
|
|
511
|
+
const compactLine1 = truncate(
|
|
512
|
+
`${selMark} ${hb} ${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
|
|
513
|
+
innerWidth,
|
|
514
|
+
);
|
|
515
|
+
const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
|
|
516
|
+
const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
|
|
517
|
+
const framed = box([compactLine1, compactLine2], cardWidth, statusBorderColor, {
|
|
518
|
+
highlightPos: borderHighlight,
|
|
519
|
+
titleFlashBg,
|
|
520
|
+
});
|
|
521
|
+
return [framed.top, ...framed.body, framed.bot];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Tier2 행 2: 상태 배지 + elapsed + tokens + conf
|
|
525
|
+
const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
|
|
526
|
+
const statusLine = truncate(
|
|
527
|
+
`${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)}`,
|
|
528
|
+
innerWidth,
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
// Tier2 행 3: progress bar
|
|
532
|
+
const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
|
|
533
|
+
const percent = Math.round(progress * 100);
|
|
534
|
+
const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
|
|
535
|
+
const bar = progressBar(percent, barWidth, time);
|
|
536
|
+
const progressLine = truncate(
|
|
537
|
+
`${bar} ${color(`${String(percent).padStart(3)}%`, MOCHA.text)}`,
|
|
538
|
+
innerWidth,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
// Tier2 행 4-6: verdict / findings / files
|
|
542
|
+
const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
|
|
543
|
+
const findings = detailHighlights(st).join(" / ") || "no notable findings yet";
|
|
544
|
+
const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") || "none";
|
|
545
|
+
|
|
546
|
+
const verdictClr = statusColor(status);
|
|
547
|
+
const lines = [
|
|
548
|
+
title,
|
|
549
|
+
statusLine,
|
|
550
|
+
progressLine,
|
|
551
|
+
truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`, innerWidth),
|
|
552
|
+
truncate(`${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`, innerWidth),
|
|
553
|
+
truncate(`${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`, innerWidth),
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
const framed = box(lines, cardWidth, statusBorderColor, {
|
|
557
|
+
highlightPos: borderHighlight,
|
|
558
|
+
titleFlashBg,
|
|
559
|
+
});
|
|
560
|
+
return [framed.top, ...framed.body, framed.bot];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Tier3: focus pane (우측 detail) ─────────────────────────────────────
|
|
564
|
+
function buildFocusPane(name, st, opts = {}) {
|
|
565
|
+
const {
|
|
566
|
+
width,
|
|
567
|
+
height = 20,
|
|
568
|
+
scrollOffset = 0,
|
|
569
|
+
followTail = false,
|
|
570
|
+
rawMode = false,
|
|
571
|
+
focused = false,
|
|
572
|
+
time = Date.now(),
|
|
573
|
+
} = opts;
|
|
574
|
+
const innerWidth = Math.max(12, width - 4);
|
|
575
|
+
|
|
576
|
+
// verdict sticky 4행
|
|
577
|
+
const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, "—");
|
|
578
|
+
const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
|
|
579
|
+
const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
|
|
580
|
+
const status = runtimeStatus(st);
|
|
581
|
+
|
|
582
|
+
// Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
|
|
583
|
+
const activeTab = opts.activeTab || "log";
|
|
584
|
+
const tabLog = activeTab === "log" ? `${MOCHA.blue}${bold("[Log]")}` : color("[Log]", MOCHA.overlay);
|
|
585
|
+
const tabDetail = activeTab === "detail" ? `${MOCHA.blue}${bold("[Detail]")}` : color("[Detail]", MOCHA.overlay);
|
|
586
|
+
const tabFiles = activeTab === "files" ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}` : color(`[Files ${files.length}]`, MOCHA.overlay);
|
|
587
|
+
const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
|
|
588
|
+
|
|
589
|
+
const stickyLines = [
|
|
590
|
+
truncate(`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`, innerWidth),
|
|
591
|
+
tabBar,
|
|
592
|
+
truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`, innerWidth),
|
|
593
|
+
truncate(`${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`, innerWidth),
|
|
594
|
+
color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
// 본문 스크롤 영역
|
|
598
|
+
const bodyAvail = Math.max(0, height - stickyLines.length - 3); // top+bot border + scrollInfo
|
|
599
|
+
|
|
600
|
+
let allBodyLines;
|
|
601
|
+
if (activeTab === "detail") {
|
|
602
|
+
const summaryLines = [];
|
|
603
|
+
for (const key of SUMMARY_KEYS) {
|
|
604
|
+
const value = st.handoff?.[key];
|
|
605
|
+
if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
|
|
606
|
+
else if (value) summaryLines.push(`${key}: ${value}`);
|
|
607
|
+
}
|
|
608
|
+
allBodyLines = summaryLines.length > 0
|
|
609
|
+
? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
|
|
610
|
+
: [dim("no structured data")];
|
|
611
|
+
} else if (activeTab === "files") {
|
|
612
|
+
const filesList = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
|
|
613
|
+
allBodyLines = filesList.length > 0
|
|
614
|
+
? filesList.map((f, i) => `${i + 1}. ${f}`)
|
|
615
|
+
: [dim("no files changed")];
|
|
616
|
+
} else {
|
|
617
|
+
allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let startIdx;
|
|
621
|
+
if (followTail) {
|
|
622
|
+
startIdx = Math.max(0, allBodyLines.length - bodyAvail);
|
|
623
|
+
} else {
|
|
624
|
+
startIdx = clamp(scrollOffset, 0, Math.max(0, allBodyLines.length - bodyAvail));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
|
|
628
|
+
if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
|
|
629
|
+
|
|
630
|
+
// scroll indicator — MOCHA.overlay for position
|
|
631
|
+
const scrollInfo = allBodyLines.length > bodyAvail
|
|
632
|
+
? color(`${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`, MOCHA.overlay)
|
|
633
|
+
: color(`${allBodyLines.length} lines`, MOCHA.overlay);
|
|
634
|
+
|
|
635
|
+
const contentLines = [
|
|
636
|
+
...stickyLines,
|
|
637
|
+
...bodySlice.map((l) => truncate(l, innerWidth)),
|
|
638
|
+
truncate(scrollInfo, innerWidth),
|
|
639
|
+
];
|
|
640
|
+
|
|
641
|
+
// Effect 2: focused pane gets gradient border (blue→border), unfocused gets dim
|
|
642
|
+
const borderColor = focused
|
|
643
|
+
? gradientBorderFn(MOCHA_RGB.blue, MOCHA_RGB.border)
|
|
644
|
+
: MOCHA.border;
|
|
645
|
+
const paneWidth = Math.max(MIN_CARD_WIDTH, width);
|
|
646
|
+
const framed = box(contentLines, paneWidth, borderColor, {
|
|
647
|
+
highlightPos: focused ? borderHighlightPosition(paneWidth, contentLines.length, time) : undefined,
|
|
648
|
+
});
|
|
649
|
+
return [framed.top, ...framed.body, framed.bot];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── summary bar (≥4 workers) ──────────────────────────────────────────────
|
|
653
|
+
function buildSummaryBar(names, workers, selectedWorker, pipeline, width, version) {
|
|
654
|
+
const maxChipWidth = clamp(Math.floor((width - 6) / Math.min(names.length, 4)), 16, 26);
|
|
655
|
+
const chips = names.map((name, idx) => {
|
|
656
|
+
const st = workers.get(name);
|
|
657
|
+
const status = runtimeStatus(st);
|
|
658
|
+
const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
|
|
659
|
+
const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
|
|
660
|
+
return padRight(truncate(label, maxChipWidth), maxChipWidth);
|
|
661
|
+
});
|
|
662
|
+
const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
|
|
663
|
+
const keysLine = truncate(color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext), width - 4);
|
|
664
|
+
const framed = box([chipsLine, keysLine], width);
|
|
665
|
+
return [framed.top, ...framed.body, framed.bot];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ── help overlay ──────────────────────────────────────────────────────────
|
|
669
|
+
function buildHelpOverlay(width, height) {
|
|
670
|
+
const innerWidth = Math.min(50, width - 6);
|
|
671
|
+
const helpLines = [
|
|
672
|
+
color(" Keyboard Shortcuts", FG.triflux),
|
|
673
|
+
"",
|
|
674
|
+
` ${color("Tab", MOCHA.blue)} rail ↔ detail 포커스 전환`,
|
|
675
|
+
` ${color("j/↓", MOCHA.blue)} 다음 워커 / 스크롤 아래`,
|
|
676
|
+
` ${color("k/↑", MOCHA.blue)} 이전 워커 / 스크롤 위`,
|
|
677
|
+
` ${color("1-9", MOCHA.blue)} 워커 직접 선택`,
|
|
678
|
+
` ${color("n", MOCHA.blue)} 최근 상태 변경 워커 선택`,
|
|
679
|
+
` ${color("f", MOCHA.blue)} follow-tail 토글`,
|
|
680
|
+
` ${color("r", MOCHA.blue)} raw mode 토글`,
|
|
681
|
+
` ${color("l", MOCHA.blue)} 탭 전환 (Log/Detail/Files)`,
|
|
682
|
+
` ${color("g", MOCHA.blue)} focus pane 상단 점프`,
|
|
683
|
+
` ${color("G", MOCHA.blue)} focus pane 하단 점프`,
|
|
684
|
+
` ${color("PgUp", MOCHA.blue)} 페이지 위 스크롤`,
|
|
685
|
+
` ${color("PgDn", MOCHA.blue)} 페이지 아래 스크롤`,
|
|
686
|
+
` ${color("Shift+↑↓", MOCHA.blue)} 워커 선택 + 포커스 이동`,
|
|
687
|
+
` ${color("Shift+←→", MOCHA.blue)} rail ↔ detail 포커스`,
|
|
688
|
+
` ${color("h/?", MOCHA.blue)} 이 도움말 토글`,
|
|
689
|
+
` ${color("q", MOCHA.blue)} 대시보드 종료`,
|
|
690
|
+
"",
|
|
691
|
+
dim(" 아무 키나 눌러 닫기"),
|
|
692
|
+
];
|
|
693
|
+
const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
|
|
694
|
+
const framedRows = [framed.top, ...framed.body, framed.bot];
|
|
695
|
+
const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
|
|
696
|
+
const leftPad = " ".repeat(Math.max(0, Math.floor((width - innerWidth - 4) / 2)));
|
|
697
|
+
const result = [];
|
|
698
|
+
for (let i = 0; i < height; i++) {
|
|
699
|
+
const fi = i - topPad;
|
|
700
|
+
if (fi >= 0 && fi < framedRows.length) {
|
|
701
|
+
result.push(`${leftPad}${framedRows[fi]}`);
|
|
702
|
+
} else {
|
|
703
|
+
result.push("");
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── joinColumns ───────────────────────────────────────────────────────────
|
|
710
|
+
function joinColumns(blocks, gap = GRID_GAP) {
|
|
711
|
+
const maxHeight = Math.max(...blocks.map((b) => b.length));
|
|
712
|
+
return Array.from({ length: maxHeight }, (_, rowIdx) =>
|
|
713
|
+
blocks
|
|
714
|
+
.map((block) => block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))))
|
|
715
|
+
.join(" ".repeat(gap)),
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ── normalizeWorkerState ──────────────────────────────────────────────────
|
|
720
|
+
function normalizeWorkerState(existing, state) {
|
|
721
|
+
const nextHandoff = state.handoff === undefined
|
|
722
|
+
? existing.handoff
|
|
723
|
+
: {
|
|
724
|
+
...(existing.handoff || {}),
|
|
725
|
+
...(state.handoff || {}),
|
|
726
|
+
verdict: state.handoff?.verdict !== undefined
|
|
727
|
+
? sanitizeOneLine(state.handoff.verdict)
|
|
728
|
+
: existing.handoff?.verdict,
|
|
729
|
+
files_changed: state.handoff?.files_changed !== undefined
|
|
730
|
+
? sanitizeFiles(state.handoff.files_changed)
|
|
731
|
+
: existing.handoff?.files_changed,
|
|
732
|
+
confidence: state.handoff?.confidence !== undefined
|
|
733
|
+
? sanitizeOneLine(state.handoff.confidence)
|
|
734
|
+
: existing.handoff?.confidence,
|
|
735
|
+
status: state.handoff?.status !== undefined
|
|
736
|
+
? sanitizeOneLine(state.handoff.status)
|
|
737
|
+
: existing.handoff?.status,
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
...existing,
|
|
742
|
+
...state,
|
|
743
|
+
cli: state.cli !== undefined ? sanitizeOneLine(state.cli, existing.cli || "codex") : (existing.cli || "codex"),
|
|
744
|
+
role: state.role !== undefined ? sanitizeOneLine(state.role) : existing.role,
|
|
745
|
+
status: state.status !== undefined ? sanitizeOneLine(state.status, existing.status || "pending") : (existing.status || "pending"),
|
|
746
|
+
snapshot: state.snapshot !== undefined ? sanitizeTextBlock(state.snapshot) : existing.snapshot,
|
|
747
|
+
summary: state.summary !== undefined ? sanitizeTextBlock(state.summary) : existing.summary,
|
|
748
|
+
detail: state.detail !== undefined ? sanitizeTextBlock(state.detail) : existing.detail,
|
|
749
|
+
findings: state.findings !== undefined ? sanitizeFindings(state.findings) : existing.findings,
|
|
750
|
+
files_changed: state.files_changed !== undefined ? sanitizeFiles(state.files_changed) : existing.files_changed,
|
|
751
|
+
confidence: state.confidence !== undefined ? sanitizeOneLine(state.confidence) : existing.confidence,
|
|
752
|
+
tokens: state.tokens !== undefined ? normalizeTokens(state.tokens) : existing.tokens,
|
|
753
|
+
progress: state.progress !== undefined ? clamp(Number(state.progress) || 0, 0, 1) : existing.progress,
|
|
754
|
+
handoff: nextHandoff,
|
|
755
|
+
_prevStatus: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
|
|
756
|
+
? existing.status : existing._prevStatus,
|
|
757
|
+
_statusChangedAt: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
|
|
758
|
+
? Date.now() : (existing._statusChangedAt || 0),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ── createLogDashboard ────────────────────────────────────────────────────
|
|
763
|
+
/**
|
|
764
|
+
* alternate-screen diff renderer (Tier1/2/3)
|
|
765
|
+
* @param {object} [opts]
|
|
766
|
+
* @param {NodeJS.WriteStream} [opts.stream=process.stdout]
|
|
767
|
+
* @param {NodeJS.ReadStream} [opts.input=process.stdin]
|
|
768
|
+
* @param {number} [opts.refreshMs=1000]
|
|
769
|
+
* @param {number} [opts.columns] — 터미널 폭 override (테스트/뷰어용)
|
|
770
|
+
* @param {string} [opts.layout] — "single"|"split-2col"|"split-3col"|"summary+detail"|"auto"
|
|
771
|
+
* @returns {LogDashboardHandle}
|
|
772
|
+
*/
|
|
773
|
+
export function createLogDashboard(opts = {}) {
|
|
774
|
+
const {
|
|
775
|
+
stream = process.stdout,
|
|
776
|
+
input = process.stdin,
|
|
777
|
+
refreshMs = 1000,
|
|
778
|
+
columns,
|
|
779
|
+
layout: layoutHint = "auto",
|
|
780
|
+
forceTTY = false,
|
|
781
|
+
} = opts;
|
|
782
|
+
|
|
783
|
+
const isTTY = forceTTY || !!stream?.isTTY;
|
|
784
|
+
|
|
785
|
+
const workers = new Map();
|
|
786
|
+
let pipeline = { phase: "exec", fix_attempt: 0 };
|
|
787
|
+
let startedAt = Date.now();
|
|
788
|
+
let timer = null;
|
|
789
|
+
let closed = false;
|
|
790
|
+
let frameCount = 0;
|
|
791
|
+
let selectedWorker = null;
|
|
792
|
+
let previousSelectedWorker = null;
|
|
793
|
+
// focus: "rail" | "detail"
|
|
794
|
+
let focus = "rail";
|
|
795
|
+
let detailScrollOffset = 0;
|
|
796
|
+
let followTail = false;
|
|
797
|
+
let rawMode = false;
|
|
798
|
+
let focusTab = "log"; // "log" | "detail" | "files"
|
|
799
|
+
let helpOverlay = false;
|
|
800
|
+
let inputAttached = false;
|
|
801
|
+
let rawModeEnabled = false;
|
|
802
|
+
|
|
803
|
+
// virtual row buffer (altScreen 전용)
|
|
804
|
+
const rowBuf = new RowBuffer();
|
|
805
|
+
|
|
806
|
+
// ── TTY 출력 헬퍼 ────────────────────────────────────────────────────
|
|
807
|
+
function write(text) {
|
|
808
|
+
if (!closed) stream.write(text);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function writeln(text) {
|
|
812
|
+
if (!closed) stream.write(`${text}\n`);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function nowElapsedSec() {
|
|
816
|
+
return Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function getViewportColumns() {
|
|
820
|
+
const v = Number.isFinite(columns)
|
|
821
|
+
? columns
|
|
822
|
+
: (Number.isFinite(stream?.columns)
|
|
823
|
+
? stream.columns
|
|
824
|
+
: (Number.isFinite(process.stdout?.columns) ? process.stdout.columns : FALLBACK_COLUMNS));
|
|
825
|
+
return Math.max(48, v || FALLBACK_COLUMNS);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function getViewportRows() {
|
|
829
|
+
const v = Number.isFinite(stream?.rows)
|
|
830
|
+
? stream.rows
|
|
831
|
+
: (Number.isFinite(process.stdout?.rows) ? process.stdout.rows : FALLBACK_ROWS);
|
|
832
|
+
return Math.max(10, v || FALLBACK_ROWS);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function visibleWorkerNames() {
|
|
836
|
+
return [...workers.keys()].sort();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function ensureSelectedWorker(names) {
|
|
840
|
+
if (names.length === 0) { selectedWorker = null; return; }
|
|
841
|
+
if (!selectedWorker || !workers.has(selectedWorker)) selectedWorker = names[0];
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
|
|
845
|
+
if (!nextWorker || nextWorker === selectedWorker) return;
|
|
846
|
+
if (preserveTrail && selectedWorker && workers.has(selectedWorker)) {
|
|
847
|
+
previousSelectedWorker = selectedWorker;
|
|
848
|
+
}
|
|
849
|
+
selectedWorker = nextWorker;
|
|
850
|
+
detailScrollOffset = 0;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function selectRelative(offset) {
|
|
854
|
+
const names = visibleWorkerNames();
|
|
855
|
+
if (names.length === 0) return;
|
|
856
|
+
ensureSelectedWorker(names);
|
|
857
|
+
const idx = Math.max(0, names.indexOf(selectedWorker));
|
|
858
|
+
setSelectedWorker(names[(idx + offset + names.length) % names.length]);
|
|
859
|
+
render();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function selectMostRecentChangedWorker() {
|
|
863
|
+
const names = visibleWorkerNames();
|
|
864
|
+
if (names.length === 0) return;
|
|
865
|
+
ensureSelectedWorker(names);
|
|
866
|
+
const target = names.reduce((best, name) => {
|
|
867
|
+
const changedAt = workers.get(name)?._statusChangedAt || 0;
|
|
868
|
+
const bestChangedAt = workers.get(best)?._statusChangedAt || 0;
|
|
869
|
+
return changedAt > bestChangedAt ? name : best;
|
|
870
|
+
}, names[0]);
|
|
871
|
+
setSelectedWorker(target);
|
|
872
|
+
render();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function scrollDetail(delta) {
|
|
876
|
+
followTail = false;
|
|
877
|
+
detailScrollOffset = Math.max(0, detailScrollOffset + delta);
|
|
878
|
+
render();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ── doClose (내부 함수) ─────────────────────────────────────────────
|
|
882
|
+
function doClose() {
|
|
883
|
+
if (closed) return;
|
|
884
|
+
if (timer) clearInterval(timer);
|
|
885
|
+
if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
|
|
886
|
+
if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
|
|
887
|
+
if (inputAttached && typeof input?.pause === "function") input.pause();
|
|
888
|
+
exitAltScreen();
|
|
889
|
+
closed = true;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ── 키 입력 ──────────────────────────────────────────────────────────
|
|
893
|
+
function handleInput(chunk) {
|
|
894
|
+
const key = String(chunk);
|
|
895
|
+
if (key === "\u0003") return; // Ctrl-C
|
|
896
|
+
|
|
897
|
+
// Help overlay: 아무 키나 누르면 닫기
|
|
898
|
+
if (helpOverlay) {
|
|
899
|
+
helpOverlay = false;
|
|
900
|
+
render();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Tab: rail ↔ detail 포커스 전환
|
|
905
|
+
if (key === "\t") {
|
|
906
|
+
focus = focus === "rail" ? "detail" : "rail";
|
|
907
|
+
render();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Shift+Arrow: 포커스 이동 + 워커 선택
|
|
912
|
+
if (key === "\x1b[1;2A") { selectRelative(-1); return; } // Shift+Up → 워커 위
|
|
913
|
+
if (key === "\x1b[1;2B") { selectRelative(1); return; } // Shift+Down → 워커 아래
|
|
914
|
+
if (key === "\x1b[1;2D") { focus = "rail"; render(); return; } // Shift+Left → rail
|
|
915
|
+
if (key === "\x1b[1;2C") { focus = "detail"; render(); return; } // Shift+Right → detail
|
|
916
|
+
|
|
917
|
+
if (focus === "detail") {
|
|
918
|
+
// detail 포커스: j/k/ArrowDown/Up = 스크롤
|
|
919
|
+
if (key === "j" || key === "\u001b[B") { scrollDetail(1); return; }
|
|
920
|
+
if (key === "k" || key === "\u001b[A") { scrollDetail(-1); return; }
|
|
921
|
+
} else {
|
|
922
|
+
// rail 포커스: j/k = 워커 선택
|
|
923
|
+
if (key === "j" || key === "\u001b[B") { selectRelative(1); return; }
|
|
924
|
+
if (key === "k" || key === "\u001b[A") { selectRelative(-1); return; }
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// g: focus pane 상단 점프
|
|
928
|
+
if (key === "g") { followTail = false; detailScrollOffset = 0; render(); return; }
|
|
929
|
+
// G: focus pane 하단 점프
|
|
930
|
+
if (key === "G") { followTail = true; detailScrollOffset = 0; render(); return; }
|
|
931
|
+
// PgUp/PgDn: 페이지 단위 스크롤
|
|
932
|
+
const pageSize = Math.max(1, Math.floor(getViewportRows() / 2));
|
|
933
|
+
if (key === "\x1b[5~") { scrollDetail(-pageSize); return; } // PgUp
|
|
934
|
+
if (key === "\x1b[6~") { scrollDetail(pageSize); return; } // PgDn
|
|
935
|
+
// f: follow-tail 토글
|
|
936
|
+
if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
|
|
937
|
+
// r: raw mode 토글
|
|
938
|
+
if (key === "r") { rawMode = !rawMode; render(); return; }
|
|
939
|
+
// l: 탭 전환 (Log → Detail → Files)
|
|
940
|
+
if (key === "l") {
|
|
941
|
+
const tabs = ["log", "detail", "files"];
|
|
942
|
+
focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
|
|
943
|
+
detailScrollOffset = 0;
|
|
944
|
+
render();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
// n: 가장 최근 상태 변경 워커로 이동
|
|
948
|
+
if (key === "n") { selectMostRecentChangedWorker(); return; }
|
|
949
|
+
// h/?: 도움말 오버레이 토글
|
|
950
|
+
if (key === "h" || key === "?") { helpOverlay = true; render(); return; }
|
|
951
|
+
// q: 대시보드 종료
|
|
952
|
+
if (key === "q") { doClose(); return; }
|
|
953
|
+
// 1-9: 워커 직접 선택
|
|
954
|
+
if (/^[1-9]$/.test(key)) {
|
|
955
|
+
const names = visibleWorkerNames();
|
|
956
|
+
const target = names[Number.parseInt(key, 10) - 1];
|
|
957
|
+
if (target) { setSelectedWorker(target); render(); }
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function attachInput() {
|
|
963
|
+
if (inputAttached) return;
|
|
964
|
+
if (!isTTY || (!forceTTY && !input?.isTTY) || typeof input?.on !== "function") return;
|
|
965
|
+
inputAttached = true;
|
|
966
|
+
if (typeof input.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
|
|
967
|
+
if (typeof input.resume === "function") input.resume();
|
|
968
|
+
input.on("data", handleInput);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ── altScreen 진입/퇴장 ───────────────────────────────────────────────
|
|
972
|
+
function enterAltScreen() {
|
|
973
|
+
if (!isTTY) return;
|
|
974
|
+
write(altScreenOn + cursorHide + clearScreen + cursorHome);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function exitAltScreen() {
|
|
978
|
+
if (!isTTY) return;
|
|
979
|
+
write(cursorShow + altScreenOff);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ── 프레임 빌드 ───────────────────────────────────────────────────────
|
|
983
|
+
function buildRows() {
|
|
984
|
+
const names = visibleWorkerNames();
|
|
985
|
+
if (names.length === 0) return [];
|
|
986
|
+
|
|
987
|
+
ensureSelectedWorker(names);
|
|
988
|
+
attachInput();
|
|
989
|
+
|
|
990
|
+
const totalCols = getViewportColumns();
|
|
991
|
+
const totalRows = getViewportRows();
|
|
992
|
+
|
|
993
|
+
// Help overlay: 전체 화면 오버레이
|
|
994
|
+
if (helpOverlay) {
|
|
995
|
+
return buildHelpOverlay(totalCols, totalRows);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const elapsed = nowElapsedSec();
|
|
999
|
+
const renderTime = Date.now();
|
|
1000
|
+
|
|
1001
|
+
// Tier1: 상단 고정 2행
|
|
1002
|
+
const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION, renderTime);
|
|
1003
|
+
|
|
1004
|
+
// 레이아웃 결정
|
|
1005
|
+
let effectiveLayout = layoutHint;
|
|
1006
|
+
if (effectiveLayout === "auto") {
|
|
1007
|
+
if (names.length >= 4) effectiveLayout = "summary+detail";
|
|
1008
|
+
else if (names.length === 3) effectiveLayout = "split-3col";
|
|
1009
|
+
else if (names.length === 2) effectiveLayout = "split-2col";
|
|
1010
|
+
else effectiveLayout = "single";
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// summary+detail: summaryBar + focus pane
|
|
1014
|
+
if (effectiveLayout === "summary+detail") {
|
|
1015
|
+
const summaryBar = buildSummaryBar(names, workers, selectedWorker, pipeline, totalCols, VERSION);
|
|
1016
|
+
const selectedState = workers.get(selectedWorker);
|
|
1017
|
+
const focusPaneHeight = Math.max(8, totalRows - tier1.length - summaryBar.length);
|
|
1018
|
+
const focusPane = buildFocusPane(selectedWorker, selectedState, {
|
|
1019
|
+
width: totalCols,
|
|
1020
|
+
height: focusPaneHeight,
|
|
1021
|
+
scrollOffset: detailScrollOffset,
|
|
1022
|
+
followTail,
|
|
1023
|
+
rawMode,
|
|
1024
|
+
focused: focus === "detail",
|
|
1025
|
+
activeTab: focusTab,
|
|
1026
|
+
time: renderTime,
|
|
1027
|
+
});
|
|
1028
|
+
return [...tier1, ...summaryBar, ...focusPane];
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// 좌우 분할: Left Rail (30%) | Right Focus (70%)
|
|
1032
|
+
// 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
|
|
1033
|
+
const GAP = 1; // rail과 focus 사이 구분선
|
|
1034
|
+
const railRatio = focus === "detail" ? 0.20 : 0.30;
|
|
1035
|
+
const railWidth = Math.max(MIN_CARD_WIDTH, Math.floor(totalCols * railRatio));
|
|
1036
|
+
const focusWidth = totalCols - railWidth - GAP;
|
|
1037
|
+
const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
|
|
1038
|
+
|
|
1039
|
+
// 반응형 compact: 워커 카드가 가용 높이 초과 시 자동 전환
|
|
1040
|
+
const normalCardHeight = 8; // box top/bot + 6 content lines
|
|
1041
|
+
const useCompact = names.length * normalCardHeight > bodyHeight;
|
|
1042
|
+
|
|
1043
|
+
// Left Rail: 워커 카드 세로 스택
|
|
1044
|
+
const railLines = [];
|
|
1045
|
+
for (const name of names) {
|
|
1046
|
+
const card = buildWorkerRail(name, workers.get(name), {
|
|
1047
|
+
width: railWidth,
|
|
1048
|
+
selected: name === selectedWorker,
|
|
1049
|
+
previousSelected: name === previousSelectedWorker,
|
|
1050
|
+
focused: focus === "rail" && name === selectedWorker,
|
|
1051
|
+
rawMode,
|
|
1052
|
+
compact: useCompact,
|
|
1053
|
+
time: renderTime,
|
|
1054
|
+
});
|
|
1055
|
+
railLines.push(...card);
|
|
1056
|
+
}
|
|
1057
|
+
// rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
|
|
1058
|
+
while (railLines.length < bodyHeight) railLines.push(padRight("", railWidth));
|
|
1059
|
+
if (railLines.length > bodyHeight) railLines.length = bodyHeight;
|
|
1060
|
+
|
|
1061
|
+
// Right Focus: 선택된 워커 상세
|
|
1062
|
+
let focusLines = [];
|
|
1063
|
+
if (selectedWorker && workers.has(selectedWorker)) {
|
|
1064
|
+
focusLines = buildFocusPane(selectedWorker, workers.get(selectedWorker), {
|
|
1065
|
+
width: focusWidth,
|
|
1066
|
+
height: bodyHeight,
|
|
1067
|
+
scrollOffset: detailScrollOffset,
|
|
1068
|
+
followTail,
|
|
1069
|
+
rawMode,
|
|
1070
|
+
focused: focus === "detail",
|
|
1071
|
+
activeTab: focusTab,
|
|
1072
|
+
time: renderTime,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
|
|
1076
|
+
if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
|
|
1077
|
+
|
|
1078
|
+
// 좌우 합성: rail[i] + separator + focus[i]
|
|
1079
|
+
const separator = dim("│");
|
|
1080
|
+
const composedRows = [];
|
|
1081
|
+
for (let i = 0; i < bodyHeight; i++) {
|
|
1082
|
+
const left = clip(railLines[i] || "", railWidth);
|
|
1083
|
+
const right = focusLines[i] || "";
|
|
1084
|
+
composedRows.push(`${left}${separator}${right}`);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// 하단 상태바
|
|
1088
|
+
const statusBar = truncate(
|
|
1089
|
+
color(` 세션 종료됨 — 아무 키나 누르면 닫힘`, MOCHA.subtext),
|
|
1090
|
+
totalCols,
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
return [...tier1, ...composedRows, statusBar];
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// ── altScreen diff render ─────────────────────────────────────────────
|
|
1097
|
+
function renderAltScreen() {
|
|
1098
|
+
const newRows = buildRows();
|
|
1099
|
+
rowBuf.set(newRows);
|
|
1100
|
+
const dirty = rowBuf.diff();
|
|
1101
|
+
const prevLen = rowBuf.prevLen;
|
|
1102
|
+
|
|
1103
|
+
if (dirty.length === 0 && newRows.length === prevLen) return;
|
|
1104
|
+
|
|
1105
|
+
const toErase = prevLen > newRows.length
|
|
1106
|
+
? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
|
|
1107
|
+
: [];
|
|
1108
|
+
|
|
1109
|
+
for (const i of dirty) {
|
|
1110
|
+
write(moveTo(i + 1, 1) + clearLine + (newRows[i] || ""));
|
|
1111
|
+
}
|
|
1112
|
+
for (const i of toErase) {
|
|
1113
|
+
write(moveTo(i + 1, 1) + clearLine);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
rowBuf.commit();
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ── append-only render (non-TTY fallback) ────────────────────────────
|
|
1120
|
+
function renderAppendOnly() {
|
|
1121
|
+
const newRows = buildRows();
|
|
1122
|
+
if (newRows.length === 0) return;
|
|
1123
|
+
writeln(newRows.join("\n"));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ── public render ─────────────────────────────────────────────────────
|
|
1127
|
+
function render() {
|
|
1128
|
+
if (closed) return;
|
|
1129
|
+
frameCount++;
|
|
1130
|
+
spinnerTick++;
|
|
1131
|
+
try {
|
|
1132
|
+
if (isTTY) {
|
|
1133
|
+
renderAltScreen();
|
|
1134
|
+
} else {
|
|
1135
|
+
renderAppendOnly();
|
|
1136
|
+
}
|
|
1137
|
+
} finally {
|
|
1138
|
+
previousSelectedWorker = null;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// altScreen 시작
|
|
1143
|
+
if (isTTY) {
|
|
1144
|
+
enterAltScreen();
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if (refreshMs > 0) {
|
|
1148
|
+
timer = setInterval(render, refreshMs);
|
|
1149
|
+
if (timer.unref) timer.unref();
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// ── 공개 API ─────────────────────────────────────────────────────────
|
|
1153
|
+
return {
|
|
1154
|
+
updateWorker(paneName, state) {
|
|
1155
|
+
const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
|
|
1156
|
+
const merged = normalizeWorkerState(existing, state);
|
|
1157
|
+
const nextSig = JSON.stringify({
|
|
1158
|
+
cli: merged.cli, status: merged.status, role: merged.role,
|
|
1159
|
+
snapshot: merged.snapshot, summary: merged.summary, detail: merged.detail,
|
|
1160
|
+
findings: merged.findings, files_changed: merged.files_changed,
|
|
1161
|
+
confidence: merged.confidence, tokens: merged.tokens,
|
|
1162
|
+
progress: merged.progress, handoff: merged.handoff,
|
|
1163
|
+
});
|
|
1164
|
+
const sigChanged = nextSig !== existing._sig;
|
|
1165
|
+
const explicitElapsed = Number.isFinite(state.elapsed) ? Math.max(0, Math.round(state.elapsed)) : null;
|
|
1166
|
+
merged._sig = nextSig;
|
|
1167
|
+
merged._logSec = sigChanged
|
|
1168
|
+
? (explicitElapsed ?? nowElapsedSec())
|
|
1169
|
+
: (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
|
|
1170
|
+
workers.set(paneName, merged);
|
|
1171
|
+
ensureSelectedWorker(visibleWorkerNames());
|
|
1172
|
+
// follow-tail: 새 데이터 → 자동 scroll 재계산
|
|
1173
|
+
if (followTail) detailScrollOffset = 0;
|
|
1174
|
+
},
|
|
1175
|
+
|
|
1176
|
+
updatePipeline(state) {
|
|
1177
|
+
pipeline = { ...pipeline, ...state };
|
|
1178
|
+
},
|
|
1179
|
+
|
|
1180
|
+
setStartTime(ms) {
|
|
1181
|
+
startedAt = ms;
|
|
1182
|
+
},
|
|
1183
|
+
|
|
1184
|
+
selectWorker(name) {
|
|
1185
|
+
if (!workers.has(name)) return;
|
|
1186
|
+
setSelectedWorker(name);
|
|
1187
|
+
},
|
|
1188
|
+
|
|
1189
|
+
toggleDetail(force) {
|
|
1190
|
+
// 하위 호환: toggleDetail = focus pane 표시 여부
|
|
1191
|
+
const next = typeof force === "boolean" ? force : focus !== "detail";
|
|
1192
|
+
focus = next ? "detail" : "rail";
|
|
1193
|
+
},
|
|
1194
|
+
|
|
1195
|
+
render,
|
|
1196
|
+
|
|
1197
|
+
getWorkers() {
|
|
1198
|
+
return new Map(workers);
|
|
1199
|
+
},
|
|
1200
|
+
|
|
1201
|
+
getFrameCount() {
|
|
1202
|
+
return frameCount;
|
|
1203
|
+
},
|
|
1204
|
+
|
|
1205
|
+
getPipelineState() {
|
|
1206
|
+
return { ...pipeline };
|
|
1207
|
+
},
|
|
1208
|
+
|
|
1209
|
+
getSelectedWorker() {
|
|
1210
|
+
return selectedWorker;
|
|
1211
|
+
},
|
|
1212
|
+
|
|
1213
|
+
isDetailExpanded() {
|
|
1214
|
+
return focus === "detail";
|
|
1215
|
+
},
|
|
1216
|
+
|
|
1217
|
+
getFocusTab() {
|
|
1218
|
+
return focusTab;
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1221
|
+
setFocusTab(tab) {
|
|
1222
|
+
const valid = ["log", "detail", "files"];
|
|
1223
|
+
if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
|
|
1224
|
+
},
|
|
1225
|
+
|
|
1226
|
+
getLayout() {
|
|
1227
|
+
return layoutHint;
|
|
1228
|
+
},
|
|
1229
|
+
|
|
1230
|
+
toggleHelp(force) {
|
|
1231
|
+
helpOverlay = typeof force === "boolean" ? force : !helpOverlay;
|
|
1232
|
+
},
|
|
1233
|
+
|
|
1234
|
+
isHelpVisible() {
|
|
1235
|
+
return helpOverlay;
|
|
1236
|
+
},
|
|
1237
|
+
|
|
1238
|
+
close() {
|
|
1239
|
+
doClose();
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// 하위 호환
|
|
1245
|
+
export { createLogDashboard as createTui };
|