@triflux/remote 10.1.0 → 10.25.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 +239 -121
- package/hub/public/dashboard.html +61 -0
- package/hub/server.mjs +2165 -708
- package/hub/store-adapter.mjs +59 -26
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +84 -17
- package/hub/team/build-worker-prompt.mjs +42 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +219 -0
- package/hub/team/claude-native-bridge.mjs +784 -0
- package/hub/team/claude-session-projection.mjs +65 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +154 -33
- package/hub/team/cli/commands/start/parse-args.mjs +89 -12
- package/hub/team/cli/commands/start/start-headless.mjs +125 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +44 -16
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +55 -1
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +20 -118
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +241 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2065 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/launcher-template.mjs +23 -13
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +74 -35
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -15
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1020 -279
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +68 -52
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +61 -33
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +605 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1522 -193
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +288 -0
- package/hub/team/tui-lite.mjs +234 -146
- package/hub/team/tui-remote-adapter.mjs +18 -34
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -44
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +927 -437
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +517 -63
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +474 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +662 -335
- package/hub/workers/factory.mjs +100 -21
- package/hub/workers/gemini-worker.mjs +179 -86
- package/hub/workers/interface.mjs +23 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +22 -16
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +179 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
package/hub/team/tui.mjs
CHANGED
|
@@ -3,52 +3,208 @@
|
|
|
3
3
|
// Tier1(상단 고정) / Tier2(worker rail) / Tier3(focus pane) 3단 계층.
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
MOCHA,
|
|
9
|
-
color,
|
|
10
|
-
dim,
|
|
6
|
+
altScreenOff,
|
|
7
|
+
altScreenOn,
|
|
11
8
|
bold,
|
|
12
9
|
box,
|
|
13
|
-
|
|
14
|
-
truncate,
|
|
15
|
-
clip,
|
|
16
|
-
stripAnsi,
|
|
17
|
-
wcswidth,
|
|
18
|
-
progressBar,
|
|
19
|
-
statusBadge,
|
|
20
|
-
altScreenOn,
|
|
21
|
-
altScreenOff,
|
|
10
|
+
clearLine,
|
|
22
11
|
clearScreen,
|
|
23
|
-
|
|
12
|
+
clip,
|
|
13
|
+
color,
|
|
24
14
|
cursorHide,
|
|
15
|
+
cursorHome,
|
|
25
16
|
cursorShow,
|
|
17
|
+
dim,
|
|
18
|
+
FG,
|
|
19
|
+
MOCHA,
|
|
26
20
|
moveTo,
|
|
27
|
-
|
|
21
|
+
padRight,
|
|
22
|
+
progressBar,
|
|
23
|
+
RESET,
|
|
24
|
+
statusBadge,
|
|
25
|
+
stripAnsi,
|
|
26
|
+
truncate,
|
|
27
|
+
wcswidth,
|
|
28
28
|
} from "./ansi.mjs";
|
|
29
|
+
import { resolveAttachCommand } from "./session.mjs";
|
|
30
|
+
import {
|
|
31
|
+
clamp,
|
|
32
|
+
cliColor,
|
|
33
|
+
normalizeWorkerState as coreNormalizeWorkerState,
|
|
34
|
+
countStatuses,
|
|
35
|
+
FALLBACK_COLUMNS,
|
|
36
|
+
FALLBACK_ROWS,
|
|
37
|
+
formatTokens,
|
|
38
|
+
loadVersion,
|
|
39
|
+
runtimeStatus,
|
|
40
|
+
sanitizeFiles,
|
|
41
|
+
sanitizeOneLine,
|
|
42
|
+
sanitizeTextBlock,
|
|
43
|
+
statusColor,
|
|
44
|
+
wrapLine,
|
|
45
|
+
wrapText as wrapTextAll,
|
|
46
|
+
} from "./tui-core.mjs";
|
|
47
|
+
import {
|
|
48
|
+
createMetricsCollector,
|
|
49
|
+
createSynapseEventStream,
|
|
50
|
+
renderMetricsTier1,
|
|
51
|
+
} from "./tui-synapse.mjs";
|
|
52
|
+
import {
|
|
53
|
+
createPanelResizer,
|
|
54
|
+
createSearchState,
|
|
55
|
+
createTokenTracker,
|
|
56
|
+
createVimMotion,
|
|
57
|
+
} from "./tui-widgets.mjs";
|
|
58
|
+
import { createWtManager } from "./wt-manager.mjs";
|
|
29
59
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// package.json에서 동적 로드 (실패 시 fallback)
|
|
33
|
-
let VERSION = "7.x";
|
|
34
|
-
try {
|
|
35
|
-
const { createRequire } = await import("node:module");
|
|
36
|
-
const require = createRequire(import.meta.url);
|
|
37
|
-
VERSION = require("../../package.json").version;
|
|
38
|
-
} catch { /* fallback */ }
|
|
60
|
+
const VERSION = await loadVersion();
|
|
39
61
|
|
|
40
|
-
|
|
41
|
-
const FALLBACK_ROWS = 30;
|
|
62
|
+
// FALLBACK_COLUMNS, FALLBACK_ROWS → tui-core.mjs에서 import
|
|
42
63
|
const MIN_CARD_WIDTH = 28;
|
|
64
|
+
const ATTACH_SESSION_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/u;
|
|
65
|
+
const DEFAULT_ATTACH_TAB_TTL_MS = 30_000;
|
|
66
|
+
const DEFAULT_ATTACH_LIMITS = Object.freeze({ local: 8, remote: 4 });
|
|
67
|
+
|
|
68
|
+
function sanitizeAttachTitle(value, fallback) {
|
|
69
|
+
const text = String(value || "")
|
|
70
|
+
.replace(/[\r\n]+/gu, " ")
|
|
71
|
+
.trim();
|
|
72
|
+
return text || fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertAttachSessionName(value) {
|
|
76
|
+
const sessionName = String(value || "").trim();
|
|
77
|
+
if (!ATTACH_SESSION_NAME_PATTERN.test(sessionName)) {
|
|
78
|
+
throw new Error(`invalid attach session name: ${sessionName || "<empty>"}`);
|
|
79
|
+
}
|
|
80
|
+
return sessionName;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createTransientTabLimiter(opts = {}) {
|
|
84
|
+
const now = opts.now || Date.now;
|
|
85
|
+
const ttlMs = Number.isFinite(opts.ttlMs)
|
|
86
|
+
? Math.max(1, Math.trunc(opts.ttlMs))
|
|
87
|
+
: DEFAULT_ATTACH_TAB_TTL_MS;
|
|
88
|
+
const limits = {
|
|
89
|
+
local: Number.isFinite(opts.limits?.local)
|
|
90
|
+
? Math.max(1, Math.trunc(opts.limits.local))
|
|
91
|
+
: DEFAULT_ATTACH_LIMITS.local,
|
|
92
|
+
remote: Number.isFinite(opts.limits?.remote)
|
|
93
|
+
? Math.max(1, Math.trunc(opts.limits.remote))
|
|
94
|
+
: DEFAULT_ATTACH_LIMITS.remote,
|
|
95
|
+
};
|
|
96
|
+
const buckets = {
|
|
97
|
+
local: [],
|
|
98
|
+
remote: [],
|
|
99
|
+
};
|
|
100
|
+
let nextId = 0;
|
|
101
|
+
|
|
102
|
+
function normalizeKind(kind) {
|
|
103
|
+
return kind === "remote" ? "remote" : "local";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function prune(kind) {
|
|
107
|
+
const normalized = normalizeKind(kind);
|
|
108
|
+
const nowAt = now();
|
|
109
|
+
buckets[normalized] = buckets[normalized].filter(
|
|
110
|
+
(entry) => entry.expiresAt > nowAt,
|
|
111
|
+
);
|
|
112
|
+
return buckets[normalized];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function acquire(kind) {
|
|
116
|
+
const normalized = normalizeKind(kind);
|
|
117
|
+
const active = prune(normalized);
|
|
118
|
+
const limit = limits[normalized];
|
|
119
|
+
if (active.length >= limit) {
|
|
120
|
+
const retryAfterMs =
|
|
121
|
+
active.length > 0 ? Math.max(0, active[0].expiresAt - now()) : ttlMs;
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
ok: false,
|
|
124
|
+
kind: normalized,
|
|
125
|
+
limit,
|
|
126
|
+
active: active.length,
|
|
127
|
+
retryAfterMs,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const entry = Object.freeze({
|
|
132
|
+
id: ++nextId,
|
|
133
|
+
expiresAt: now() + ttlMs,
|
|
134
|
+
});
|
|
135
|
+
buckets[normalized] = [...active, entry];
|
|
136
|
+
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
ok: true,
|
|
139
|
+
kind: normalized,
|
|
140
|
+
limit,
|
|
141
|
+
release() {
|
|
142
|
+
buckets[normalized] = buckets[normalized].filter(
|
|
143
|
+
(candidate) => candidate.id !== entry.id,
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function snapshot() {
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
local: prune("local").length,
|
|
152
|
+
remote: prune("remote").length,
|
|
153
|
+
ttlMs,
|
|
154
|
+
limits: Object.freeze({ ...limits }),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
acquire,
|
|
160
|
+
snapshot,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildDashboardAttachRequest(worker, opts = {}) {
|
|
165
|
+
const resolveAttach = opts.resolveAttachCommand || resolveAttachCommand;
|
|
166
|
+
const sessionName = assertAttachSessionName(
|
|
167
|
+
worker?.sessionName || worker?.paneName,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (worker?.remote && worker?.sshUser) {
|
|
171
|
+
const host = String(worker.host || "unknown");
|
|
172
|
+
const ip = String(worker._sshIp || host);
|
|
173
|
+
const title = sanitizeAttachTitle(
|
|
174
|
+
`${host}:${worker.role || sessionName}`,
|
|
175
|
+
`${host}:${sessionName}`,
|
|
176
|
+
);
|
|
177
|
+
const command = `ssh ${worker.sshUser}@${ip} -t "psmux attach-session -t ${sessionName}"`;
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
kind: "remote",
|
|
180
|
+
sessionName,
|
|
181
|
+
title,
|
|
182
|
+
command,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const attachSpec = resolveAttach(sessionName);
|
|
187
|
+
const title = sanitizeAttachTitle(worker?.role || sessionName, sessionName);
|
|
188
|
+
const commandParts = [attachSpec.command, ...attachSpec.args];
|
|
189
|
+
return Object.freeze({
|
|
190
|
+
kind: "local",
|
|
191
|
+
sessionName,
|
|
192
|
+
title,
|
|
193
|
+
command: commandParts.join(" "),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
43
196
|
|
|
44
197
|
// ✻ heartbeat — Claude Code 리버스 엔지니어링 기반 breathing animation
|
|
45
198
|
// 프레임: ["·","✢","✳","✶","✻","✽"] + 역재생 = 12프레임 왕복
|
|
46
199
|
// 타이밍: 2000ms/cycle, RGB truecolor 보간
|
|
47
200
|
const SPINNER_FRAMES_RAW = ["·", "✢", "✳", "✶", "✻", "✽"];
|
|
48
|
-
const SPINNER_FRAMES = [
|
|
201
|
+
const SPINNER_FRAMES = [
|
|
202
|
+
...SPINNER_FRAMES_RAW,
|
|
203
|
+
...[...SPINNER_FRAMES_RAW].reverse(),
|
|
204
|
+
];
|
|
49
205
|
const SPINNER_CYCLE_MS = 2000;
|
|
50
206
|
const SPINNER_BASE_COLOR = { r: 203, g: 166, b: 247 }; // Catppuccin Mocha mauve
|
|
51
|
-
const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 };
|
|
207
|
+
const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
|
|
52
208
|
const spinnerStart = Date.now();
|
|
53
209
|
let spinnerTick = 0;
|
|
54
210
|
|
|
@@ -68,18 +224,28 @@ function pseudoRandomFrame(step, seed) {
|
|
|
68
224
|
return Math.abs(Math.imul(step + seed, 2654435761)) % SPINNER_FRAMES.length;
|
|
69
225
|
}
|
|
70
226
|
|
|
71
|
-
function heartbeat(
|
|
72
|
-
|
|
227
|
+
function heartbeat(
|
|
228
|
+
status,
|
|
229
|
+
shimmerIntensity = 0,
|
|
230
|
+
statusChangedAt = 0,
|
|
231
|
+
time = Date.now(),
|
|
232
|
+
) {
|
|
233
|
+
const transitionElapsed = statusChangedAt
|
|
234
|
+
? Math.max(0, time - statusChangedAt)
|
|
235
|
+
: Number.POSITIVE_INFINITY;
|
|
73
236
|
if (transitionElapsed < 500) {
|
|
74
237
|
const step = Math.floor(transitionElapsed / 50);
|
|
75
238
|
const idx = pseudoRandomFrame(step, statusChangedAt % 997);
|
|
76
|
-
const targetColor =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
239
|
+
const targetColor =
|
|
240
|
+
status === "failed" || status === "error"
|
|
241
|
+
? MOCHA.fail
|
|
242
|
+
: status === "done" || status === "completed"
|
|
243
|
+
? MOCHA.ok
|
|
244
|
+
: shimmerIntensity > 0
|
|
245
|
+
? rgbSeq(
|
|
246
|
+
lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity),
|
|
247
|
+
)
|
|
248
|
+
: MOCHA.executing;
|
|
83
249
|
return `${targetColor}${SPINNER_FRAMES[idx]}${RESET}`;
|
|
84
250
|
}
|
|
85
251
|
|
|
@@ -87,10 +253,13 @@ function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Dat
|
|
|
87
253
|
if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
|
|
88
254
|
if (status !== "running") return dim("○");
|
|
89
255
|
const elapsed = time - spinnerStart;
|
|
90
|
-
const idx =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
256
|
+
const idx =
|
|
257
|
+
Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) %
|
|
258
|
+
SPINNER_FRAMES.length;
|
|
259
|
+
const c =
|
|
260
|
+
shimmerIntensity > 0
|
|
261
|
+
? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
|
|
262
|
+
: SPINNER_BASE_COLOR;
|
|
94
263
|
return `${rgbSeq(c)}${SPINNER_FRAMES[idx]}${RESET}`;
|
|
95
264
|
}
|
|
96
265
|
|
|
@@ -107,7 +276,9 @@ function activityWave(tick, count = 4) {
|
|
|
107
276
|
let wave = "";
|
|
108
277
|
for (let i = 0; i < count; i++) {
|
|
109
278
|
const phase = tick * 0.3 + i * 1.5;
|
|
110
|
-
const idx = Math.floor(
|
|
279
|
+
const idx = Math.floor(
|
|
280
|
+
(Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1),
|
|
281
|
+
);
|
|
111
282
|
wave += WAVE_CHARS[idx];
|
|
112
283
|
}
|
|
113
284
|
return `${MOCHA.executing}${wave}${RESET}`;
|
|
@@ -119,17 +290,33 @@ const DEFAULT_DETAIL_LINES = 10;
|
|
|
119
290
|
const _TIER1_ROWS = 2;
|
|
120
291
|
|
|
121
292
|
const SUMMARY_KEYS = [
|
|
122
|
-
"status",
|
|
123
|
-
"
|
|
293
|
+
"status",
|
|
294
|
+
"lead_action",
|
|
295
|
+
"verdict",
|
|
296
|
+
"files_changed",
|
|
297
|
+
"confidence",
|
|
298
|
+
"risk",
|
|
299
|
+
"detail",
|
|
300
|
+
"error_stage",
|
|
301
|
+
"retryable",
|
|
302
|
+
"partial_output",
|
|
124
303
|
];
|
|
125
304
|
|
|
126
305
|
// ── 레이아웃 브레이크포인트 ──────────────────────────────────────────────
|
|
127
306
|
// 80-119: 28col rail, 120-159: 36col rail, 160+: 균등
|
|
128
307
|
function _resolveRailWidth(totalCols, columnCount) {
|
|
129
308
|
if (columnCount <= 1) return totalCols;
|
|
130
|
-
if (totalCols >= 160)
|
|
131
|
-
|
|
132
|
-
|
|
309
|
+
if (totalCols >= 160)
|
|
310
|
+
return Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount);
|
|
311
|
+
if (totalCols >= 120)
|
|
312
|
+
return Math.min(
|
|
313
|
+
36,
|
|
314
|
+
Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount),
|
|
315
|
+
);
|
|
316
|
+
return Math.min(
|
|
317
|
+
28,
|
|
318
|
+
Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount),
|
|
319
|
+
);
|
|
133
320
|
}
|
|
134
321
|
|
|
135
322
|
function _autoColumnCount(totalCols, workerCount) {
|
|
@@ -139,115 +326,31 @@ function _autoColumnCount(totalCols, workerCount) {
|
|
|
139
326
|
return 1;
|
|
140
327
|
}
|
|
141
328
|
|
|
142
|
-
//
|
|
143
|
-
function clamp(value, min, max) {
|
|
144
|
-
return Math.min(max, Math.max(min, value));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function stripCodeBlocks(text) {
|
|
148
|
-
return String(text || "")
|
|
149
|
-
.replace(/\r/g, "")
|
|
150
|
-
// fenced code blocks
|
|
151
|
-
.replace(/```[\s\S]*?(?:```|$)/g, "\n")
|
|
152
|
-
.replace(/^\s*```.*$/gm, "")
|
|
153
|
-
// indented code blocks (4+ spaces or tab at line start)
|
|
154
|
-
.replace(/^(?: {4}|\t).+$/gm, "")
|
|
155
|
-
// shell prompts: PS C:\...>, >, $
|
|
156
|
-
.replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "")
|
|
157
|
-
.trim();
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function sanitizeTextBlock(text, rawMode = false) {
|
|
161
|
-
const normalized = rawMode ? String(text || "").replace(/\r/g, "") : stripCodeBlocks(text);
|
|
162
|
-
return normalized
|
|
163
|
-
.split("\n")
|
|
164
|
-
.map((line) => line.trim())
|
|
165
|
-
.filter(Boolean)
|
|
166
|
-
.filter((line) => line !== "--- HANDOFF ---")
|
|
167
|
-
.join("\n")
|
|
168
|
-
.trim();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function sanitizeOneLine(text, fallback = "") {
|
|
172
|
-
const normalized = sanitizeTextBlock(text).replace(/\s+/g, " ").trim();
|
|
173
|
-
return normalized || fallback;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function sanitizeFiles(files) {
|
|
177
|
-
if (!files) return [];
|
|
178
|
-
const raw = Array.isArray(files) ? files : String(files).split(",");
|
|
179
|
-
return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function sanitizeFindings(findings) {
|
|
183
|
-
if (!findings) return [];
|
|
184
|
-
const raw = Array.isArray(findings)
|
|
185
|
-
? findings
|
|
186
|
-
: sanitizeTextBlock(findings).split("\n");
|
|
187
|
-
return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function normalizeTokens(tokens) {
|
|
191
|
-
if (tokens === null || tokens === undefined) return "";
|
|
192
|
-
if (typeof tokens === "number" && Number.isFinite(tokens)) return tokens;
|
|
193
|
-
const raw = sanitizeOneLine(tokens);
|
|
194
|
-
if (!raw) return "";
|
|
195
|
-
const match = raw.match(/(\d+(?:[.,]\d+)?\s*[kKmM]?)/);
|
|
196
|
-
return match ? match[1].replace(/\s+/g, "").toLowerCase() : raw;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function formatTokens(tokens) {
|
|
200
|
-
if (tokens === null || tokens === undefined || tokens === "") return "n/a";
|
|
201
|
-
if (typeof tokens === "number" && Number.isFinite(tokens)) {
|
|
202
|
-
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
|
|
203
|
-
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
204
|
-
return `${tokens}`;
|
|
205
|
-
}
|
|
206
|
-
return String(tokens);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ── 색상 헬퍼 ─────────────────────────────────────────────────────────────
|
|
210
|
-
function cliColor(cli) {
|
|
211
|
-
if (cli === "gemini") return FG.gemini;
|
|
212
|
-
if (cli === "claude") return FG.claude;
|
|
213
|
-
if (cli === "codex") return FG.codex;
|
|
214
|
-
return FG.white;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function runtimeStatus(st) {
|
|
218
|
-
return st?.handoff?.status || st?.status || "pending";
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function statusColor(status) {
|
|
222
|
-
if (status === "ok" || status === "completed") return MOCHA.ok;
|
|
223
|
-
if (status === "partial") return MOCHA.partial;
|
|
224
|
-
if (status === "failed") return MOCHA.fail;
|
|
225
|
-
if (status === "running" || status === "in_progress") return MOCHA.executing;
|
|
226
|
-
return FG.muted;
|
|
227
|
-
}
|
|
329
|
+
// 텍스트/상태/색상 유틸은 tui-core.mjs에서 import (위 참조)
|
|
228
330
|
|
|
229
331
|
// ── MOCHA RGB (gradual fade 보간용) ──
|
|
230
332
|
const MOCHA_RGB = {
|
|
231
|
-
ok:
|
|
232
|
-
partial:
|
|
233
|
-
fail:
|
|
333
|
+
ok: { r: 166, g: 227, b: 161 },
|
|
334
|
+
partial: { r: 250, g: 179, b: 135 },
|
|
335
|
+
fail: { r: 243, g: 139, b: 168 },
|
|
234
336
|
executing: { r: 116, g: 199, b: 236 },
|
|
235
|
-
muted:
|
|
236
|
-
border:
|
|
237
|
-
blue:
|
|
238
|
-
sky:
|
|
239
|
-
yellow:
|
|
240
|
-
peach:
|
|
241
|
-
maroon:
|
|
242
|
-
surface0:
|
|
243
|
-
thinking:
|
|
337
|
+
muted: { r: 147, g: 153, b: 178 },
|
|
338
|
+
border: { r: 69, g: 71, b: 90 },
|
|
339
|
+
blue: { r: 137, g: 180, b: 250 },
|
|
340
|
+
sky: { r: 116, g: 199, b: 236 },
|
|
341
|
+
yellow: { r: 249, g: 226, b: 175 },
|
|
342
|
+
peach: { r: 250, g: 179, b: 135 },
|
|
343
|
+
maroon: { r: 235, g: 160, b: 172 },
|
|
344
|
+
surface0: { r: 49, g: 50, b: 68 },
|
|
345
|
+
thinking: { r: 203, g: 166, b: 247 },
|
|
244
346
|
};
|
|
245
347
|
|
|
246
348
|
function statusToRgb(status) {
|
|
247
349
|
if (status === "ok" || status === "completed") return MOCHA_RGB.ok;
|
|
248
350
|
if (status === "partial") return MOCHA_RGB.partial;
|
|
249
351
|
if (status === "failed") return MOCHA_RGB.fail;
|
|
250
|
-
if (status === "running" || status === "in_progress")
|
|
352
|
+
if (status === "running" || status === "in_progress")
|
|
353
|
+
return MOCHA_RGB.executing;
|
|
251
354
|
return MOCHA_RGB.muted;
|
|
252
355
|
}
|
|
253
356
|
|
|
@@ -288,7 +391,7 @@ function _flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
|
|
|
288
391
|
}
|
|
289
392
|
|
|
290
393
|
function easeOutCubic(t) {
|
|
291
|
-
return 1 - (
|
|
394
|
+
return 1 - (1 - t) ** 3;
|
|
292
395
|
}
|
|
293
396
|
|
|
294
397
|
function borderHighlightPosition(width, bodyLines, time = Date.now()) {
|
|
@@ -299,13 +402,20 @@ function borderHighlightPosition(width, bodyLines, time = Date.now()) {
|
|
|
299
402
|
}
|
|
300
403
|
|
|
301
404
|
function titleFlash(status, changeElapsed) {
|
|
302
|
-
const isCompleted =
|
|
303
|
-
|
|
405
|
+
const isCompleted =
|
|
406
|
+
status === "completed" || status === "done" || status === "ok";
|
|
407
|
+
const isFailed =
|
|
408
|
+
status === "failed" || status === "error" || status === "fail";
|
|
304
409
|
if ((!isCompleted && !isFailed) || changeElapsed > 800) return null;
|
|
305
410
|
const flashRgb = isCompleted ? MOCHA_RGB.ok : MOCHA_RGB.fail;
|
|
306
|
-
const bgRgb =
|
|
307
|
-
|
|
308
|
-
|
|
411
|
+
const bgRgb =
|
|
412
|
+
changeElapsed <= 300
|
|
413
|
+
? flashRgb
|
|
414
|
+
: lerpRgb(
|
|
415
|
+
flashRgb,
|
|
416
|
+
MOCHA_RGB.surface0,
|
|
417
|
+
clamp((changeElapsed - 300) / 500, 0, 1),
|
|
418
|
+
);
|
|
309
419
|
return rgbSeq(bgRgb, 48);
|
|
310
420
|
}
|
|
311
421
|
|
|
@@ -324,43 +434,26 @@ function dedupeRole(role, name, cli) {
|
|
|
324
434
|
return r;
|
|
325
435
|
}
|
|
326
436
|
|
|
327
|
-
//
|
|
328
|
-
function wrapLine(text, width) {
|
|
329
|
-
const limit = Math.max(8, width);
|
|
330
|
-
const source = String(text || "").trim();
|
|
331
|
-
if (!source) return [""];
|
|
332
|
-
const words = source.split(/\s+/);
|
|
333
|
-
const lines = [];
|
|
334
|
-
let current = "";
|
|
335
|
-
for (const word of words) {
|
|
336
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
337
|
-
if (wcswidth(candidate) <= limit) { current = candidate; continue; }
|
|
338
|
-
if (current) { lines.push(current); current = ""; }
|
|
339
|
-
if (wcswidth(word) <= limit) { current = word; continue; }
|
|
340
|
-
let offset = 0;
|
|
341
|
-
while (offset < word.length) {
|
|
342
|
-
lines.push(word.slice(offset, offset + limit));
|
|
343
|
-
offset += limit;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (current) lines.push(current);
|
|
347
|
-
return lines.length > 0 ? lines : [source.slice(0, limit)];
|
|
348
|
-
}
|
|
437
|
+
// wrapLine, wrapTextAll → tui-core.mjs에서 import
|
|
349
438
|
|
|
350
|
-
function _wrapText(
|
|
439
|
+
function _wrapText(
|
|
440
|
+
text,
|
|
441
|
+
width,
|
|
442
|
+
maxLines = DEFAULT_DETAIL_LINES,
|
|
443
|
+
rawMode = false,
|
|
444
|
+
) {
|
|
351
445
|
if (maxLines <= 0) return [];
|
|
352
446
|
const input = sanitizeTextBlock(text, rawMode);
|
|
353
447
|
if (!input) return [];
|
|
354
|
-
const wrapped = input
|
|
448
|
+
const wrapped = input
|
|
449
|
+
.split("\n")
|
|
450
|
+
.flatMap((line) => wrapLine(line, width))
|
|
451
|
+
.filter(Boolean);
|
|
355
452
|
if (wrapped.length <= maxLines) return wrapped;
|
|
356
|
-
return [
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
function wrapTextAll(text, width, rawMode = false) {
|
|
361
|
-
const input = sanitizeTextBlock(text, rawMode);
|
|
362
|
-
if (!input) return [];
|
|
363
|
-
return input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
|
|
453
|
+
return [
|
|
454
|
+
...wrapped.slice(0, maxLines - 1),
|
|
455
|
+
truncate(wrapped[wrapped.length - 1], width),
|
|
456
|
+
];
|
|
364
457
|
}
|
|
365
458
|
|
|
366
459
|
// ── virtual row buffer ────────────────────────────────────────────────────
|
|
@@ -388,46 +481,53 @@ class RowBuffer {
|
|
|
388
481
|
this._prev = [...this._rows];
|
|
389
482
|
}
|
|
390
483
|
|
|
391
|
-
get rows() {
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
function countStatuses(names, workers) {
|
|
397
|
-
let ok = 0, partial = 0, failed = 0, running = 0;
|
|
398
|
-
for (const name of names) {
|
|
399
|
-
const st = workers.get(name);
|
|
400
|
-
const s = runtimeStatus(st);
|
|
401
|
-
if (s === "ok" || s === "completed") ok++;
|
|
402
|
-
else if (s === "partial") partial++;
|
|
403
|
-
else if (s === "failed") failed++;
|
|
404
|
-
else if (s === "running" || s === "in_progress") running++;
|
|
484
|
+
get rows() {
|
|
485
|
+
return this._rows;
|
|
486
|
+
}
|
|
487
|
+
get prevLen() {
|
|
488
|
+
return this._prev.length;
|
|
405
489
|
}
|
|
406
|
-
return { ok, partial, failed, running };
|
|
407
490
|
}
|
|
408
491
|
|
|
492
|
+
// countStatuses → tui-core.mjs에서 import
|
|
493
|
+
|
|
409
494
|
// ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
|
|
410
495
|
function phaseColor(phase, time = Date.now()) {
|
|
411
496
|
const shimmer = currentShimmer(time);
|
|
412
|
-
if (phase === "exec" || phase === "executing")
|
|
413
|
-
|
|
414
|
-
if (phase === "
|
|
497
|
+
if (phase === "exec" || phase === "executing")
|
|
498
|
+
return rgbSeq(lerpRgb(MOCHA_RGB.blue, MOCHA_RGB.sky, shimmer));
|
|
499
|
+
if (phase === "verify" || phase === "verifying")
|
|
500
|
+
return rgbSeq(lerpRgb(MOCHA_RGB.yellow, MOCHA_RGB.peach, shimmer));
|
|
501
|
+
if (phase === "fix" || phase === "fixing")
|
|
502
|
+
return rgbSeq(lerpRgb(MOCHA_RGB.fail, MOCHA_RGB.maroon, shimmer));
|
|
415
503
|
return FG.accent;
|
|
416
504
|
}
|
|
417
505
|
|
|
418
|
-
function buildTier1(
|
|
506
|
+
function buildTier1(
|
|
507
|
+
names,
|
|
508
|
+
workers,
|
|
509
|
+
pipeline,
|
|
510
|
+
elapsed,
|
|
511
|
+
width,
|
|
512
|
+
version,
|
|
513
|
+
time = Date.now(),
|
|
514
|
+
) {
|
|
419
515
|
const { ok, partial, failed, running } = countStatuses(names, workers);
|
|
420
516
|
const phase = pipeline.phase || "exec";
|
|
421
517
|
const row1 = truncate(
|
|
422
518
|
`${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase, time))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
|
|
423
|
-
|
|
519
|
+
`${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)}${running > 0 ? ` ${activityWave(spinnerTick)}` : ""}`,
|
|
424
520
|
width,
|
|
425
521
|
);
|
|
426
|
-
const keysHint = color(
|
|
522
|
+
const keysHint = color(
|
|
523
|
+
"Tab:focus • j/k:nav • gg/G:jump • /:search • n/N:next • H/L:resize • f:follow • l:tab",
|
|
524
|
+
MOCHA.subtext,
|
|
525
|
+
);
|
|
427
526
|
const hintWidth = wcswidth(stripAnsi(keysHint));
|
|
428
|
-
const row2 =
|
|
429
|
-
|
|
430
|
-
|
|
527
|
+
const row2 =
|
|
528
|
+
hintWidth >= width
|
|
529
|
+
? truncate(keysHint, width)
|
|
530
|
+
: padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
|
|
431
531
|
return [row1, row2];
|
|
432
532
|
}
|
|
433
533
|
|
|
@@ -437,7 +537,8 @@ function detailText(st) {
|
|
|
437
537
|
const lines = [];
|
|
438
538
|
for (const key of SUMMARY_KEYS) {
|
|
439
539
|
const value = st.handoff?.[key];
|
|
440
|
-
if (Array.isArray(value) && value.length > 0)
|
|
540
|
+
if (Array.isArray(value) && value.length > 0)
|
|
541
|
+
lines.push(`${key}: ${value.join(", ")}`);
|
|
441
542
|
else if (value) lines.push(`${key}: ${value}`);
|
|
442
543
|
}
|
|
443
544
|
if (st.snapshot) lines.unshift(st.snapshot);
|
|
@@ -452,7 +553,10 @@ function detailHighlights(st) {
|
|
|
452
553
|
.map((line) => line.replace(/^verdict\s*:\s*/i, "").trim())
|
|
453
554
|
.filter(Boolean)
|
|
454
555
|
.filter((line) => line !== verdict)
|
|
455
|
-
.filter(
|
|
556
|
+
.filter(
|
|
557
|
+
(line) =>
|
|
558
|
+
!SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)),
|
|
559
|
+
)
|
|
456
560
|
.slice(0, 2);
|
|
457
561
|
}
|
|
458
562
|
|
|
@@ -460,7 +564,7 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
460
564
|
const {
|
|
461
565
|
width,
|
|
462
566
|
selected = false,
|
|
463
|
-
focused = false,
|
|
567
|
+
focused = false, // rail 포커스 여부
|
|
464
568
|
previousSelected = false,
|
|
465
569
|
_rawMode = false,
|
|
466
570
|
compact = false,
|
|
@@ -471,19 +575,29 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
471
575
|
const role = sanitizeOneLine(st.role);
|
|
472
576
|
const status = runtimeStatus(st);
|
|
473
577
|
const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
|
|
474
|
-
const changeElapsed = st._statusChangedAt
|
|
578
|
+
const changeElapsed = st._statusChangedAt
|
|
579
|
+
? Math.max(0, time - st._statusChangedAt)
|
|
580
|
+
: Number.POSITIVE_INFINITY;
|
|
475
581
|
|
|
476
582
|
// Tier2 행 1: 이름 + CLI + role
|
|
477
583
|
const selMark = selected
|
|
478
|
-
?
|
|
584
|
+
? focused
|
|
585
|
+
? color("▶", MOCHA.blue)
|
|
586
|
+
: color(">", FG.triflux)
|
|
479
587
|
: previousSelected
|
|
480
588
|
? dim("~")
|
|
481
589
|
: " ";
|
|
482
|
-
const hb = heartbeat(
|
|
590
|
+
const hb = heartbeat(
|
|
591
|
+
status,
|
|
592
|
+
status === "running" ? currentShimmer(time) : 0,
|
|
593
|
+
st._statusChangedAt,
|
|
594
|
+
time,
|
|
595
|
+
);
|
|
483
596
|
// host 배지 (원격 워커용)
|
|
484
|
-
const hostBadge =
|
|
485
|
-
|
|
486
|
-
|
|
597
|
+
const hostBadge =
|
|
598
|
+
st.host && st.host !== "local"
|
|
599
|
+
? color(`[${st.host}]`, MOCHA.mauve) + " "
|
|
600
|
+
: "";
|
|
487
601
|
const displayRole = dedupeRole(role, name, cli);
|
|
488
602
|
const title = truncate(
|
|
489
603
|
`${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
|
|
@@ -491,7 +605,9 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
491
605
|
);
|
|
492
606
|
|
|
493
607
|
const cardWidth = Math.max(MIN_CARD_WIDTH, width);
|
|
494
|
-
const borderHighlight = focused
|
|
608
|
+
const borderHighlight = focused
|
|
609
|
+
? borderHighlightPosition(cardWidth, compact ? 2 : 6, time)
|
|
610
|
+
: undefined;
|
|
495
611
|
const titleFlashBg = titleFlash(status, changeElapsed);
|
|
496
612
|
|
|
497
613
|
// status-specific border: focused→mauve, selected→bright, non-selected→glow decay
|
|
@@ -502,37 +618,58 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
502
618
|
}
|
|
503
619
|
if (selected) return statusColor(status);
|
|
504
620
|
const from = statusToRgb(status);
|
|
505
|
-
const decayBase = st._statusChangedAt
|
|
621
|
+
const decayBase = st._statusChangedAt
|
|
622
|
+
? clamp(changeElapsed / CARD_GLOW_MS, 0, 1)
|
|
623
|
+
: 1;
|
|
506
624
|
const decayT = easeOutCubic(decayBase);
|
|
507
|
-
return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 +
|
|
625
|
+
return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + 0.5 * decayT));
|
|
508
626
|
})();
|
|
509
627
|
|
|
510
628
|
if (compact) {
|
|
511
629
|
// compact 2-line 카드
|
|
512
|
-
const progress = Number.isFinite(st.progress)
|
|
630
|
+
const progress = Number.isFinite(st.progress)
|
|
631
|
+
? clamp(st.progress, 0, 1)
|
|
632
|
+
: status === "running"
|
|
633
|
+
? 0.3
|
|
634
|
+
: 1;
|
|
513
635
|
const percent = Math.round(progress * 100);
|
|
514
636
|
const compactLine1 = truncate(
|
|
515
637
|
`${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
|
|
516
638
|
innerWidth,
|
|
517
639
|
);
|
|
518
|
-
const verdict = sanitizeOneLine(
|
|
640
|
+
const verdict = sanitizeOneLine(
|
|
641
|
+
st.handoff?.verdict || st.summary || st.snapshot,
|
|
642
|
+
status,
|
|
643
|
+
);
|
|
519
644
|
const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
|
|
520
|
-
const framed = box(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
645
|
+
const framed = box(
|
|
646
|
+
[compactLine1, compactLine2],
|
|
647
|
+
cardWidth,
|
|
648
|
+
statusBorderColor,
|
|
649
|
+
{
|
|
650
|
+
highlightPos: borderHighlight,
|
|
651
|
+
titleFlashBg,
|
|
652
|
+
},
|
|
653
|
+
);
|
|
524
654
|
return [framed.top, ...framed.body, framed.bot];
|
|
525
655
|
}
|
|
526
656
|
|
|
527
657
|
// Tier2 행 2: 상태 배지 + elapsed + tokens + conf
|
|
528
|
-
const confidence = sanitizeOneLine(
|
|
658
|
+
const confidence = sanitizeOneLine(
|
|
659
|
+
st.handoff?.confidence || st.confidence,
|
|
660
|
+
"n/a",
|
|
661
|
+
);
|
|
529
662
|
const statusLine = truncate(
|
|
530
663
|
`${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)}`,
|
|
531
664
|
innerWidth,
|
|
532
665
|
);
|
|
533
666
|
|
|
534
667
|
// Tier2 행 3: progress bar
|
|
535
|
-
const progress = Number.isFinite(st.progress)
|
|
668
|
+
const progress = Number.isFinite(st.progress)
|
|
669
|
+
? clamp(st.progress, 0, 1)
|
|
670
|
+
: status === "running"
|
|
671
|
+
? 0.3
|
|
672
|
+
: 1;
|
|
536
673
|
const percent = Math.round(progress * 100);
|
|
537
674
|
const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
|
|
538
675
|
const bar = progressBar(percent, barWidth, time);
|
|
@@ -542,18 +679,33 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
542
679
|
);
|
|
543
680
|
|
|
544
681
|
// Tier2 행 4-6: verdict / findings / files
|
|
545
|
-
const verdict = sanitizeOneLine(
|
|
546
|
-
|
|
547
|
-
|
|
682
|
+
const verdict = sanitizeOneLine(
|
|
683
|
+
st.handoff?.verdict || st.summary || st.snapshot,
|
|
684
|
+
status,
|
|
685
|
+
);
|
|
686
|
+
const findings =
|
|
687
|
+
detailHighlights(st).join(" / ") || "no notable findings yet";
|
|
688
|
+
const files =
|
|
689
|
+
sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") ||
|
|
690
|
+
"none";
|
|
548
691
|
|
|
549
692
|
const verdictClr = statusColor(status);
|
|
550
693
|
const lines = [
|
|
551
694
|
title,
|
|
552
695
|
statusLine,
|
|
553
696
|
progressLine,
|
|
554
|
-
truncate(
|
|
555
|
-
|
|
556
|
-
|
|
697
|
+
truncate(
|
|
698
|
+
`${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`,
|
|
699
|
+
innerWidth,
|
|
700
|
+
),
|
|
701
|
+
truncate(
|
|
702
|
+
`${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`,
|
|
703
|
+
innerWidth,
|
|
704
|
+
),
|
|
705
|
+
truncate(
|
|
706
|
+
`${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`,
|
|
707
|
+
innerWidth,
|
|
708
|
+
),
|
|
557
709
|
];
|
|
558
710
|
|
|
559
711
|
const framed = box(lines, cardWidth, statusBorderColor, {
|
|
@@ -577,23 +729,47 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
577
729
|
const innerWidth = Math.max(12, width - 4);
|
|
578
730
|
|
|
579
731
|
// verdict sticky 4행
|
|
580
|
-
const verdict = sanitizeOneLine(
|
|
581
|
-
|
|
732
|
+
const verdict = sanitizeOneLine(
|
|
733
|
+
st.handoff?.verdict || st.summary || st.snapshot,
|
|
734
|
+
"—",
|
|
735
|
+
);
|
|
736
|
+
const confidence = sanitizeOneLine(
|
|
737
|
+
st.handoff?.confidence || st.confidence,
|
|
738
|
+
"n/a",
|
|
739
|
+
);
|
|
582
740
|
const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
|
|
583
741
|
const status = runtimeStatus(st);
|
|
584
742
|
|
|
585
743
|
// Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
|
|
586
744
|
const activeTab = opts.activeTab || "log";
|
|
587
|
-
const tabLog =
|
|
588
|
-
|
|
589
|
-
|
|
745
|
+
const tabLog =
|
|
746
|
+
activeTab === "log"
|
|
747
|
+
? `${MOCHA.blue}${bold("[Log]")}`
|
|
748
|
+
: color("[Log]", MOCHA.overlay);
|
|
749
|
+
const tabDetail =
|
|
750
|
+
activeTab === "detail"
|
|
751
|
+
? `${MOCHA.blue}${bold("[Detail]")}`
|
|
752
|
+
: color("[Detail]", MOCHA.overlay);
|
|
753
|
+
const tabFiles =
|
|
754
|
+
activeTab === "files"
|
|
755
|
+
? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}`
|
|
756
|
+
: color(`[Files ${files.length}]`, MOCHA.overlay);
|
|
590
757
|
const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
|
|
591
758
|
|
|
592
759
|
const stickyLines = [
|
|
593
|
-
truncate(
|
|
760
|
+
truncate(
|
|
761
|
+
`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`,
|
|
762
|
+
innerWidth,
|
|
763
|
+
),
|
|
594
764
|
tabBar,
|
|
595
|
-
truncate(
|
|
596
|
-
|
|
765
|
+
truncate(
|
|
766
|
+
`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`,
|
|
767
|
+
innerWidth,
|
|
768
|
+
),
|
|
769
|
+
truncate(
|
|
770
|
+
`${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`,
|
|
771
|
+
innerWidth,
|
|
772
|
+
),
|
|
597
773
|
color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
|
|
598
774
|
];
|
|
599
775
|
|
|
@@ -605,17 +781,22 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
605
781
|
const summaryLines = [];
|
|
606
782
|
for (const key of SUMMARY_KEYS) {
|
|
607
783
|
const value = st.handoff?.[key];
|
|
608
|
-
if (Array.isArray(value) && value.length > 0)
|
|
784
|
+
if (Array.isArray(value) && value.length > 0)
|
|
785
|
+
summaryLines.push(`${key}: ${value.join(", ")}`);
|
|
609
786
|
else if (value) summaryLines.push(`${key}: ${value}`);
|
|
610
787
|
}
|
|
611
|
-
allBodyLines =
|
|
612
|
-
|
|
613
|
-
|
|
788
|
+
allBodyLines =
|
|
789
|
+
summaryLines.length > 0
|
|
790
|
+
? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
|
|
791
|
+
: [dim("no structured data")];
|
|
614
792
|
} else if (activeTab === "files") {
|
|
615
|
-
const filesList = sanitizeFiles(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
793
|
+
const filesList = sanitizeFiles(
|
|
794
|
+
st.handoff?.files_changed || st.files_changed,
|
|
795
|
+
);
|
|
796
|
+
allBodyLines =
|
|
797
|
+
filesList.length > 0
|
|
798
|
+
? filesList.map((f, i) => `${i + 1}. ${f}`)
|
|
799
|
+
: [dim("no files changed")];
|
|
619
800
|
} else {
|
|
620
801
|
allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
|
|
621
802
|
}
|
|
@@ -624,16 +805,24 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
624
805
|
if (followTail) {
|
|
625
806
|
startIdx = Math.max(0, allBodyLines.length - bodyAvail);
|
|
626
807
|
} else {
|
|
627
|
-
startIdx = clamp(
|
|
808
|
+
startIdx = clamp(
|
|
809
|
+
scrollOffset,
|
|
810
|
+
0,
|
|
811
|
+
Math.max(0, allBodyLines.length - bodyAvail),
|
|
812
|
+
);
|
|
628
813
|
}
|
|
629
814
|
|
|
630
815
|
const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
|
|
631
816
|
if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
|
|
632
817
|
|
|
633
818
|
// scroll indicator — MOCHA.overlay for position
|
|
634
|
-
const scrollInfo =
|
|
635
|
-
|
|
636
|
-
|
|
819
|
+
const scrollInfo =
|
|
820
|
+
allBodyLines.length > bodyAvail
|
|
821
|
+
? color(
|
|
822
|
+
`${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`,
|
|
823
|
+
MOCHA.overlay,
|
|
824
|
+
)
|
|
825
|
+
: color(`${allBodyLines.length} lines`, MOCHA.overlay);
|
|
637
826
|
|
|
638
827
|
const contentLines = [
|
|
639
828
|
...stickyLines,
|
|
@@ -647,23 +836,49 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
647
836
|
: MOCHA.border;
|
|
648
837
|
const paneWidth = Math.max(MIN_CARD_WIDTH, width);
|
|
649
838
|
const framed = box(contentLines, paneWidth, borderColor, {
|
|
650
|
-
highlightPos: focused
|
|
839
|
+
highlightPos: focused
|
|
840
|
+
? borderHighlightPosition(paneWidth, contentLines.length, time)
|
|
841
|
+
: undefined,
|
|
651
842
|
});
|
|
652
843
|
return [framed.top, ...framed.body, framed.bot];
|
|
653
844
|
}
|
|
654
845
|
|
|
655
846
|
// ── summary bar (≥4 workers) ──────────────────────────────────────────────
|
|
656
|
-
function buildSummaryBar(
|
|
657
|
-
|
|
847
|
+
function buildSummaryBar(
|
|
848
|
+
names,
|
|
849
|
+
workers,
|
|
850
|
+
selectedWorker,
|
|
851
|
+
pipeline,
|
|
852
|
+
width,
|
|
853
|
+
version,
|
|
854
|
+
) {
|
|
855
|
+
const maxChipWidth = clamp(
|
|
856
|
+
Math.floor((width - 6) / Math.min(names.length, 4)),
|
|
857
|
+
16,
|
|
858
|
+
26,
|
|
859
|
+
);
|
|
658
860
|
const chips = names.map((name, idx) => {
|
|
659
861
|
const st = workers.get(name);
|
|
660
862
|
const status = runtimeStatus(st);
|
|
661
|
-
const progress = Number.isFinite(st.progress)
|
|
863
|
+
const progress = Number.isFinite(st.progress)
|
|
864
|
+
? clamp(st.progress, 0, 1)
|
|
865
|
+
: status === "running"
|
|
866
|
+
? 0.3
|
|
867
|
+
: 1;
|
|
662
868
|
const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
|
|
663
869
|
return padRight(truncate(label, maxChipWidth), maxChipWidth);
|
|
664
870
|
});
|
|
665
|
-
const chipsLine = truncate(
|
|
666
|
-
|
|
871
|
+
const chipsLine = truncate(
|
|
872
|
+
chips.join(color(" │ ", MOCHA.overlay)),
|
|
873
|
+
width - 4,
|
|
874
|
+
);
|
|
875
|
+
const keysLine = truncate(
|
|
876
|
+
color(
|
|
877
|
+
"Tab:focus • j/k:nav • gg/G:jump • /:search • n/N:next • H/L:resize • f:follow • l:tab",
|
|
878
|
+
MOCHA.subtext,
|
|
879
|
+
),
|
|
880
|
+
width - 4,
|
|
881
|
+
);
|
|
667
882
|
const framed = box([chipsLine, keysLine], width);
|
|
668
883
|
return [framed.top, ...framed.body, framed.bot];
|
|
669
884
|
}
|
|
@@ -696,7 +911,9 @@ function buildHelpOverlay(width, height) {
|
|
|
696
911
|
const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
|
|
697
912
|
const framedRows = [framed.top, ...framed.body, framed.bot];
|
|
698
913
|
const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
|
|
699
|
-
const leftPad = " ".repeat(
|
|
914
|
+
const leftPad = " ".repeat(
|
|
915
|
+
Math.max(0, Math.floor((width - innerWidth - 4) / 2)),
|
|
916
|
+
);
|
|
700
917
|
const result = [];
|
|
701
918
|
for (let i = 0; i < height; i++) {
|
|
702
919
|
const fi = i - topPad;
|
|
@@ -714,52 +931,23 @@ function _joinColumns(blocks, gap = GRID_GAP) {
|
|
|
714
931
|
const maxHeight = Math.max(...blocks.map((b) => b.length));
|
|
715
932
|
return Array.from({ length: maxHeight }, (_, rowIdx) =>
|
|
716
933
|
blocks
|
|
717
|
-
.map(
|
|
934
|
+
.map(
|
|
935
|
+
(block) =>
|
|
936
|
+
block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))),
|
|
937
|
+
)
|
|
718
938
|
.join(" ".repeat(gap)),
|
|
719
939
|
);
|
|
720
940
|
}
|
|
721
941
|
|
|
722
942
|
// ── normalizeWorkerState ──────────────────────────────────────────────────
|
|
723
943
|
function normalizeWorkerState(existing, state) {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
: existing.handoff?.verdict,
|
|
732
|
-
files_changed: state.handoff?.files_changed !== undefined
|
|
733
|
-
? sanitizeFiles(state.handoff.files_changed)
|
|
734
|
-
: existing.handoff?.files_changed,
|
|
735
|
-
confidence: state.handoff?.confidence !== undefined
|
|
736
|
-
? sanitizeOneLine(state.handoff.confidence)
|
|
737
|
-
: existing.handoff?.confidence,
|
|
738
|
-
status: state.handoff?.status !== undefined
|
|
739
|
-
? sanitizeOneLine(state.handoff.status)
|
|
740
|
-
: existing.handoff?.status,
|
|
741
|
-
};
|
|
742
|
-
|
|
743
|
-
return {
|
|
744
|
-
...existing,
|
|
745
|
-
...state,
|
|
746
|
-
cli: state.cli !== undefined ? sanitizeOneLine(state.cli, existing.cli || "codex") : (existing.cli || "codex"),
|
|
747
|
-
role: state.role !== undefined ? sanitizeOneLine(state.role) : existing.role,
|
|
748
|
-
status: state.status !== undefined ? sanitizeOneLine(state.status, existing.status || "pending") : (existing.status || "pending"),
|
|
749
|
-
snapshot: state.snapshot !== undefined ? sanitizeTextBlock(state.snapshot) : existing.snapshot,
|
|
750
|
-
summary: state.summary !== undefined ? sanitizeTextBlock(state.summary) : existing.summary,
|
|
751
|
-
detail: state.detail !== undefined ? sanitizeTextBlock(state.detail) : existing.detail,
|
|
752
|
-
findings: state.findings !== undefined ? sanitizeFindings(state.findings) : existing.findings,
|
|
753
|
-
files_changed: state.files_changed !== undefined ? sanitizeFiles(state.files_changed) : existing.files_changed,
|
|
754
|
-
confidence: state.confidence !== undefined ? sanitizeOneLine(state.confidence) : existing.confidence,
|
|
755
|
-
tokens: state.tokens !== undefined ? normalizeTokens(state.tokens) : existing.tokens,
|
|
756
|
-
progress: state.progress !== undefined ? clamp(Number(state.progress) || 0, 0, 1) : existing.progress,
|
|
757
|
-
handoff: nextHandoff,
|
|
758
|
-
_prevStatus: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
|
|
759
|
-
? existing.status : existing._prevStatus,
|
|
760
|
-
_statusChangedAt: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
|
|
761
|
-
? Date.now() : (existing._statusChangedAt || 0),
|
|
762
|
-
};
|
|
944
|
+
return coreNormalizeWorkerState(
|
|
945
|
+
existing || { cli: "codex", status: "pending" },
|
|
946
|
+
state,
|
|
947
|
+
{
|
|
948
|
+
trackChanges: true,
|
|
949
|
+
},
|
|
950
|
+
);
|
|
763
951
|
}
|
|
764
952
|
|
|
765
953
|
// ── createLogDashboard ────────────────────────────────────────────────────
|
|
@@ -774,6 +962,7 @@ function normalizeWorkerState(existing, state) {
|
|
|
774
962
|
* @returns {LogDashboardHandle}
|
|
775
963
|
*/
|
|
776
964
|
export function createLogDashboard(opts = {}) {
|
|
965
|
+
const deps = opts.deps || {};
|
|
777
966
|
const {
|
|
778
967
|
stream = process.stdout,
|
|
779
968
|
input = process.stdin,
|
|
@@ -784,10 +973,26 @@ export function createLogDashboard(opts = {}) {
|
|
|
784
973
|
} = opts;
|
|
785
974
|
|
|
786
975
|
const isTTY = forceTTY || !!stream?.isTTY;
|
|
976
|
+
const now = deps.now || Date.now;
|
|
977
|
+
const setTimeoutFn = deps.setTimeout || setTimeout;
|
|
978
|
+
const clearTimeoutFn = deps.clearTimeout || clearTimeout;
|
|
979
|
+
const openTab =
|
|
980
|
+
deps.openTab ||
|
|
981
|
+
((request) => {
|
|
982
|
+
const wtm = createWtManager();
|
|
983
|
+
return wtm.createTab({ title: request.title, command: request.command });
|
|
984
|
+
});
|
|
985
|
+
const attachLimiter =
|
|
986
|
+
deps.attachLimiter ||
|
|
987
|
+
createTransientTabLimiter({
|
|
988
|
+
now,
|
|
989
|
+
ttlMs: deps.attachTabTtlMs,
|
|
990
|
+
limits: deps.attachLimits,
|
|
991
|
+
});
|
|
787
992
|
|
|
788
993
|
const workers = new Map();
|
|
789
994
|
let pipeline = { phase: "exec", fix_attempt: 0 };
|
|
790
|
-
let startedAt =
|
|
995
|
+
let startedAt = now();
|
|
791
996
|
let timer = null;
|
|
792
997
|
let closed = false;
|
|
793
998
|
let frameCount = 0;
|
|
@@ -803,6 +1008,27 @@ export function createLogDashboard(opts = {}) {
|
|
|
803
1008
|
let inputAttached = false;
|
|
804
1009
|
let rawModeEnabled = false;
|
|
805
1010
|
|
|
1011
|
+
// UX 위젯 (ISSUE-14)
|
|
1012
|
+
const tokenTracker = createTokenTracker();
|
|
1013
|
+
const searchState = createSearchState();
|
|
1014
|
+
const vimMotion = createVimMotion();
|
|
1015
|
+
const panelResizer = createPanelResizer({
|
|
1016
|
+
initialRatio: focus === "detail" ? 0.2 : 0.3,
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// Synapse 실시간 관제 (Phase 3)
|
|
1020
|
+
const synapseMetrics = deps.synapseMetrics || createMetricsCollector();
|
|
1021
|
+
const synapseStream =
|
|
1022
|
+
deps.synapseStream ||
|
|
1023
|
+
createSynapseEventStream({
|
|
1024
|
+
onEvent(event) {
|
|
1025
|
+
synapseMetrics.ingest(event);
|
|
1026
|
+
},
|
|
1027
|
+
fetchImpl: deps.synapseFetch,
|
|
1028
|
+
});
|
|
1029
|
+
// Synapse 자동 시작 (옵트인: deps.enableSynapse)
|
|
1030
|
+
if (deps.enableSynapse) synapseStream.start();
|
|
1031
|
+
|
|
806
1032
|
// virtual row buffer (altScreen 전용)
|
|
807
1033
|
const rowBuf = new RowBuffer();
|
|
808
1034
|
|
|
@@ -816,22 +1042,26 @@ export function createLogDashboard(opts = {}) {
|
|
|
816
1042
|
}
|
|
817
1043
|
|
|
818
1044
|
function nowElapsedSec() {
|
|
819
|
-
return Math.max(0, Math.round((
|
|
1045
|
+
return Math.max(0, Math.round((now() - startedAt) / 1000));
|
|
820
1046
|
}
|
|
821
1047
|
|
|
822
1048
|
function getViewportColumns() {
|
|
823
1049
|
const v = Number.isFinite(columns)
|
|
824
1050
|
? columns
|
|
825
|
-
:
|
|
826
|
-
|
|
827
|
-
|
|
1051
|
+
: Number.isFinite(stream?.columns)
|
|
1052
|
+
? stream.columns
|
|
1053
|
+
: Number.isFinite(process.stdout?.columns)
|
|
1054
|
+
? process.stdout.columns
|
|
1055
|
+
: FALLBACK_COLUMNS;
|
|
828
1056
|
return Math.max(48, v || FALLBACK_COLUMNS);
|
|
829
1057
|
}
|
|
830
1058
|
|
|
831
1059
|
function getViewportRows() {
|
|
832
1060
|
const v = Number.isFinite(stream?.rows)
|
|
833
1061
|
? stream.rows
|
|
834
|
-
:
|
|
1062
|
+
: Number.isFinite(process.stdout?.rows)
|
|
1063
|
+
? process.stdout.rows
|
|
1064
|
+
: FALLBACK_ROWS;
|
|
835
1065
|
return Math.max(10, v || FALLBACK_ROWS);
|
|
836
1066
|
}
|
|
837
1067
|
|
|
@@ -840,8 +1070,12 @@ export function createLogDashboard(opts = {}) {
|
|
|
840
1070
|
}
|
|
841
1071
|
|
|
842
1072
|
function ensureSelectedWorker(names) {
|
|
843
|
-
if (names.length === 0) {
|
|
844
|
-
|
|
1073
|
+
if (names.length === 0) {
|
|
1074
|
+
selectedWorker = null;
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (!selectedWorker || !workers.has(selectedWorker))
|
|
1078
|
+
selectedWorker = names[0];
|
|
845
1079
|
}
|
|
846
1080
|
|
|
847
1081
|
function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
|
|
@@ -885,8 +1119,11 @@ export function createLogDashboard(opts = {}) {
|
|
|
885
1119
|
function doClose() {
|
|
886
1120
|
if (closed) return;
|
|
887
1121
|
if (timer) clearInterval(timer);
|
|
888
|
-
|
|
889
|
-
if (
|
|
1122
|
+
synapseStream.stop();
|
|
1123
|
+
if (inputAttached && typeof input?.off === "function")
|
|
1124
|
+
input.off("data", handleInput);
|
|
1125
|
+
if (rawModeEnabled && typeof input?.setRawMode === "function")
|
|
1126
|
+
input.setRawMode(false);
|
|
890
1127
|
if (inputAttached && typeof input?.pause === "function") input.pause();
|
|
891
1128
|
exitAltScreen();
|
|
892
1129
|
closed = true;
|
|
@@ -895,7 +1132,18 @@ export function createLogDashboard(opts = {}) {
|
|
|
895
1132
|
// ── 키 입력 ──────────────────────────────────────────────────────────
|
|
896
1133
|
function handleInput(chunk) {
|
|
897
1134
|
const key = String(chunk);
|
|
898
|
-
if (key === "\u0003")
|
|
1135
|
+
if (key === "\u0003") {
|
|
1136
|
+
doClose();
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// 검색 모드 활성 중: 키를 검색 상태에 위임
|
|
1141
|
+
if (searchState.active) {
|
|
1142
|
+
if (searchState.handleKey(key)) {
|
|
1143
|
+
render();
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
899
1147
|
|
|
900
1148
|
// Help overlay: 아무 키나 누르면 닫기
|
|
901
1149
|
if (helpOverlay) {
|
|
@@ -910,7 +1158,10 @@ export function createLogDashboard(opts = {}) {
|
|
|
910
1158
|
const w = workers.get(selectedWorker);
|
|
911
1159
|
if (!w) return;
|
|
912
1160
|
const sessionTarget = w.sessionName || w.paneName;
|
|
913
|
-
if (!sessionTarget)
|
|
1161
|
+
if (!sessionTarget) {
|
|
1162
|
+
showFlash("세션 정보 없음 — 아직 준비 중");
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
914
1165
|
attachToSession(w);
|
|
915
1166
|
return;
|
|
916
1167
|
}
|
|
@@ -923,33 +1174,97 @@ export function createLogDashboard(opts = {}) {
|
|
|
923
1174
|
}
|
|
924
1175
|
|
|
925
1176
|
// Shift+Arrow: 포커스 이동 + 워커 선택
|
|
926
|
-
if (key === "\x1b[1;2A") {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1177
|
+
if (key === "\x1b[1;2A") {
|
|
1178
|
+
selectRelative(-1);
|
|
1179
|
+
return;
|
|
1180
|
+
} // Shift+Up → 워커 위
|
|
1181
|
+
if (key === "\x1b[1;2B") {
|
|
1182
|
+
selectRelative(1);
|
|
1183
|
+
return;
|
|
1184
|
+
} // Shift+Down → 워커 아래
|
|
1185
|
+
if (key === "\x1b[1;2D") {
|
|
1186
|
+
focus = "rail";
|
|
1187
|
+
render();
|
|
1188
|
+
return;
|
|
1189
|
+
} // Shift+Left → rail
|
|
1190
|
+
if (key === "\x1b[1;2C") {
|
|
1191
|
+
focus = "detail";
|
|
1192
|
+
render();
|
|
1193
|
+
return;
|
|
1194
|
+
} // Shift+Right → detail
|
|
930
1195
|
|
|
931
1196
|
if (focus === "detail") {
|
|
932
1197
|
// detail 포커스: j/k/ArrowDown/Up = 스크롤
|
|
933
|
-
if (key === "j" || key === "\u001b[B") {
|
|
934
|
-
|
|
1198
|
+
if (key === "j" || key === "\u001b[B") {
|
|
1199
|
+
scrollDetail(1);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (key === "k" || key === "\u001b[A") {
|
|
1203
|
+
scrollDetail(-1);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
935
1206
|
} else {
|
|
936
1207
|
// rail 포커스: j/k = 워커 선택
|
|
937
|
-
if (key === "j" || key === "\u001b[B") {
|
|
938
|
-
|
|
1208
|
+
if (key === "j" || key === "\u001b[B") {
|
|
1209
|
+
selectRelative(1);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (key === "k" || key === "\u001b[A") {
|
|
1213
|
+
selectRelative(-1);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
939
1216
|
}
|
|
940
1217
|
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1218
|
+
// vim 모션: gg(첫 워커/상단), G(마지막 워커/하단)
|
|
1219
|
+
const motion = vimMotion.handleKey(key);
|
|
1220
|
+
if (motion === "gg") {
|
|
1221
|
+
if (focus === "detail") {
|
|
1222
|
+
followTail = false;
|
|
1223
|
+
detailScrollOffset = 0;
|
|
1224
|
+
} else {
|
|
1225
|
+
const names = visibleWorkerNames();
|
|
1226
|
+
if (names.length > 0) setSelectedWorker(names[0]);
|
|
1227
|
+
}
|
|
1228
|
+
render();
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (motion === "G") {
|
|
1232
|
+
if (focus === "detail") {
|
|
1233
|
+
followTail = true;
|
|
1234
|
+
detailScrollOffset = 0;
|
|
1235
|
+
} else {
|
|
1236
|
+
const names = visibleWorkerNames();
|
|
1237
|
+
if (names.length > 0) setSelectedWorker(names[names.length - 1]);
|
|
1238
|
+
}
|
|
1239
|
+
render();
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
// 단일 g는 vimMotion 대기 → return하지 않음 (이전 동작 유지)
|
|
1243
|
+
if (key === "g") return;
|
|
1244
|
+
|
|
945
1245
|
// PgUp/PgDn: 페이지 단위 스크롤
|
|
946
1246
|
const pageSize = Math.max(1, Math.floor(getViewportRows() / 2));
|
|
947
|
-
if (key === "\x1b[5~") {
|
|
948
|
-
|
|
1247
|
+
if (key === "\x1b[5~") {
|
|
1248
|
+
scrollDetail(-pageSize);
|
|
1249
|
+
return;
|
|
1250
|
+
} // PgUp
|
|
1251
|
+
if (key === "\x1b[6~") {
|
|
1252
|
+
scrollDetail(pageSize);
|
|
1253
|
+
return;
|
|
1254
|
+
} // PgDn
|
|
949
1255
|
// f: follow-tail 토글
|
|
950
|
-
if (key === "f") {
|
|
1256
|
+
if (key === "f") {
|
|
1257
|
+
followTail = !followTail;
|
|
1258
|
+
if (followTail) detailScrollOffset = 0;
|
|
1259
|
+
render();
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
951
1262
|
// r: raw mode 토글
|
|
952
|
-
if (key === "r") {
|
|
1263
|
+
if (key === "r") {
|
|
1264
|
+
rawMode = !rawMode;
|
|
1265
|
+
render();
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
953
1268
|
// l: 탭 전환 (Log → Detail → Files)
|
|
954
1269
|
if (key === "l") {
|
|
955
1270
|
const tabs = ["log", "detail", "files"];
|
|
@@ -958,54 +1273,123 @@ export function createLogDashboard(opts = {}) {
|
|
|
958
1273
|
render();
|
|
959
1274
|
return;
|
|
960
1275
|
}
|
|
961
|
-
//
|
|
962
|
-
if (key === "
|
|
1276
|
+
// /: 검색 모드 활성화 (vim 패턴)
|
|
1277
|
+
if (key === "/") {
|
|
1278
|
+
searchState.activate();
|
|
1279
|
+
render();
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// n: 검색 결과 다음 / 최근 변경 워커
|
|
1283
|
+
if (key === "n") {
|
|
1284
|
+
if (searchState.query) {
|
|
1285
|
+
const names = visibleWorkerNames();
|
|
1286
|
+
const idx = names.indexOf(selectedWorker);
|
|
1287
|
+
const match = searchState.findMatch(names, idx, 1);
|
|
1288
|
+
if (match >= 0) {
|
|
1289
|
+
setSelectedWorker(names[match]);
|
|
1290
|
+
render();
|
|
1291
|
+
} else {
|
|
1292
|
+
showFlash(`검색 결과 없음: ${searchState.query}`);
|
|
1293
|
+
}
|
|
1294
|
+
} else {
|
|
1295
|
+
selectMostRecentChangedWorker();
|
|
1296
|
+
}
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
// N: 검색 결과 이전
|
|
1300
|
+
if (key === "N") {
|
|
1301
|
+
if (searchState.query) {
|
|
1302
|
+
const names = visibleWorkerNames();
|
|
1303
|
+
const idx = names.indexOf(selectedWorker);
|
|
1304
|
+
const match = searchState.findMatch(names, idx, -1);
|
|
1305
|
+
if (match >= 0) {
|
|
1306
|
+
setSelectedWorker(names[match]);
|
|
1307
|
+
render();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
// H: rail 축소 (패널 리사이즈)
|
|
1313
|
+
if (key === "H") {
|
|
1314
|
+
panelResizer.shrinkRail();
|
|
1315
|
+
render();
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
// L: rail 확대 (패널 리사이즈)
|
|
1319
|
+
if (key === "L") {
|
|
1320
|
+
panelResizer.expandRail();
|
|
1321
|
+
render();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
963
1324
|
// h/?: 도움말 오버레이 토글
|
|
964
|
-
if (key === "h" || key === "?") {
|
|
1325
|
+
if (key === "h" || key === "?") {
|
|
1326
|
+
helpOverlay = true;
|
|
1327
|
+
render();
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
965
1330
|
// q: 대시보드 종료
|
|
966
|
-
if (key === "q") {
|
|
1331
|
+
if (key === "q") {
|
|
1332
|
+
doClose();
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
967
1335
|
// 1-9: 워커 직접 선택
|
|
968
1336
|
if (/^[1-9]$/.test(key)) {
|
|
969
1337
|
const names = visibleWorkerNames();
|
|
970
1338
|
const target = names[Number.parseInt(key, 10) - 1];
|
|
971
|
-
if (target) {
|
|
1339
|
+
if (target) {
|
|
1340
|
+
setSelectedWorker(target);
|
|
1341
|
+
render();
|
|
1342
|
+
}
|
|
972
1343
|
return;
|
|
973
1344
|
}
|
|
974
1345
|
}
|
|
975
1346
|
|
|
976
1347
|
// ── Enter→attach (k9s 패턴) ───────────────────────────────────────────
|
|
977
|
-
function attachToSession(worker) {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1348
|
+
async function attachToSession(worker) {
|
|
1349
|
+
let request;
|
|
1350
|
+
try {
|
|
1351
|
+
request = buildDashboardAttachRequest(worker);
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
showFlash(`[attach] ${error.message}`);
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
984
1356
|
|
|
985
|
-
const
|
|
986
|
-
if (
|
|
987
|
-
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1357
|
+
const lease = attachLimiter.acquire(request.kind);
|
|
1358
|
+
if (!lease.ok) {
|
|
1359
|
+
const seconds = Math.max(1, Math.ceil(lease.retryAfterMs / 1000));
|
|
1360
|
+
const label = lease.kind === "remote" ? "원격" : "로컬";
|
|
1361
|
+
showFlash(
|
|
1362
|
+
`[attach] ${label} 탭 제한(${lease.limit}/30초) — ${seconds}초 후 다시 시도`,
|
|
1363
|
+
);
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
try {
|
|
1368
|
+
await openTab(request);
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
lease.release?.();
|
|
1371
|
+
const reason = error?.reasonCode || error?.message || "unknown";
|
|
1372
|
+
showFlash(`[attach] 탭 열기 실패: ${reason}`);
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (rawModeEnabled && typeof input?.setRawMode === "function") {
|
|
1377
|
+
input.setRawMode(false);
|
|
1378
|
+
rawModeEnabled = false;
|
|
1000
1379
|
}
|
|
1380
|
+
if (typeof input?.pause === "function") input.pause();
|
|
1381
|
+
exitAltScreen();
|
|
1001
1382
|
|
|
1002
|
-
|
|
1003
|
-
setTimeout(() => {
|
|
1383
|
+
setTimeoutFn(() => {
|
|
1004
1384
|
enterAltScreen();
|
|
1005
|
-
if (typeof input?.setRawMode === "function") {
|
|
1385
|
+
if (typeof input?.setRawMode === "function") {
|
|
1386
|
+
input.setRawMode(true);
|
|
1387
|
+
rawModeEnabled = true;
|
|
1388
|
+
}
|
|
1006
1389
|
if (typeof input?.resume === "function") input.resume();
|
|
1007
1390
|
render();
|
|
1008
1391
|
}, 200);
|
|
1392
|
+
return true;
|
|
1009
1393
|
}
|
|
1010
1394
|
|
|
1011
1395
|
// ── flash 메시지 (완료/실패 알림용) ────────────────────────────────────
|
|
@@ -1013,16 +1397,27 @@ export function createLogDashboard(opts = {}) {
|
|
|
1013
1397
|
let flashTimer = null;
|
|
1014
1398
|
function showFlash(msg, durationMs = 5000) {
|
|
1015
1399
|
flashMessage = msg;
|
|
1016
|
-
if (flashTimer)
|
|
1017
|
-
flashTimer =
|
|
1400
|
+
if (flashTimer) clearTimeoutFn(flashTimer);
|
|
1401
|
+
flashTimer = setTimeoutFn(() => {
|
|
1402
|
+
flashMessage = "";
|
|
1403
|
+
render();
|
|
1404
|
+
}, durationMs);
|
|
1018
1405
|
render();
|
|
1019
1406
|
}
|
|
1020
1407
|
|
|
1021
1408
|
function attachInput() {
|
|
1022
1409
|
if (inputAttached) return;
|
|
1023
|
-
if (
|
|
1410
|
+
if (
|
|
1411
|
+
!isTTY ||
|
|
1412
|
+
(!forceTTY && !input?.isTTY) ||
|
|
1413
|
+
typeof input?.on !== "function"
|
|
1414
|
+
)
|
|
1415
|
+
return;
|
|
1024
1416
|
inputAttached = true;
|
|
1025
|
-
if (typeof input.setRawMode === "function") {
|
|
1417
|
+
if (typeof input.setRawMode === "function") {
|
|
1418
|
+
input.setRawMode(true);
|
|
1419
|
+
rawModeEnabled = true;
|
|
1420
|
+
}
|
|
1026
1421
|
if (typeof input.resume === "function") input.resume();
|
|
1027
1422
|
input.on("data", handleInput);
|
|
1028
1423
|
}
|
|
@@ -1058,10 +1453,32 @@ export function createLogDashboard(opts = {}) {
|
|
|
1058
1453
|
const renderTime = Date.now();
|
|
1059
1454
|
|
|
1060
1455
|
// Tier1: 상단 고정 2행
|
|
1061
|
-
const tier1 = buildTier1(
|
|
1456
|
+
const tier1 = buildTier1(
|
|
1457
|
+
names,
|
|
1458
|
+
workers,
|
|
1459
|
+
pipeline,
|
|
1460
|
+
elapsed,
|
|
1461
|
+
totalCols,
|
|
1462
|
+
VERSION,
|
|
1463
|
+
renderTime,
|
|
1464
|
+
);
|
|
1062
1465
|
// flash 메시지 (완료/실패 알림)
|
|
1063
1466
|
if (flashMessage) {
|
|
1064
|
-
tier1.push(
|
|
1467
|
+
tier1.push(
|
|
1468
|
+
truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols),
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
// Synapse 실시간 메트릭 (활성 시)
|
|
1472
|
+
if (synapseStream.isRunning) {
|
|
1473
|
+
const metricsSnap = synapseMetrics.snapshot();
|
|
1474
|
+
tier1.push(
|
|
1475
|
+
truncate(renderMetricsTier1(metricsSnap, totalCols), totalCols),
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
// 검색 프롬프트 (/ 모드)
|
|
1479
|
+
const searchPrompt = searchState.renderPrompt(totalCols);
|
|
1480
|
+
if (searchPrompt) {
|
|
1481
|
+
tier1.push(truncate(searchPrompt, totalCols));
|
|
1065
1482
|
}
|
|
1066
1483
|
|
|
1067
1484
|
// 레이아웃 결정
|
|
@@ -1075,9 +1492,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1075
1492
|
|
|
1076
1493
|
// summary+detail: summaryBar + focus pane
|
|
1077
1494
|
if (effectiveLayout === "summary+detail") {
|
|
1078
|
-
const summaryBar = buildSummaryBar(
|
|
1495
|
+
const summaryBar = buildSummaryBar(
|
|
1496
|
+
names,
|
|
1497
|
+
workers,
|
|
1498
|
+
selectedWorker,
|
|
1499
|
+
pipeline,
|
|
1500
|
+
totalCols,
|
|
1501
|
+
VERSION,
|
|
1502
|
+
);
|
|
1079
1503
|
const selectedState = workers.get(selectedWorker);
|
|
1080
|
-
const focusPaneHeight = Math.max(
|
|
1504
|
+
const focusPaneHeight = Math.max(
|
|
1505
|
+
8,
|
|
1506
|
+
totalRows - tier1.length - summaryBar.length,
|
|
1507
|
+
);
|
|
1081
1508
|
const focusPane = buildFocusPane(selectedWorker, selectedState, {
|
|
1082
1509
|
width: totalCols,
|
|
1083
1510
|
height: focusPaneHeight,
|
|
@@ -1091,11 +1518,12 @@ export function createLogDashboard(opts = {}) {
|
|
|
1091
1518
|
return [...tier1, ...summaryBar, ...focusPane];
|
|
1092
1519
|
}
|
|
1093
1520
|
|
|
1094
|
-
// 좌우 분할: Left Rail
|
|
1095
|
-
// 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
|
|
1521
|
+
// 좌우 분할: Left Rail | Right Focus (H/L로 비율 조정 가능)
|
|
1096
1522
|
const GAP = 1; // rail과 focus 사이 구분선
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1523
|
+
const railWidth = Math.max(
|
|
1524
|
+
MIN_CARD_WIDTH,
|
|
1525
|
+
Math.floor(totalCols * panelResizer.ratio),
|
|
1526
|
+
);
|
|
1099
1527
|
const focusWidth = totalCols - railWidth - GAP;
|
|
1100
1528
|
const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
|
|
1101
1529
|
|
|
@@ -1118,7 +1546,8 @@ export function createLogDashboard(opts = {}) {
|
|
|
1118
1546
|
railLines.push(...card);
|
|
1119
1547
|
}
|
|
1120
1548
|
// rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
|
|
1121
|
-
while (railLines.length < bodyHeight)
|
|
1549
|
+
while (railLines.length < bodyHeight)
|
|
1550
|
+
railLines.push(padRight("", railWidth));
|
|
1122
1551
|
if (railLines.length > bodyHeight) railLines.length = bodyHeight;
|
|
1123
1552
|
|
|
1124
1553
|
// Right Focus: 선택된 워커 상세
|
|
@@ -1135,7 +1564,8 @@ export function createLogDashboard(opts = {}) {
|
|
|
1135
1564
|
time: renderTime,
|
|
1136
1565
|
});
|
|
1137
1566
|
}
|
|
1138
|
-
while (focusLines.length < bodyHeight)
|
|
1567
|
+
while (focusLines.length < bodyHeight)
|
|
1568
|
+
focusLines.push(padRight("", focusWidth));
|
|
1139
1569
|
if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
|
|
1140
1570
|
|
|
1141
1571
|
// 좌우 합성: rail[i] + separator + focus[i]
|
|
@@ -1148,15 +1578,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1148
1578
|
}
|
|
1149
1579
|
|
|
1150
1580
|
// 하단 상태바
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
);
|
|
1581
|
+
const allDone = names.every((n) => {
|
|
1582
|
+
const s = runtimeStatus(workers.get(n));
|
|
1583
|
+
return s === "ok" || s === "completed" || s === "failed";
|
|
1584
|
+
});
|
|
1585
|
+
const statusText = allDone
|
|
1586
|
+
? " Enter: attach • q: 종료"
|
|
1587
|
+
: " Enter: attach • Tab: 포커스 • j/k: 이동 • h: 도움말";
|
|
1588
|
+
const statusBar = truncate(color(statusText, MOCHA.subtext), totalCols);
|
|
1155
1589
|
|
|
1156
1590
|
return [...tier1, ...composedRows, statusBar];
|
|
1157
1591
|
}
|
|
1158
1592
|
|
|
1159
|
-
// ── altScreen diff render
|
|
1593
|
+
// ── altScreen diff render (batched single write → 깜빡임 방지) ───────
|
|
1160
1594
|
function renderAltScreen() {
|
|
1161
1595
|
const newRows = buildRows();
|
|
1162
1596
|
rowBuf.set(newRows);
|
|
@@ -1165,16 +1599,16 @@ export function createLogDashboard(opts = {}) {
|
|
|
1165
1599
|
|
|
1166
1600
|
if (dirty.length === 0 && newRows.length === prevLen) return;
|
|
1167
1601
|
|
|
1168
|
-
|
|
1169
|
-
? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
|
|
1170
|
-
: [];
|
|
1171
|
-
|
|
1602
|
+
let buf = "";
|
|
1172
1603
|
for (const i of dirty) {
|
|
1173
|
-
|
|
1604
|
+
buf += moveTo(i + 1, 1) + clearLine + (newRows[i] || "");
|
|
1174
1605
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1606
|
+
if (prevLen > newRows.length) {
|
|
1607
|
+
for (let i = newRows.length; i < prevLen; i++) {
|
|
1608
|
+
buf += moveTo(i + 1, 1) + clearLine;
|
|
1609
|
+
}
|
|
1177
1610
|
}
|
|
1611
|
+
if (buf) write(buf);
|
|
1178
1612
|
|
|
1179
1613
|
rowBuf.commit();
|
|
1180
1614
|
}
|
|
@@ -1215,22 +1649,39 @@ export function createLogDashboard(opts = {}) {
|
|
|
1215
1649
|
// ── 공개 API ─────────────────────────────────────────────────────────
|
|
1216
1650
|
return {
|
|
1217
1651
|
updateWorker(paneName, state) {
|
|
1218
|
-
const existing = workers.get(paneName) || {
|
|
1652
|
+
const existing = workers.get(paneName) || {
|
|
1653
|
+
cli: "codex",
|
|
1654
|
+
status: "pending",
|
|
1655
|
+
};
|
|
1219
1656
|
const merged = normalizeWorkerState(existing, state);
|
|
1220
1657
|
const nextSig = JSON.stringify({
|
|
1221
|
-
cli: merged.cli,
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1658
|
+
cli: merged.cli,
|
|
1659
|
+
status: merged.status,
|
|
1660
|
+
role: merged.role,
|
|
1661
|
+
snapshot: merged.snapshot,
|
|
1662
|
+
summary: merged.summary,
|
|
1663
|
+
detail: merged.detail,
|
|
1664
|
+
findings: merged.findings,
|
|
1665
|
+
files_changed: merged.files_changed,
|
|
1666
|
+
confidence: merged.confidence,
|
|
1667
|
+
tokens: merged.tokens,
|
|
1668
|
+
progress: merged.progress,
|
|
1669
|
+
handoff: merged.handoff,
|
|
1226
1670
|
});
|
|
1227
1671
|
const sigChanged = nextSig !== existing._sig;
|
|
1228
|
-
const explicitElapsed = Number.isFinite(state.elapsed)
|
|
1672
|
+
const explicitElapsed = Number.isFinite(state.elapsed)
|
|
1673
|
+
? Math.max(0, Math.round(state.elapsed))
|
|
1674
|
+
: null;
|
|
1229
1675
|
merged._sig = nextSig;
|
|
1230
1676
|
merged._logSec = sigChanged
|
|
1231
1677
|
? (explicitElapsed ?? nowElapsedSec())
|
|
1232
|
-
:
|
|
1678
|
+
: Number.isFinite(existing._logSec)
|
|
1679
|
+
? existing._logSec
|
|
1680
|
+
: (explicitElapsed ?? nowElapsedSec());
|
|
1233
1681
|
workers.set(paneName, merged);
|
|
1682
|
+
// 토큰 히스토리 추적 (스파크라인용)
|
|
1683
|
+
if (merged.tokens !== undefined)
|
|
1684
|
+
tokenTracker.record(paneName, merged.tokens);
|
|
1234
1685
|
ensureSelectedWorker(visibleWorkerNames());
|
|
1235
1686
|
// follow-tail: 새 데이터 → 자동 scroll 재계산
|
|
1236
1687
|
if (followTail) detailScrollOffset = 0;
|
|
@@ -1283,7 +1734,10 @@ export function createLogDashboard(opts = {}) {
|
|
|
1283
1734
|
|
|
1284
1735
|
setFocusTab(tab) {
|
|
1285
1736
|
const valid = ["log", "detail", "files"];
|
|
1286
|
-
if (valid.includes(tab)) {
|
|
1737
|
+
if (valid.includes(tab)) {
|
|
1738
|
+
focusTab = tab;
|
|
1739
|
+
detailScrollOffset = 0;
|
|
1740
|
+
}
|
|
1287
1741
|
},
|
|
1288
1742
|
|
|
1289
1743
|
getLayout() {
|
|
@@ -1302,7 +1756,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1302
1756
|
|
|
1303
1757
|
attachWorker(name) {
|
|
1304
1758
|
const w = workers.get(name);
|
|
1305
|
-
if (w)
|
|
1759
|
+
if (!w) return false;
|
|
1760
|
+
return attachToSession(w);
|
|
1761
|
+
},
|
|
1762
|
+
|
|
1763
|
+
// Synapse 관제 API
|
|
1764
|
+
startSynapse() {
|
|
1765
|
+
synapseStream.start();
|
|
1766
|
+
},
|
|
1767
|
+
stopSynapse() {
|
|
1768
|
+
synapseStream.stop();
|
|
1769
|
+
},
|
|
1770
|
+
getSynapseMetrics() {
|
|
1771
|
+
return synapseMetrics.snapshot();
|
|
1306
1772
|
},
|
|
1307
1773
|
|
|
1308
1774
|
close() {
|
|
@@ -1327,27 +1793,30 @@ export function createLogDashboard(opts = {}) {
|
|
|
1327
1793
|
// failed=red, dead/init/starting=dim
|
|
1328
1794
|
|
|
1329
1795
|
const CONDUCTOR_STATE_LABEL = {
|
|
1330
|
-
init:
|
|
1331
|
-
starting:
|
|
1332
|
-
healthy:
|
|
1333
|
-
stalled:
|
|
1334
|
-
input_wait:
|
|
1335
|
-
failed:
|
|
1336
|
-
restarting:
|
|
1337
|
-
dead:
|
|
1338
|
-
completed:
|
|
1796
|
+
init: { label: "INIT", seq: MOCHA.subtext },
|
|
1797
|
+
starting: { label: "START", seq: MOCHA.executing },
|
|
1798
|
+
healthy: { label: "OK", seq: MOCHA.ok },
|
|
1799
|
+
stalled: { label: "STALL", seq: MOCHA.yellow },
|
|
1800
|
+
input_wait: { label: "INPUT_WAIT", seq: FG.cyan },
|
|
1801
|
+
failed: { label: "FAIL", seq: MOCHA.fail },
|
|
1802
|
+
restarting: { label: "RESTART", seq: MOCHA.partial },
|
|
1803
|
+
dead: { label: "DEAD", seq: FG.gray },
|
|
1804
|
+
completed: { label: "DONE", seq: MOCHA.ok },
|
|
1339
1805
|
};
|
|
1340
1806
|
|
|
1341
1807
|
function conductorHealthCell(state) {
|
|
1342
|
-
const entry = CONDUCTOR_STATE_LABEL[state] || {
|
|
1808
|
+
const entry = CONDUCTOR_STATE_LABEL[state] || {
|
|
1809
|
+
label: state.toUpperCase(),
|
|
1810
|
+
seq: FG.gray,
|
|
1811
|
+
};
|
|
1343
1812
|
return `${entry.seq}■ ${entry.label}${RESET}`;
|
|
1344
1813
|
}
|
|
1345
1814
|
|
|
1346
1815
|
function conductorRelTime(ms) {
|
|
1347
|
-
if (!ms) return
|
|
1816
|
+
if (!ms) return "—";
|
|
1348
1817
|
const sec = Math.round((Date.now() - ms) / 1000);
|
|
1349
|
-
if (sec < 0)
|
|
1350
|
-
if (sec < 60)
|
|
1818
|
+
if (sec < 0) return "—";
|
|
1819
|
+
if (sec < 60) return `${sec}s ago`;
|
|
1351
1820
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
1352
1821
|
return `${Math.floor(sec / 3600)}h ago`;
|
|
1353
1822
|
}
|
|
@@ -1365,31 +1834,32 @@ export function renderConductorTier(snapshot, cols = 100) {
|
|
|
1365
1834
|
|
|
1366
1835
|
// ── 열 너비 계산 ────────────────────────────────────────
|
|
1367
1836
|
// ID(8) Agent(7) Host(6) Health(dyn) LastOut(dyn) Restarts(8) Why(rest)
|
|
1368
|
-
const COL_ID
|
|
1369
|
-
const COL_AGENT
|
|
1370
|
-
const COL_HOST
|
|
1837
|
+
const COL_ID = 8;
|
|
1838
|
+
const COL_AGENT = 7;
|
|
1839
|
+
const COL_HOST = 6;
|
|
1371
1840
|
const COL_RESTARTS = 4;
|
|
1372
|
-
const COL_HEALTH
|
|
1373
|
-
const COL_LASTOUT
|
|
1841
|
+
const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
|
|
1842
|
+
const COL_LASTOUT = 9; // '999m ago' = 8 + space
|
|
1374
1843
|
// Why gets the remainder
|
|
1375
|
-
const fixedCols =
|
|
1844
|
+
const fixedCols =
|
|
1845
|
+
COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
|
|
1376
1846
|
const COL_WHY = Math.max(4, inner - fixedCols);
|
|
1377
1847
|
|
|
1378
1848
|
function cell(text, width_) {
|
|
1379
|
-
return clip(String(text ??
|
|
1849
|
+
return clip(String(text ?? ""), width_);
|
|
1380
1850
|
}
|
|
1381
1851
|
|
|
1382
1852
|
function buildRow(id, agent, host, healthCell, lastOut, restarts, why) {
|
|
1383
|
-
const idC
|
|
1384
|
-
const agentC
|
|
1385
|
-
const hostC
|
|
1853
|
+
const idC = cell(id, COL_ID);
|
|
1854
|
+
const agentC = cell(agent, COL_AGENT);
|
|
1855
|
+
const hostC = cell(host, COL_HOST);
|
|
1386
1856
|
const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
|
|
1387
|
-
const lastOutC
|
|
1388
|
-
const whyC
|
|
1857
|
+
const lastOutC = clip(lastOut, COL_LASTOUT);
|
|
1858
|
+
const whyC = cell(why, COL_WHY);
|
|
1389
1859
|
// healthCell already has ANSI codes; pad its visible width manually
|
|
1390
1860
|
const healthVis = wcswidth(stripAnsi(healthCell));
|
|
1391
1861
|
const healthPad = Math.max(0, COL_HEALTH - healthVis);
|
|
1392
|
-
const healthC
|
|
1862
|
+
const healthC = healthCell + " ".repeat(healthPad);
|
|
1393
1863
|
|
|
1394
1864
|
return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
|
|
1395
1865
|
}
|
|
@@ -1404,40 +1874,60 @@ export function renderConductorTier(snapshot, cols = 100) {
|
|
|
1404
1874
|
const dashLeft = 1;
|
|
1405
1875
|
const dashRight = Math.max(0, dashLen - dashLeft);
|
|
1406
1876
|
const borderSeq = MOCHA.border;
|
|
1407
|
-
const topBorder =
|
|
1408
|
-
`${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
|
|
1877
|
+
const topBorder = `${borderSeq}┌${"─".repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${"─".repeat(dashRight)}┐${RESET}`;
|
|
1409
1878
|
|
|
1410
1879
|
// ── ヘッダー行 ───────────────────────────────────────────
|
|
1411
|
-
const headerRow = buildRow(
|
|
1880
|
+
const headerRow = buildRow(
|
|
1881
|
+
"ID",
|
|
1882
|
+
"Agent",
|
|
1883
|
+
"Host",
|
|
1884
|
+
clip("Health", COL_HEALTH),
|
|
1885
|
+
"Last Out",
|
|
1886
|
+
"Rst",
|
|
1887
|
+
"Why",
|
|
1888
|
+
);
|
|
1412
1889
|
const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
|
|
1413
1890
|
|
|
1414
1891
|
// ── データ行 ────────────────────────────────────────────
|
|
1415
1892
|
const dataLines = [];
|
|
1416
1893
|
if (!snapshot || snapshot.length === 0) {
|
|
1417
|
-
const emptyMsg = color(
|
|
1418
|
-
const _emptyPad = clip(
|
|
1419
|
-
|
|
1894
|
+
const emptyMsg = color("(no sessions)", FG.muted);
|
|
1895
|
+
const _emptyPad = clip(
|
|
1896
|
+
stripAnsi(emptyMsg) === "(no sessions)" ? emptyMsg : emptyMsg,
|
|
1897
|
+
inner,
|
|
1898
|
+
);
|
|
1899
|
+
dataLines.push(
|
|
1900
|
+
`${borderSeq}│${RESET} ${padRight(emptyMsg, inner - 2)} ${borderSeq}│${RESET}`,
|
|
1901
|
+
);
|
|
1420
1902
|
} else {
|
|
1421
1903
|
for (const s of snapshot) {
|
|
1422
|
-
const id
|
|
1423
|
-
const agent
|
|
1424
|
-
const host
|
|
1425
|
-
const state
|
|
1904
|
+
const id = String(s.id ?? "").slice(0, COL_ID);
|
|
1905
|
+
const agent = String(s.agent ?? "unknown").slice(0, COL_AGENT);
|
|
1906
|
+
const host = "local";
|
|
1907
|
+
const state = s.state ?? "init";
|
|
1426
1908
|
const healthCell = conductorHealthCell(state);
|
|
1427
|
-
const lastOut
|
|
1909
|
+
const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
|
|
1428
1910
|
const restarts = s.restarts ?? 0;
|
|
1429
1911
|
// derive "why" from last state transition context
|
|
1430
1912
|
const why = s.health?.inputWaitPattern
|
|
1431
1913
|
? String(s.health.inputWaitPattern).slice(0, COL_WHY)
|
|
1432
|
-
:
|
|
1433
|
-
|
|
1434
|
-
const rowText = buildRow(
|
|
1914
|
+
: "";
|
|
1915
|
+
|
|
1916
|
+
const rowText = buildRow(
|
|
1917
|
+
id,
|
|
1918
|
+
agent,
|
|
1919
|
+
host,
|
|
1920
|
+
healthCell,
|
|
1921
|
+
lastOut,
|
|
1922
|
+
restarts,
|
|
1923
|
+
why,
|
|
1924
|
+
);
|
|
1435
1925
|
dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
|
|
1436
1926
|
}
|
|
1437
1927
|
}
|
|
1438
1928
|
|
|
1439
1929
|
// ── Bottom border ─────────────────────────────────────
|
|
1440
|
-
const botBorder = `${borderSeq}└${
|
|
1930
|
+
const botBorder = `${borderSeq}└${"─".repeat(boxWidth)}┘${RESET}`;
|
|
1441
1931
|
|
|
1442
1932
|
return [topBorder, headerLine, ...dataLines, botBorder];
|
|
1443
1933
|
}
|