@triflux/remote 10.0.1 → 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 +68 -7
- package/hub/server.mjs +2181 -701
- package/hub/store-adapter.mjs +101 -37
- 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 +127 -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 +45 -17
- 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 +21 -119
- 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 +1067 -264
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +69 -53
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +62 -34
- 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 +1526 -198
- 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 +21 -37
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -45
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +936 -449
- 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 +518 -64
- 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 +663 -337
- 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,56 +3,209 @@
|
|
|
3
3
|
// Tier1(상단 고정) / Tier2(worker rail) / Tier3(focus pane) 3단 계층.
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
BG,
|
|
9
|
-
MOCHA,
|
|
10
|
-
color,
|
|
11
|
-
dim,
|
|
6
|
+
altScreenOff,
|
|
7
|
+
altScreenOn,
|
|
12
8
|
bold,
|
|
13
9
|
box,
|
|
14
|
-
|
|
15
|
-
truncate,
|
|
16
|
-
clip,
|
|
17
|
-
stripAnsi,
|
|
18
|
-
wcswidth,
|
|
19
|
-
progressBar,
|
|
20
|
-
statusBadge,
|
|
21
|
-
STATUS_ICON,
|
|
22
|
-
altScreenOn,
|
|
23
|
-
altScreenOff,
|
|
10
|
+
clearLine,
|
|
24
11
|
clearScreen,
|
|
25
|
-
|
|
12
|
+
clip,
|
|
13
|
+
color,
|
|
26
14
|
cursorHide,
|
|
15
|
+
cursorHome,
|
|
27
16
|
cursorShow,
|
|
17
|
+
dim,
|
|
18
|
+
FG,
|
|
19
|
+
MOCHA,
|
|
28
20
|
moveTo,
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
padRight,
|
|
22
|
+
progressBar,
|
|
23
|
+
RESET,
|
|
24
|
+
statusBadge,
|
|
25
|
+
stripAnsi,
|
|
26
|
+
truncate,
|
|
27
|
+
wcswidth,
|
|
31
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";
|
|
32
59
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// package.json에서 동적 로드 (실패 시 fallback)
|
|
36
|
-
let VERSION = "7.x";
|
|
37
|
-
try {
|
|
38
|
-
const { createRequire } = await import("node:module");
|
|
39
|
-
const require = createRequire(import.meta.url);
|
|
40
|
-
VERSION = require("../../package.json").version;
|
|
41
|
-
} catch { /* fallback */ }
|
|
60
|
+
const VERSION = await loadVersion();
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
const FALLBACK_ROWS = 30;
|
|
62
|
+
// FALLBACK_COLUMNS, FALLBACK_ROWS → tui-core.mjs에서 import
|
|
45
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
|
+
}
|
|
46
196
|
|
|
47
197
|
// ✻ heartbeat — Claude Code 리버스 엔지니어링 기반 breathing animation
|
|
48
198
|
// 프레임: ["·","✢","✳","✶","✻","✽"] + 역재생 = 12프레임 왕복
|
|
49
199
|
// 타이밍: 2000ms/cycle, RGB truecolor 보간
|
|
50
200
|
const SPINNER_FRAMES_RAW = ["·", "✢", "✳", "✶", "✻", "✽"];
|
|
51
|
-
const SPINNER_FRAMES = [
|
|
201
|
+
const SPINNER_FRAMES = [
|
|
202
|
+
...SPINNER_FRAMES_RAW,
|
|
203
|
+
...[...SPINNER_FRAMES_RAW].reverse(),
|
|
204
|
+
];
|
|
52
205
|
const SPINNER_CYCLE_MS = 2000;
|
|
53
206
|
const SPINNER_BASE_COLOR = { r: 203, g: 166, b: 247 }; // Catppuccin Mocha mauve
|
|
54
|
-
const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 };
|
|
55
|
-
|
|
207
|
+
const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
|
|
208
|
+
const spinnerStart = Date.now();
|
|
56
209
|
let spinnerTick = 0;
|
|
57
210
|
|
|
58
211
|
function lerpRgb(a, b, t) {
|
|
@@ -71,18 +224,28 @@ function pseudoRandomFrame(step, seed) {
|
|
|
71
224
|
return Math.abs(Math.imul(step + seed, 2654435761)) % SPINNER_FRAMES.length;
|
|
72
225
|
}
|
|
73
226
|
|
|
74
|
-
function heartbeat(
|
|
75
|
-
|
|
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;
|
|
76
236
|
if (transitionElapsed < 500) {
|
|
77
237
|
const step = Math.floor(transitionElapsed / 50);
|
|
78
238
|
const idx = pseudoRandomFrame(step, statusChangedAt % 997);
|
|
79
|
-
const targetColor =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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;
|
|
86
249
|
return `${targetColor}${SPINNER_FRAMES[idx]}${RESET}`;
|
|
87
250
|
}
|
|
88
251
|
|
|
@@ -90,10 +253,13 @@ function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Dat
|
|
|
90
253
|
if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
|
|
91
254
|
if (status !== "running") return dim("○");
|
|
92
255
|
const elapsed = time - spinnerStart;
|
|
93
|
-
const idx =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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;
|
|
97
263
|
return `${rgbSeq(c)}${SPINNER_FRAMES[idx]}${RESET}`;
|
|
98
264
|
}
|
|
99
265
|
|
|
@@ -110,7 +276,9 @@ function activityWave(tick, count = 4) {
|
|
|
110
276
|
let wave = "";
|
|
111
277
|
for (let i = 0; i < count; i++) {
|
|
112
278
|
const phase = tick * 0.3 + i * 1.5;
|
|
113
|
-
const idx = Math.floor(
|
|
279
|
+
const idx = Math.floor(
|
|
280
|
+
(Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1),
|
|
281
|
+
);
|
|
114
282
|
wave += WAVE_CHARS[idx];
|
|
115
283
|
}
|
|
116
284
|
return `${MOCHA.executing}${wave}${RESET}`;
|
|
@@ -119,138 +287,70 @@ function activityWave(tick, count = 4) {
|
|
|
119
287
|
const GRID_GAP = 2;
|
|
120
288
|
const DEFAULT_DETAIL_LINES = 10;
|
|
121
289
|
// Tier1 상단 고정 행 수
|
|
122
|
-
const
|
|
290
|
+
const _TIER1_ROWS = 2;
|
|
123
291
|
|
|
124
292
|
const SUMMARY_KEYS = [
|
|
125
|
-
"status",
|
|
126
|
-
"
|
|
293
|
+
"status",
|
|
294
|
+
"lead_action",
|
|
295
|
+
"verdict",
|
|
296
|
+
"files_changed",
|
|
297
|
+
"confidence",
|
|
298
|
+
"risk",
|
|
299
|
+
"detail",
|
|
300
|
+
"error_stage",
|
|
301
|
+
"retryable",
|
|
302
|
+
"partial_output",
|
|
127
303
|
];
|
|
128
304
|
|
|
129
305
|
// ── 레이아웃 브레이크포인트 ──────────────────────────────────────────────
|
|
130
306
|
// 80-119: 28col rail, 120-159: 36col rail, 160+: 균등
|
|
131
|
-
function
|
|
307
|
+
function _resolveRailWidth(totalCols, columnCount) {
|
|
132
308
|
if (columnCount <= 1) return totalCols;
|
|
133
|
-
if (totalCols >= 160)
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
);
|
|
136
320
|
}
|
|
137
321
|
|
|
138
|
-
function
|
|
322
|
+
function _autoColumnCount(totalCols, workerCount) {
|
|
139
323
|
if (workerCount <= 1) return 1;
|
|
140
324
|
if (totalCols >= 160) return Math.min(workerCount, 3);
|
|
141
325
|
if (totalCols >= 120) return Math.min(workerCount, 2);
|
|
142
326
|
return 1;
|
|
143
327
|
}
|
|
144
328
|
|
|
145
|
-
//
|
|
146
|
-
function clamp(value, min, max) {
|
|
147
|
-
return Math.min(max, Math.max(min, value));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function stripCodeBlocks(text) {
|
|
151
|
-
return String(text || "")
|
|
152
|
-
.replace(/\r/g, "")
|
|
153
|
-
// fenced code blocks
|
|
154
|
-
.replace(/```[\s\S]*?(?:```|$)/g, "\n")
|
|
155
|
-
.replace(/^\s*```.*$/gm, "")
|
|
156
|
-
// indented code blocks (4+ spaces or tab at line start)
|
|
157
|
-
.replace(/^(?: |\t).+$/gm, "")
|
|
158
|
-
// shell prompts: PS C:\...>, >, $
|
|
159
|
-
.replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "")
|
|
160
|
-
.trim();
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function sanitizeTextBlock(text, rawMode = false) {
|
|
164
|
-
const normalized = rawMode ? String(text || "").replace(/\r/g, "") : stripCodeBlocks(text);
|
|
165
|
-
return normalized
|
|
166
|
-
.split("\n")
|
|
167
|
-
.map((line) => line.trim())
|
|
168
|
-
.filter(Boolean)
|
|
169
|
-
.filter((line) => line !== "--- HANDOFF ---")
|
|
170
|
-
.join("\n")
|
|
171
|
-
.trim();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function sanitizeOneLine(text, fallback = "") {
|
|
175
|
-
const normalized = sanitizeTextBlock(text).replace(/\s+/g, " ").trim();
|
|
176
|
-
return normalized || fallback;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function sanitizeFiles(files) {
|
|
180
|
-
if (!files) return [];
|
|
181
|
-
const raw = Array.isArray(files) ? files : String(files).split(",");
|
|
182
|
-
return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function sanitizeFindings(findings) {
|
|
186
|
-
if (!findings) return [];
|
|
187
|
-
const raw = Array.isArray(findings)
|
|
188
|
-
? findings
|
|
189
|
-
: sanitizeTextBlock(findings).split("\n");
|
|
190
|
-
return raw.map((e) => sanitizeOneLine(e)).filter(Boolean);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function normalizeTokens(tokens) {
|
|
194
|
-
if (tokens === null || tokens === undefined) return "";
|
|
195
|
-
if (typeof tokens === "number" && Number.isFinite(tokens)) return tokens;
|
|
196
|
-
const raw = sanitizeOneLine(tokens);
|
|
197
|
-
if (!raw) return "";
|
|
198
|
-
const match = raw.match(/(\d+(?:[.,]\d+)?\s*[kKmM]?)/);
|
|
199
|
-
return match ? match[1].replace(/\s+/g, "").toLowerCase() : raw;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function formatTokens(tokens) {
|
|
203
|
-
if (tokens === null || tokens === undefined || tokens === "") return "n/a";
|
|
204
|
-
if (typeof tokens === "number" && Number.isFinite(tokens)) {
|
|
205
|
-
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
|
|
206
|
-
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
207
|
-
return `${tokens}`;
|
|
208
|
-
}
|
|
209
|
-
return String(tokens);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ── 색상 헬퍼 ─────────────────────────────────────────────────────────────
|
|
213
|
-
function cliColor(cli) {
|
|
214
|
-
if (cli === "gemini") return FG.gemini;
|
|
215
|
-
if (cli === "claude") return FG.claude;
|
|
216
|
-
if (cli === "codex") return FG.codex;
|
|
217
|
-
return FG.white;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function runtimeStatus(st) {
|
|
221
|
-
return st?.handoff?.status || st?.status || "pending";
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function statusColor(status) {
|
|
225
|
-
if (status === "ok" || status === "completed") return MOCHA.ok;
|
|
226
|
-
if (status === "partial") return MOCHA.partial;
|
|
227
|
-
if (status === "failed") return MOCHA.fail;
|
|
228
|
-
if (status === "running" || status === "in_progress") return MOCHA.executing;
|
|
229
|
-
return FG.muted;
|
|
230
|
-
}
|
|
329
|
+
// 텍스트/상태/색상 유틸은 tui-core.mjs에서 import (위 참조)
|
|
231
330
|
|
|
232
331
|
// ── MOCHA RGB (gradual fade 보간용) ──
|
|
233
332
|
const MOCHA_RGB = {
|
|
234
|
-
ok:
|
|
235
|
-
partial:
|
|
236
|
-
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 },
|
|
237
336
|
executing: { r: 116, g: 199, b: 236 },
|
|
238
|
-
muted:
|
|
239
|
-
border:
|
|
240
|
-
blue:
|
|
241
|
-
sky:
|
|
242
|
-
yellow:
|
|
243
|
-
peach:
|
|
244
|
-
maroon:
|
|
245
|
-
surface0:
|
|
246
|
-
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 },
|
|
247
346
|
};
|
|
248
347
|
|
|
249
348
|
function statusToRgb(status) {
|
|
250
349
|
if (status === "ok" || status === "completed") return MOCHA_RGB.ok;
|
|
251
350
|
if (status === "partial") return MOCHA_RGB.partial;
|
|
252
351
|
if (status === "failed") return MOCHA_RGB.fail;
|
|
253
|
-
if (status === "running" || status === "in_progress")
|
|
352
|
+
if (status === "running" || status === "in_progress")
|
|
353
|
+
return MOCHA_RGB.executing;
|
|
254
354
|
return MOCHA_RGB.muted;
|
|
255
355
|
}
|
|
256
356
|
|
|
@@ -275,7 +375,7 @@ function gradientBorderFn(topRgb, bottomRgb) {
|
|
|
275
375
|
}
|
|
276
376
|
|
|
277
377
|
// Effect 3: Flash-fade border — 상태 변경 시 백색 플래시 → 페이드아웃
|
|
278
|
-
function
|
|
378
|
+
function _flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
|
|
279
379
|
const elapsed = Date.now() - (changedAt || 0);
|
|
280
380
|
if (elapsed >= FADE_DURATION_MS || !prevStatus) return null;
|
|
281
381
|
const statusRgb = statusToRgb(currentStatus);
|
|
@@ -291,7 +391,7 @@ function flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
|
|
|
291
391
|
}
|
|
292
392
|
|
|
293
393
|
function easeOutCubic(t) {
|
|
294
|
-
return 1 - (
|
|
394
|
+
return 1 - (1 - t) ** 3;
|
|
295
395
|
}
|
|
296
396
|
|
|
297
397
|
function borderHighlightPosition(width, bodyLines, time = Date.now()) {
|
|
@@ -302,13 +402,20 @@ function borderHighlightPosition(width, bodyLines, time = Date.now()) {
|
|
|
302
402
|
}
|
|
303
403
|
|
|
304
404
|
function titleFlash(status, changeElapsed) {
|
|
305
|
-
const isCompleted =
|
|
306
|
-
|
|
405
|
+
const isCompleted =
|
|
406
|
+
status === "completed" || status === "done" || status === "ok";
|
|
407
|
+
const isFailed =
|
|
408
|
+
status === "failed" || status === "error" || status === "fail";
|
|
307
409
|
if ((!isCompleted && !isFailed) || changeElapsed > 800) return null;
|
|
308
410
|
const flashRgb = isCompleted ? MOCHA_RGB.ok : MOCHA_RGB.fail;
|
|
309
|
-
const bgRgb =
|
|
310
|
-
|
|
311
|
-
|
|
411
|
+
const bgRgb =
|
|
412
|
+
changeElapsed <= 300
|
|
413
|
+
? flashRgb
|
|
414
|
+
: lerpRgb(
|
|
415
|
+
flashRgb,
|
|
416
|
+
MOCHA_RGB.surface0,
|
|
417
|
+
clamp((changeElapsed - 300) / 500, 0, 1),
|
|
418
|
+
);
|
|
312
419
|
return rgbSeq(bgRgb, 48);
|
|
313
420
|
}
|
|
314
421
|
|
|
@@ -319,51 +426,34 @@ function dedupeRole(role, name, cli) {
|
|
|
319
426
|
r = r.replace(new RegExp(esc(cli), "gi"), "").trim();
|
|
320
427
|
r = r.replace(new RegExp(esc(name), "gi"), "").trim();
|
|
321
428
|
// CLI indicator emojis 제거
|
|
322
|
-
r = r.replace(/[⚪⚫🔴🟠🟡🟢🔵🟣🟤⭕🔘]/
|
|
429
|
+
r = r.replace(/[⚪⚫🔴🟠🟡🟢🔵🟣🟤⭕🔘]/gu, "").trim();
|
|
323
430
|
// 빈 괄호 제거 + 중첩 괄호 정리
|
|
324
431
|
r = r.replace(/\(\s*\)/g, "").trim();
|
|
325
432
|
r = r.replace(/^\(([^()]+)\)$/, "$1").trim();
|
|
326
|
-
r = r.replace(/^\s*[
|
|
433
|
+
r = r.replace(/^\s*[•·-]\s*/, "").trim();
|
|
327
434
|
return r;
|
|
328
435
|
}
|
|
329
436
|
|
|
330
|
-
//
|
|
331
|
-
function wrapLine(text, width) {
|
|
332
|
-
const limit = Math.max(8, width);
|
|
333
|
-
const source = String(text || "").trim();
|
|
334
|
-
if (!source) return [""];
|
|
335
|
-
const words = source.split(/\s+/);
|
|
336
|
-
const lines = [];
|
|
337
|
-
let current = "";
|
|
338
|
-
for (const word of words) {
|
|
339
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
340
|
-
if (wcswidth(candidate) <= limit) { current = candidate; continue; }
|
|
341
|
-
if (current) { lines.push(current); current = ""; }
|
|
342
|
-
if (wcswidth(word) <= limit) { current = word; continue; }
|
|
343
|
-
let offset = 0;
|
|
344
|
-
while (offset < word.length) {
|
|
345
|
-
lines.push(word.slice(offset, offset + limit));
|
|
346
|
-
offset += limit;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
if (current) lines.push(current);
|
|
350
|
-
return lines.length > 0 ? lines : [source.slice(0, limit)];
|
|
351
|
-
}
|
|
437
|
+
// wrapLine, wrapTextAll → tui-core.mjs에서 import
|
|
352
438
|
|
|
353
|
-
function
|
|
439
|
+
function _wrapText(
|
|
440
|
+
text,
|
|
441
|
+
width,
|
|
442
|
+
maxLines = DEFAULT_DETAIL_LINES,
|
|
443
|
+
rawMode = false,
|
|
444
|
+
) {
|
|
354
445
|
if (maxLines <= 0) return [];
|
|
355
446
|
const input = sanitizeTextBlock(text, rawMode);
|
|
356
447
|
if (!input) return [];
|
|
357
|
-
const wrapped = input
|
|
448
|
+
const wrapped = input
|
|
449
|
+
.split("\n")
|
|
450
|
+
.flatMap((line) => wrapLine(line, width))
|
|
451
|
+
.filter(Boolean);
|
|
358
452
|
if (wrapped.length <= maxLines) return wrapped;
|
|
359
|
-
return [
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
function wrapTextAll(text, width, rawMode = false) {
|
|
364
|
-
const input = sanitizeTextBlock(text, rawMode);
|
|
365
|
-
if (!input) return [];
|
|
366
|
-
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
|
+
];
|
|
367
457
|
}
|
|
368
458
|
|
|
369
459
|
// ── virtual row buffer ────────────────────────────────────────────────────
|
|
@@ -391,46 +481,53 @@ class RowBuffer {
|
|
|
391
481
|
this._prev = [...this._rows];
|
|
392
482
|
}
|
|
393
483
|
|
|
394
|
-
get rows() {
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
function countStatuses(names, workers) {
|
|
400
|
-
let ok = 0, partial = 0, failed = 0, running = 0;
|
|
401
|
-
for (const name of names) {
|
|
402
|
-
const st = workers.get(name);
|
|
403
|
-
const s = runtimeStatus(st);
|
|
404
|
-
if (s === "ok" || s === "completed") ok++;
|
|
405
|
-
else if (s === "partial") partial++;
|
|
406
|
-
else if (s === "failed") failed++;
|
|
407
|
-
else if (s === "running" || s === "in_progress") running++;
|
|
484
|
+
get rows() {
|
|
485
|
+
return this._rows;
|
|
486
|
+
}
|
|
487
|
+
get prevLen() {
|
|
488
|
+
return this._prev.length;
|
|
408
489
|
}
|
|
409
|
-
return { ok, partial, failed, running };
|
|
410
490
|
}
|
|
411
491
|
|
|
492
|
+
// countStatuses → tui-core.mjs에서 import
|
|
493
|
+
|
|
412
494
|
// ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
|
|
413
495
|
function phaseColor(phase, time = Date.now()) {
|
|
414
496
|
const shimmer = currentShimmer(time);
|
|
415
|
-
if (phase === "exec" || phase === "executing")
|
|
416
|
-
|
|
417
|
-
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));
|
|
418
503
|
return FG.accent;
|
|
419
504
|
}
|
|
420
505
|
|
|
421
|
-
function buildTier1(
|
|
506
|
+
function buildTier1(
|
|
507
|
+
names,
|
|
508
|
+
workers,
|
|
509
|
+
pipeline,
|
|
510
|
+
elapsed,
|
|
511
|
+
width,
|
|
512
|
+
version,
|
|
513
|
+
time = Date.now(),
|
|
514
|
+
) {
|
|
422
515
|
const { ok, partial, failed, running } = countStatuses(names, workers);
|
|
423
516
|
const phase = pipeline.phase || "exec";
|
|
424
517
|
const row1 = truncate(
|
|
425
518
|
`${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase, time))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
|
|
426
|
-
|
|
519
|
+
`${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)}${running > 0 ? ` ${activityWave(spinnerTick)}` : ""}`,
|
|
427
520
|
width,
|
|
428
521
|
);
|
|
429
|
-
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
|
+
);
|
|
430
526
|
const hintWidth = wcswidth(stripAnsi(keysHint));
|
|
431
|
-
const row2 =
|
|
432
|
-
|
|
433
|
-
|
|
527
|
+
const row2 =
|
|
528
|
+
hintWidth >= width
|
|
529
|
+
? truncate(keysHint, width)
|
|
530
|
+
: padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
|
|
434
531
|
return [row1, row2];
|
|
435
532
|
}
|
|
436
533
|
|
|
@@ -440,7 +537,8 @@ function detailText(st) {
|
|
|
440
537
|
const lines = [];
|
|
441
538
|
for (const key of SUMMARY_KEYS) {
|
|
442
539
|
const value = st.handoff?.[key];
|
|
443
|
-
if (Array.isArray(value) && value.length > 0)
|
|
540
|
+
if (Array.isArray(value) && value.length > 0)
|
|
541
|
+
lines.push(`${key}: ${value.join(", ")}`);
|
|
444
542
|
else if (value) lines.push(`${key}: ${value}`);
|
|
445
543
|
}
|
|
446
544
|
if (st.snapshot) lines.unshift(st.snapshot);
|
|
@@ -455,7 +553,10 @@ function detailHighlights(st) {
|
|
|
455
553
|
.map((line) => line.replace(/^verdict\s*:\s*/i, "").trim())
|
|
456
554
|
.filter(Boolean)
|
|
457
555
|
.filter((line) => line !== verdict)
|
|
458
|
-
.filter(
|
|
556
|
+
.filter(
|
|
557
|
+
(line) =>
|
|
558
|
+
!SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)),
|
|
559
|
+
)
|
|
459
560
|
.slice(0, 2);
|
|
460
561
|
}
|
|
461
562
|
|
|
@@ -463,9 +564,9 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
463
564
|
const {
|
|
464
565
|
width,
|
|
465
566
|
selected = false,
|
|
466
|
-
focused = false,
|
|
567
|
+
focused = false, // rail 포커스 여부
|
|
467
568
|
previousSelected = false,
|
|
468
|
-
|
|
569
|
+
_rawMode = false,
|
|
469
570
|
compact = false,
|
|
470
571
|
time = Date.now(),
|
|
471
572
|
} = opts;
|
|
@@ -474,19 +575,29 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
474
575
|
const role = sanitizeOneLine(st.role);
|
|
475
576
|
const status = runtimeStatus(st);
|
|
476
577
|
const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
|
|
477
|
-
const changeElapsed = st._statusChangedAt
|
|
578
|
+
const changeElapsed = st._statusChangedAt
|
|
579
|
+
? Math.max(0, time - st._statusChangedAt)
|
|
580
|
+
: Number.POSITIVE_INFINITY;
|
|
478
581
|
|
|
479
582
|
// Tier2 행 1: 이름 + CLI + role
|
|
480
583
|
const selMark = selected
|
|
481
|
-
?
|
|
584
|
+
? focused
|
|
585
|
+
? color("▶", MOCHA.blue)
|
|
586
|
+
: color(">", FG.triflux)
|
|
482
587
|
: previousSelected
|
|
483
588
|
? dim("~")
|
|
484
589
|
: " ";
|
|
485
|
-
const hb = heartbeat(
|
|
590
|
+
const hb = heartbeat(
|
|
591
|
+
status,
|
|
592
|
+
status === "running" ? currentShimmer(time) : 0,
|
|
593
|
+
st._statusChangedAt,
|
|
594
|
+
time,
|
|
595
|
+
);
|
|
486
596
|
// host 배지 (원격 워커용)
|
|
487
|
-
const hostBadge =
|
|
488
|
-
|
|
489
|
-
|
|
597
|
+
const hostBadge =
|
|
598
|
+
st.host && st.host !== "local"
|
|
599
|
+
? color(`[${st.host}]`, MOCHA.mauve) + " "
|
|
600
|
+
: "";
|
|
490
601
|
const displayRole = dedupeRole(role, name, cli);
|
|
491
602
|
const title = truncate(
|
|
492
603
|
`${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
|
|
@@ -494,7 +605,9 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
494
605
|
);
|
|
495
606
|
|
|
496
607
|
const cardWidth = Math.max(MIN_CARD_WIDTH, width);
|
|
497
|
-
const borderHighlight = focused
|
|
608
|
+
const borderHighlight = focused
|
|
609
|
+
? borderHighlightPosition(cardWidth, compact ? 2 : 6, time)
|
|
610
|
+
: undefined;
|
|
498
611
|
const titleFlashBg = titleFlash(status, changeElapsed);
|
|
499
612
|
|
|
500
613
|
// status-specific border: focused→mauve, selected→bright, non-selected→glow decay
|
|
@@ -505,37 +618,58 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
505
618
|
}
|
|
506
619
|
if (selected) return statusColor(status);
|
|
507
620
|
const from = statusToRgb(status);
|
|
508
|
-
const decayBase = st._statusChangedAt
|
|
621
|
+
const decayBase = st._statusChangedAt
|
|
622
|
+
? clamp(changeElapsed / CARD_GLOW_MS, 0, 1)
|
|
623
|
+
: 1;
|
|
509
624
|
const decayT = easeOutCubic(decayBase);
|
|
510
|
-
return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 +
|
|
625
|
+
return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + 0.5 * decayT));
|
|
511
626
|
})();
|
|
512
627
|
|
|
513
628
|
if (compact) {
|
|
514
629
|
// compact 2-line 카드
|
|
515
|
-
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;
|
|
516
635
|
const percent = Math.round(progress * 100);
|
|
517
636
|
const compactLine1 = truncate(
|
|
518
637
|
`${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
|
|
519
638
|
innerWidth,
|
|
520
639
|
);
|
|
521
|
-
const verdict = sanitizeOneLine(
|
|
640
|
+
const verdict = sanitizeOneLine(
|
|
641
|
+
st.handoff?.verdict || st.summary || st.snapshot,
|
|
642
|
+
status,
|
|
643
|
+
);
|
|
522
644
|
const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
|
|
523
|
-
const framed = box(
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
645
|
+
const framed = box(
|
|
646
|
+
[compactLine1, compactLine2],
|
|
647
|
+
cardWidth,
|
|
648
|
+
statusBorderColor,
|
|
649
|
+
{
|
|
650
|
+
highlightPos: borderHighlight,
|
|
651
|
+
titleFlashBg,
|
|
652
|
+
},
|
|
653
|
+
);
|
|
527
654
|
return [framed.top, ...framed.body, framed.bot];
|
|
528
655
|
}
|
|
529
656
|
|
|
530
657
|
// Tier2 행 2: 상태 배지 + elapsed + tokens + conf
|
|
531
|
-
const confidence = sanitizeOneLine(
|
|
658
|
+
const confidence = sanitizeOneLine(
|
|
659
|
+
st.handoff?.confidence || st.confidence,
|
|
660
|
+
"n/a",
|
|
661
|
+
);
|
|
532
662
|
const statusLine = truncate(
|
|
533
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)}`,
|
|
534
664
|
innerWidth,
|
|
535
665
|
);
|
|
536
666
|
|
|
537
667
|
// Tier2 행 3: progress bar
|
|
538
|
-
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;
|
|
539
673
|
const percent = Math.round(progress * 100);
|
|
540
674
|
const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
|
|
541
675
|
const bar = progressBar(percent, barWidth, time);
|
|
@@ -545,18 +679,33 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
545
679
|
);
|
|
546
680
|
|
|
547
681
|
// Tier2 행 4-6: verdict / findings / files
|
|
548
|
-
const verdict = sanitizeOneLine(
|
|
549
|
-
|
|
550
|
-
|
|
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";
|
|
551
691
|
|
|
552
692
|
const verdictClr = statusColor(status);
|
|
553
693
|
const lines = [
|
|
554
694
|
title,
|
|
555
695
|
statusLine,
|
|
556
696
|
progressLine,
|
|
557
|
-
truncate(
|
|
558
|
-
|
|
559
|
-
|
|
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
|
+
),
|
|
560
709
|
];
|
|
561
710
|
|
|
562
711
|
const framed = box(lines, cardWidth, statusBorderColor, {
|
|
@@ -580,23 +729,47 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
580
729
|
const innerWidth = Math.max(12, width - 4);
|
|
581
730
|
|
|
582
731
|
// verdict sticky 4행
|
|
583
|
-
const verdict = sanitizeOneLine(
|
|
584
|
-
|
|
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
|
+
);
|
|
585
740
|
const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
|
|
586
741
|
const status = runtimeStatus(st);
|
|
587
742
|
|
|
588
743
|
// Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
|
|
589
744
|
const activeTab = opts.activeTab || "log";
|
|
590
|
-
const tabLog =
|
|
591
|
-
|
|
592
|
-
|
|
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);
|
|
593
757
|
const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
|
|
594
758
|
|
|
595
759
|
const stickyLines = [
|
|
596
|
-
truncate(
|
|
760
|
+
truncate(
|
|
761
|
+
`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`,
|
|
762
|
+
innerWidth,
|
|
763
|
+
),
|
|
597
764
|
tabBar,
|
|
598
|
-
truncate(
|
|
599
|
-
|
|
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
|
+
),
|
|
600
773
|
color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
|
|
601
774
|
];
|
|
602
775
|
|
|
@@ -608,17 +781,22 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
608
781
|
const summaryLines = [];
|
|
609
782
|
for (const key of SUMMARY_KEYS) {
|
|
610
783
|
const value = st.handoff?.[key];
|
|
611
|
-
if (Array.isArray(value) && value.length > 0)
|
|
784
|
+
if (Array.isArray(value) && value.length > 0)
|
|
785
|
+
summaryLines.push(`${key}: ${value.join(", ")}`);
|
|
612
786
|
else if (value) summaryLines.push(`${key}: ${value}`);
|
|
613
787
|
}
|
|
614
|
-
allBodyLines =
|
|
615
|
-
|
|
616
|
-
|
|
788
|
+
allBodyLines =
|
|
789
|
+
summaryLines.length > 0
|
|
790
|
+
? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
|
|
791
|
+
: [dim("no structured data")];
|
|
617
792
|
} else if (activeTab === "files") {
|
|
618
|
-
const filesList = sanitizeFiles(
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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")];
|
|
622
800
|
} else {
|
|
623
801
|
allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
|
|
624
802
|
}
|
|
@@ -627,16 +805,24 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
627
805
|
if (followTail) {
|
|
628
806
|
startIdx = Math.max(0, allBodyLines.length - bodyAvail);
|
|
629
807
|
} else {
|
|
630
|
-
startIdx = clamp(
|
|
808
|
+
startIdx = clamp(
|
|
809
|
+
scrollOffset,
|
|
810
|
+
0,
|
|
811
|
+
Math.max(0, allBodyLines.length - bodyAvail),
|
|
812
|
+
);
|
|
631
813
|
}
|
|
632
814
|
|
|
633
815
|
const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
|
|
634
816
|
if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
|
|
635
817
|
|
|
636
818
|
// scroll indicator — MOCHA.overlay for position
|
|
637
|
-
const scrollInfo =
|
|
638
|
-
|
|
639
|
-
|
|
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);
|
|
640
826
|
|
|
641
827
|
const contentLines = [
|
|
642
828
|
...stickyLines,
|
|
@@ -650,23 +836,49 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
650
836
|
: MOCHA.border;
|
|
651
837
|
const paneWidth = Math.max(MIN_CARD_WIDTH, width);
|
|
652
838
|
const framed = box(contentLines, paneWidth, borderColor, {
|
|
653
|
-
highlightPos: focused
|
|
839
|
+
highlightPos: focused
|
|
840
|
+
? borderHighlightPosition(paneWidth, contentLines.length, time)
|
|
841
|
+
: undefined,
|
|
654
842
|
});
|
|
655
843
|
return [framed.top, ...framed.body, framed.bot];
|
|
656
844
|
}
|
|
657
845
|
|
|
658
846
|
// ── summary bar (≥4 workers) ──────────────────────────────────────────────
|
|
659
|
-
function buildSummaryBar(
|
|
660
|
-
|
|
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
|
+
);
|
|
661
860
|
const chips = names.map((name, idx) => {
|
|
662
861
|
const st = workers.get(name);
|
|
663
862
|
const status = runtimeStatus(st);
|
|
664
|
-
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;
|
|
665
868
|
const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
|
|
666
869
|
return padRight(truncate(label, maxChipWidth), maxChipWidth);
|
|
667
870
|
});
|
|
668
|
-
const chipsLine = truncate(
|
|
669
|
-
|
|
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
|
+
);
|
|
670
882
|
const framed = box([chipsLine, keysLine], width);
|
|
671
883
|
return [framed.top, ...framed.body, framed.bot];
|
|
672
884
|
}
|
|
@@ -699,7 +911,9 @@ function buildHelpOverlay(width, height) {
|
|
|
699
911
|
const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
|
|
700
912
|
const framedRows = [framed.top, ...framed.body, framed.bot];
|
|
701
913
|
const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
|
|
702
|
-
const leftPad = " ".repeat(
|
|
914
|
+
const leftPad = " ".repeat(
|
|
915
|
+
Math.max(0, Math.floor((width - innerWidth - 4) / 2)),
|
|
916
|
+
);
|
|
703
917
|
const result = [];
|
|
704
918
|
for (let i = 0; i < height; i++) {
|
|
705
919
|
const fi = i - topPad;
|
|
@@ -713,56 +927,27 @@ function buildHelpOverlay(width, height) {
|
|
|
713
927
|
}
|
|
714
928
|
|
|
715
929
|
// ── joinColumns ───────────────────────────────────────────────────────────
|
|
716
|
-
function
|
|
930
|
+
function _joinColumns(blocks, gap = GRID_GAP) {
|
|
717
931
|
const maxHeight = Math.max(...blocks.map((b) => b.length));
|
|
718
932
|
return Array.from({ length: maxHeight }, (_, rowIdx) =>
|
|
719
933
|
blocks
|
|
720
|
-
.map(
|
|
934
|
+
.map(
|
|
935
|
+
(block) =>
|
|
936
|
+
block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))),
|
|
937
|
+
)
|
|
721
938
|
.join(" ".repeat(gap)),
|
|
722
939
|
);
|
|
723
940
|
}
|
|
724
941
|
|
|
725
942
|
// ── normalizeWorkerState ──────────────────────────────────────────────────
|
|
726
943
|
function normalizeWorkerState(existing, state) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
: existing.handoff?.verdict,
|
|
735
|
-
files_changed: state.handoff?.files_changed !== undefined
|
|
736
|
-
? sanitizeFiles(state.handoff.files_changed)
|
|
737
|
-
: existing.handoff?.files_changed,
|
|
738
|
-
confidence: state.handoff?.confidence !== undefined
|
|
739
|
-
? sanitizeOneLine(state.handoff.confidence)
|
|
740
|
-
: existing.handoff?.confidence,
|
|
741
|
-
status: state.handoff?.status !== undefined
|
|
742
|
-
? sanitizeOneLine(state.handoff.status)
|
|
743
|
-
: existing.handoff?.status,
|
|
744
|
-
};
|
|
745
|
-
|
|
746
|
-
return {
|
|
747
|
-
...existing,
|
|
748
|
-
...state,
|
|
749
|
-
cli: state.cli !== undefined ? sanitizeOneLine(state.cli, existing.cli || "codex") : (existing.cli || "codex"),
|
|
750
|
-
role: state.role !== undefined ? sanitizeOneLine(state.role) : existing.role,
|
|
751
|
-
status: state.status !== undefined ? sanitizeOneLine(state.status, existing.status || "pending") : (existing.status || "pending"),
|
|
752
|
-
snapshot: state.snapshot !== undefined ? sanitizeTextBlock(state.snapshot) : existing.snapshot,
|
|
753
|
-
summary: state.summary !== undefined ? sanitizeTextBlock(state.summary) : existing.summary,
|
|
754
|
-
detail: state.detail !== undefined ? sanitizeTextBlock(state.detail) : existing.detail,
|
|
755
|
-
findings: state.findings !== undefined ? sanitizeFindings(state.findings) : existing.findings,
|
|
756
|
-
files_changed: state.files_changed !== undefined ? sanitizeFiles(state.files_changed) : existing.files_changed,
|
|
757
|
-
confidence: state.confidence !== undefined ? sanitizeOneLine(state.confidence) : existing.confidence,
|
|
758
|
-
tokens: state.tokens !== undefined ? normalizeTokens(state.tokens) : existing.tokens,
|
|
759
|
-
progress: state.progress !== undefined ? clamp(Number(state.progress) || 0, 0, 1) : existing.progress,
|
|
760
|
-
handoff: nextHandoff,
|
|
761
|
-
_prevStatus: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
|
|
762
|
-
? existing.status : existing._prevStatus,
|
|
763
|
-
_statusChangedAt: (state.status !== undefined && sanitizeOneLine(state.status) !== existing.status)
|
|
764
|
-
? Date.now() : (existing._statusChangedAt || 0),
|
|
765
|
-
};
|
|
944
|
+
return coreNormalizeWorkerState(
|
|
945
|
+
existing || { cli: "codex", status: "pending" },
|
|
946
|
+
state,
|
|
947
|
+
{
|
|
948
|
+
trackChanges: true,
|
|
949
|
+
},
|
|
950
|
+
);
|
|
766
951
|
}
|
|
767
952
|
|
|
768
953
|
// ── createLogDashboard ────────────────────────────────────────────────────
|
|
@@ -777,6 +962,7 @@ function normalizeWorkerState(existing, state) {
|
|
|
777
962
|
* @returns {LogDashboardHandle}
|
|
778
963
|
*/
|
|
779
964
|
export function createLogDashboard(opts = {}) {
|
|
965
|
+
const deps = opts.deps || {};
|
|
780
966
|
const {
|
|
781
967
|
stream = process.stdout,
|
|
782
968
|
input = process.stdin,
|
|
@@ -787,10 +973,26 @@ export function createLogDashboard(opts = {}) {
|
|
|
787
973
|
} = opts;
|
|
788
974
|
|
|
789
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
|
+
});
|
|
790
992
|
|
|
791
993
|
const workers = new Map();
|
|
792
994
|
let pipeline = { phase: "exec", fix_attempt: 0 };
|
|
793
|
-
let startedAt =
|
|
995
|
+
let startedAt = now();
|
|
794
996
|
let timer = null;
|
|
795
997
|
let closed = false;
|
|
796
998
|
let frameCount = 0;
|
|
@@ -806,6 +1008,27 @@ export function createLogDashboard(opts = {}) {
|
|
|
806
1008
|
let inputAttached = false;
|
|
807
1009
|
let rawModeEnabled = false;
|
|
808
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
|
+
|
|
809
1032
|
// virtual row buffer (altScreen 전용)
|
|
810
1033
|
const rowBuf = new RowBuffer();
|
|
811
1034
|
|
|
@@ -819,22 +1042,26 @@ export function createLogDashboard(opts = {}) {
|
|
|
819
1042
|
}
|
|
820
1043
|
|
|
821
1044
|
function nowElapsedSec() {
|
|
822
|
-
return Math.max(0, Math.round((
|
|
1045
|
+
return Math.max(0, Math.round((now() - startedAt) / 1000));
|
|
823
1046
|
}
|
|
824
1047
|
|
|
825
1048
|
function getViewportColumns() {
|
|
826
1049
|
const v = Number.isFinite(columns)
|
|
827
1050
|
? columns
|
|
828
|
-
:
|
|
829
|
-
|
|
830
|
-
|
|
1051
|
+
: Number.isFinite(stream?.columns)
|
|
1052
|
+
? stream.columns
|
|
1053
|
+
: Number.isFinite(process.stdout?.columns)
|
|
1054
|
+
? process.stdout.columns
|
|
1055
|
+
: FALLBACK_COLUMNS;
|
|
831
1056
|
return Math.max(48, v || FALLBACK_COLUMNS);
|
|
832
1057
|
}
|
|
833
1058
|
|
|
834
1059
|
function getViewportRows() {
|
|
835
1060
|
const v = Number.isFinite(stream?.rows)
|
|
836
1061
|
? stream.rows
|
|
837
|
-
:
|
|
1062
|
+
: Number.isFinite(process.stdout?.rows)
|
|
1063
|
+
? process.stdout.rows
|
|
1064
|
+
: FALLBACK_ROWS;
|
|
838
1065
|
return Math.max(10, v || FALLBACK_ROWS);
|
|
839
1066
|
}
|
|
840
1067
|
|
|
@@ -843,8 +1070,12 @@ export function createLogDashboard(opts = {}) {
|
|
|
843
1070
|
}
|
|
844
1071
|
|
|
845
1072
|
function ensureSelectedWorker(names) {
|
|
846
|
-
if (names.length === 0) {
|
|
847
|
-
|
|
1073
|
+
if (names.length === 0) {
|
|
1074
|
+
selectedWorker = null;
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (!selectedWorker || !workers.has(selectedWorker))
|
|
1078
|
+
selectedWorker = names[0];
|
|
848
1079
|
}
|
|
849
1080
|
|
|
850
1081
|
function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
|
|
@@ -888,8 +1119,11 @@ export function createLogDashboard(opts = {}) {
|
|
|
888
1119
|
function doClose() {
|
|
889
1120
|
if (closed) return;
|
|
890
1121
|
if (timer) clearInterval(timer);
|
|
891
|
-
|
|
892
|
-
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);
|
|
893
1127
|
if (inputAttached && typeof input?.pause === "function") input.pause();
|
|
894
1128
|
exitAltScreen();
|
|
895
1129
|
closed = true;
|
|
@@ -898,7 +1132,18 @@ export function createLogDashboard(opts = {}) {
|
|
|
898
1132
|
// ── 키 입력 ──────────────────────────────────────────────────────────
|
|
899
1133
|
function handleInput(chunk) {
|
|
900
1134
|
const key = String(chunk);
|
|
901
|
-
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
|
+
}
|
|
902
1147
|
|
|
903
1148
|
// Help overlay: 아무 키나 누르면 닫기
|
|
904
1149
|
if (helpOverlay) {
|
|
@@ -913,7 +1158,10 @@ export function createLogDashboard(opts = {}) {
|
|
|
913
1158
|
const w = workers.get(selectedWorker);
|
|
914
1159
|
if (!w) return;
|
|
915
1160
|
const sessionTarget = w.sessionName || w.paneName;
|
|
916
|
-
if (!sessionTarget)
|
|
1161
|
+
if (!sessionTarget) {
|
|
1162
|
+
showFlash("세션 정보 없음 — 아직 준비 중");
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
917
1165
|
attachToSession(w);
|
|
918
1166
|
return;
|
|
919
1167
|
}
|
|
@@ -926,33 +1174,97 @@ export function createLogDashboard(opts = {}) {
|
|
|
926
1174
|
}
|
|
927
1175
|
|
|
928
1176
|
// Shift+Arrow: 포커스 이동 + 워커 선택
|
|
929
|
-
if (key === "\x1b[1;2A") {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
|
933
1195
|
|
|
934
1196
|
if (focus === "detail") {
|
|
935
1197
|
// detail 포커스: j/k/ArrowDown/Up = 스크롤
|
|
936
|
-
if (key === "j" || key === "\u001b[B") {
|
|
937
|
-
|
|
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
|
+
}
|
|
938
1206
|
} else {
|
|
939
1207
|
// rail 포커스: j/k = 워커 선택
|
|
940
|
-
if (key === "j" || key === "\u001b[B") {
|
|
941
|
-
|
|
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
|
+
}
|
|
942
1216
|
}
|
|
943
1217
|
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
+
|
|
948
1245
|
// PgUp/PgDn: 페이지 단위 스크롤
|
|
949
1246
|
const pageSize = Math.max(1, Math.floor(getViewportRows() / 2));
|
|
950
|
-
if (key === "\x1b[5~") {
|
|
951
|
-
|
|
1247
|
+
if (key === "\x1b[5~") {
|
|
1248
|
+
scrollDetail(-pageSize);
|
|
1249
|
+
return;
|
|
1250
|
+
} // PgUp
|
|
1251
|
+
if (key === "\x1b[6~") {
|
|
1252
|
+
scrollDetail(pageSize);
|
|
1253
|
+
return;
|
|
1254
|
+
} // PgDn
|
|
952
1255
|
// f: follow-tail 토글
|
|
953
|
-
if (key === "f") {
|
|
1256
|
+
if (key === "f") {
|
|
1257
|
+
followTail = !followTail;
|
|
1258
|
+
if (followTail) detailScrollOffset = 0;
|
|
1259
|
+
render();
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
954
1262
|
// r: raw mode 토글
|
|
955
|
-
if (key === "r") {
|
|
1263
|
+
if (key === "r") {
|
|
1264
|
+
rawMode = !rawMode;
|
|
1265
|
+
render();
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
956
1268
|
// l: 탭 전환 (Log → Detail → Files)
|
|
957
1269
|
if (key === "l") {
|
|
958
1270
|
const tabs = ["log", "detail", "files"];
|
|
@@ -961,54 +1273,123 @@ export function createLogDashboard(opts = {}) {
|
|
|
961
1273
|
render();
|
|
962
1274
|
return;
|
|
963
1275
|
}
|
|
964
|
-
//
|
|
965
|
-
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
|
+
}
|
|
966
1324
|
// h/?: 도움말 오버레이 토글
|
|
967
|
-
if (key === "h" || key === "?") {
|
|
1325
|
+
if (key === "h" || key === "?") {
|
|
1326
|
+
helpOverlay = true;
|
|
1327
|
+
render();
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
968
1330
|
// q: 대시보드 종료
|
|
969
|
-
if (key === "q") {
|
|
1331
|
+
if (key === "q") {
|
|
1332
|
+
doClose();
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
970
1335
|
// 1-9: 워커 직접 선택
|
|
971
1336
|
if (/^[1-9]$/.test(key)) {
|
|
972
1337
|
const names = visibleWorkerNames();
|
|
973
1338
|
const target = names[Number.parseInt(key, 10) - 1];
|
|
974
|
-
if (target) {
|
|
1339
|
+
if (target) {
|
|
1340
|
+
setSelectedWorker(target);
|
|
1341
|
+
render();
|
|
1342
|
+
}
|
|
975
1343
|
return;
|
|
976
1344
|
}
|
|
977
1345
|
}
|
|
978
1346
|
|
|
979
1347
|
// ── Enter→attach (k9s 패턴) ───────────────────────────────────────────
|
|
980
|
-
function attachToSession(worker) {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
+
}
|
|
987
1356
|
|
|
988
|
-
const
|
|
989
|
-
if (
|
|
990
|
-
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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;
|
|
1003
1379
|
}
|
|
1380
|
+
if (typeof input?.pause === "function") input.pause();
|
|
1381
|
+
exitAltScreen();
|
|
1004
1382
|
|
|
1005
|
-
|
|
1006
|
-
setTimeout(() => {
|
|
1383
|
+
setTimeoutFn(() => {
|
|
1007
1384
|
enterAltScreen();
|
|
1008
|
-
if (typeof input?.setRawMode === "function") {
|
|
1385
|
+
if (typeof input?.setRawMode === "function") {
|
|
1386
|
+
input.setRawMode(true);
|
|
1387
|
+
rawModeEnabled = true;
|
|
1388
|
+
}
|
|
1009
1389
|
if (typeof input?.resume === "function") input.resume();
|
|
1010
1390
|
render();
|
|
1011
1391
|
}, 200);
|
|
1392
|
+
return true;
|
|
1012
1393
|
}
|
|
1013
1394
|
|
|
1014
1395
|
// ── flash 메시지 (완료/실패 알림용) ────────────────────────────────────
|
|
@@ -1016,16 +1397,27 @@ export function createLogDashboard(opts = {}) {
|
|
|
1016
1397
|
let flashTimer = null;
|
|
1017
1398
|
function showFlash(msg, durationMs = 5000) {
|
|
1018
1399
|
flashMessage = msg;
|
|
1019
|
-
if (flashTimer)
|
|
1020
|
-
flashTimer =
|
|
1400
|
+
if (flashTimer) clearTimeoutFn(flashTimer);
|
|
1401
|
+
flashTimer = setTimeoutFn(() => {
|
|
1402
|
+
flashMessage = "";
|
|
1403
|
+
render();
|
|
1404
|
+
}, durationMs);
|
|
1021
1405
|
render();
|
|
1022
1406
|
}
|
|
1023
1407
|
|
|
1024
1408
|
function attachInput() {
|
|
1025
1409
|
if (inputAttached) return;
|
|
1026
|
-
if (
|
|
1410
|
+
if (
|
|
1411
|
+
!isTTY ||
|
|
1412
|
+
(!forceTTY && !input?.isTTY) ||
|
|
1413
|
+
typeof input?.on !== "function"
|
|
1414
|
+
)
|
|
1415
|
+
return;
|
|
1027
1416
|
inputAttached = true;
|
|
1028
|
-
if (typeof input.setRawMode === "function") {
|
|
1417
|
+
if (typeof input.setRawMode === "function") {
|
|
1418
|
+
input.setRawMode(true);
|
|
1419
|
+
rawModeEnabled = true;
|
|
1420
|
+
}
|
|
1029
1421
|
if (typeof input.resume === "function") input.resume();
|
|
1030
1422
|
input.on("data", handleInput);
|
|
1031
1423
|
}
|
|
@@ -1061,10 +1453,32 @@ export function createLogDashboard(opts = {}) {
|
|
|
1061
1453
|
const renderTime = Date.now();
|
|
1062
1454
|
|
|
1063
1455
|
// Tier1: 상단 고정 2행
|
|
1064
|
-
const tier1 = buildTier1(
|
|
1456
|
+
const tier1 = buildTier1(
|
|
1457
|
+
names,
|
|
1458
|
+
workers,
|
|
1459
|
+
pipeline,
|
|
1460
|
+
elapsed,
|
|
1461
|
+
totalCols,
|
|
1462
|
+
VERSION,
|
|
1463
|
+
renderTime,
|
|
1464
|
+
);
|
|
1065
1465
|
// flash 메시지 (완료/실패 알림)
|
|
1066
1466
|
if (flashMessage) {
|
|
1067
|
-
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));
|
|
1068
1482
|
}
|
|
1069
1483
|
|
|
1070
1484
|
// 레이아웃 결정
|
|
@@ -1078,9 +1492,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1078
1492
|
|
|
1079
1493
|
// summary+detail: summaryBar + focus pane
|
|
1080
1494
|
if (effectiveLayout === "summary+detail") {
|
|
1081
|
-
const summaryBar = buildSummaryBar(
|
|
1495
|
+
const summaryBar = buildSummaryBar(
|
|
1496
|
+
names,
|
|
1497
|
+
workers,
|
|
1498
|
+
selectedWorker,
|
|
1499
|
+
pipeline,
|
|
1500
|
+
totalCols,
|
|
1501
|
+
VERSION,
|
|
1502
|
+
);
|
|
1082
1503
|
const selectedState = workers.get(selectedWorker);
|
|
1083
|
-
const focusPaneHeight = Math.max(
|
|
1504
|
+
const focusPaneHeight = Math.max(
|
|
1505
|
+
8,
|
|
1506
|
+
totalRows - tier1.length - summaryBar.length,
|
|
1507
|
+
);
|
|
1084
1508
|
const focusPane = buildFocusPane(selectedWorker, selectedState, {
|
|
1085
1509
|
width: totalCols,
|
|
1086
1510
|
height: focusPaneHeight,
|
|
@@ -1094,11 +1518,12 @@ export function createLogDashboard(opts = {}) {
|
|
|
1094
1518
|
return [...tier1, ...summaryBar, ...focusPane];
|
|
1095
1519
|
}
|
|
1096
1520
|
|
|
1097
|
-
// 좌우 분할: Left Rail
|
|
1098
|
-
// 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
|
|
1521
|
+
// 좌우 분할: Left Rail | Right Focus (H/L로 비율 조정 가능)
|
|
1099
1522
|
const GAP = 1; // rail과 focus 사이 구분선
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1523
|
+
const railWidth = Math.max(
|
|
1524
|
+
MIN_CARD_WIDTH,
|
|
1525
|
+
Math.floor(totalCols * panelResizer.ratio),
|
|
1526
|
+
);
|
|
1102
1527
|
const focusWidth = totalCols - railWidth - GAP;
|
|
1103
1528
|
const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
|
|
1104
1529
|
|
|
@@ -1121,7 +1546,8 @@ export function createLogDashboard(opts = {}) {
|
|
|
1121
1546
|
railLines.push(...card);
|
|
1122
1547
|
}
|
|
1123
1548
|
// rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
|
|
1124
|
-
while (railLines.length < bodyHeight)
|
|
1549
|
+
while (railLines.length < bodyHeight)
|
|
1550
|
+
railLines.push(padRight("", railWidth));
|
|
1125
1551
|
if (railLines.length > bodyHeight) railLines.length = bodyHeight;
|
|
1126
1552
|
|
|
1127
1553
|
// Right Focus: 선택된 워커 상세
|
|
@@ -1138,7 +1564,8 @@ export function createLogDashboard(opts = {}) {
|
|
|
1138
1564
|
time: renderTime,
|
|
1139
1565
|
});
|
|
1140
1566
|
}
|
|
1141
|
-
while (focusLines.length < bodyHeight)
|
|
1567
|
+
while (focusLines.length < bodyHeight)
|
|
1568
|
+
focusLines.push(padRight("", focusWidth));
|
|
1142
1569
|
if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
|
|
1143
1570
|
|
|
1144
1571
|
// 좌우 합성: rail[i] + separator + focus[i]
|
|
@@ -1151,15 +1578,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1151
1578
|
}
|
|
1152
1579
|
|
|
1153
1580
|
// 하단 상태바
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
);
|
|
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);
|
|
1158
1589
|
|
|
1159
1590
|
return [...tier1, ...composedRows, statusBar];
|
|
1160
1591
|
}
|
|
1161
1592
|
|
|
1162
|
-
// ── altScreen diff render
|
|
1593
|
+
// ── altScreen diff render (batched single write → 깜빡임 방지) ───────
|
|
1163
1594
|
function renderAltScreen() {
|
|
1164
1595
|
const newRows = buildRows();
|
|
1165
1596
|
rowBuf.set(newRows);
|
|
@@ -1168,16 +1599,16 @@ export function createLogDashboard(opts = {}) {
|
|
|
1168
1599
|
|
|
1169
1600
|
if (dirty.length === 0 && newRows.length === prevLen) return;
|
|
1170
1601
|
|
|
1171
|
-
|
|
1172
|
-
? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
|
|
1173
|
-
: [];
|
|
1174
|
-
|
|
1602
|
+
let buf = "";
|
|
1175
1603
|
for (const i of dirty) {
|
|
1176
|
-
|
|
1604
|
+
buf += moveTo(i + 1, 1) + clearLine + (newRows[i] || "");
|
|
1177
1605
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1606
|
+
if (prevLen > newRows.length) {
|
|
1607
|
+
for (let i = newRows.length; i < prevLen; i++) {
|
|
1608
|
+
buf += moveTo(i + 1, 1) + clearLine;
|
|
1609
|
+
}
|
|
1180
1610
|
}
|
|
1611
|
+
if (buf) write(buf);
|
|
1181
1612
|
|
|
1182
1613
|
rowBuf.commit();
|
|
1183
1614
|
}
|
|
@@ -1218,22 +1649,39 @@ export function createLogDashboard(opts = {}) {
|
|
|
1218
1649
|
// ── 공개 API ─────────────────────────────────────────────────────────
|
|
1219
1650
|
return {
|
|
1220
1651
|
updateWorker(paneName, state) {
|
|
1221
|
-
const existing = workers.get(paneName) || {
|
|
1652
|
+
const existing = workers.get(paneName) || {
|
|
1653
|
+
cli: "codex",
|
|
1654
|
+
status: "pending",
|
|
1655
|
+
};
|
|
1222
1656
|
const merged = normalizeWorkerState(existing, state);
|
|
1223
1657
|
const nextSig = JSON.stringify({
|
|
1224
|
-
cli: merged.cli,
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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,
|
|
1229
1670
|
});
|
|
1230
1671
|
const sigChanged = nextSig !== existing._sig;
|
|
1231
|
-
const explicitElapsed = Number.isFinite(state.elapsed)
|
|
1672
|
+
const explicitElapsed = Number.isFinite(state.elapsed)
|
|
1673
|
+
? Math.max(0, Math.round(state.elapsed))
|
|
1674
|
+
: null;
|
|
1232
1675
|
merged._sig = nextSig;
|
|
1233
1676
|
merged._logSec = sigChanged
|
|
1234
1677
|
? (explicitElapsed ?? nowElapsedSec())
|
|
1235
|
-
:
|
|
1678
|
+
: Number.isFinite(existing._logSec)
|
|
1679
|
+
? existing._logSec
|
|
1680
|
+
: (explicitElapsed ?? nowElapsedSec());
|
|
1236
1681
|
workers.set(paneName, merged);
|
|
1682
|
+
// 토큰 히스토리 추적 (스파크라인용)
|
|
1683
|
+
if (merged.tokens !== undefined)
|
|
1684
|
+
tokenTracker.record(paneName, merged.tokens);
|
|
1237
1685
|
ensureSelectedWorker(visibleWorkerNames());
|
|
1238
1686
|
// follow-tail: 새 데이터 → 자동 scroll 재계산
|
|
1239
1687
|
if (followTail) detailScrollOffset = 0;
|
|
@@ -1286,7 +1734,10 @@ export function createLogDashboard(opts = {}) {
|
|
|
1286
1734
|
|
|
1287
1735
|
setFocusTab(tab) {
|
|
1288
1736
|
const valid = ["log", "detail", "files"];
|
|
1289
|
-
if (valid.includes(tab)) {
|
|
1737
|
+
if (valid.includes(tab)) {
|
|
1738
|
+
focusTab = tab;
|
|
1739
|
+
detailScrollOffset = 0;
|
|
1740
|
+
}
|
|
1290
1741
|
},
|
|
1291
1742
|
|
|
1292
1743
|
getLayout() {
|
|
@@ -1305,7 +1756,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1305
1756
|
|
|
1306
1757
|
attachWorker(name) {
|
|
1307
1758
|
const w = workers.get(name);
|
|
1308
|
-
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();
|
|
1309
1772
|
},
|
|
1310
1773
|
|
|
1311
1774
|
close() {
|
|
@@ -1330,27 +1793,30 @@ export function createLogDashboard(opts = {}) {
|
|
|
1330
1793
|
// failed=red, dead/init/starting=dim
|
|
1331
1794
|
|
|
1332
1795
|
const CONDUCTOR_STATE_LABEL = {
|
|
1333
|
-
init:
|
|
1334
|
-
starting:
|
|
1335
|
-
healthy:
|
|
1336
|
-
stalled:
|
|
1337
|
-
input_wait:
|
|
1338
|
-
failed:
|
|
1339
|
-
restarting:
|
|
1340
|
-
dead:
|
|
1341
|
-
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 },
|
|
1342
1805
|
};
|
|
1343
1806
|
|
|
1344
1807
|
function conductorHealthCell(state) {
|
|
1345
|
-
const entry = CONDUCTOR_STATE_LABEL[state] || {
|
|
1808
|
+
const entry = CONDUCTOR_STATE_LABEL[state] || {
|
|
1809
|
+
label: state.toUpperCase(),
|
|
1810
|
+
seq: FG.gray,
|
|
1811
|
+
};
|
|
1346
1812
|
return `${entry.seq}■ ${entry.label}${RESET}`;
|
|
1347
1813
|
}
|
|
1348
1814
|
|
|
1349
1815
|
function conductorRelTime(ms) {
|
|
1350
|
-
if (!ms) return
|
|
1816
|
+
if (!ms) return "—";
|
|
1351
1817
|
const sec = Math.round((Date.now() - ms) / 1000);
|
|
1352
|
-
if (sec < 0)
|
|
1353
|
-
if (sec < 60)
|
|
1818
|
+
if (sec < 0) return "—";
|
|
1819
|
+
if (sec < 60) return `${sec}s ago`;
|
|
1354
1820
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
1355
1821
|
return `${Math.floor(sec / 3600)}h ago`;
|
|
1356
1822
|
}
|
|
@@ -1368,31 +1834,32 @@ export function renderConductorTier(snapshot, cols = 100) {
|
|
|
1368
1834
|
|
|
1369
1835
|
// ── 열 너비 계산 ────────────────────────────────────────
|
|
1370
1836
|
// ID(8) Agent(7) Host(6) Health(dyn) LastOut(dyn) Restarts(8) Why(rest)
|
|
1371
|
-
const COL_ID
|
|
1372
|
-
const COL_AGENT
|
|
1373
|
-
const COL_HOST
|
|
1837
|
+
const COL_ID = 8;
|
|
1838
|
+
const COL_AGENT = 7;
|
|
1839
|
+
const COL_HOST = 6;
|
|
1374
1840
|
const COL_RESTARTS = 4;
|
|
1375
|
-
const COL_HEALTH
|
|
1376
|
-
const COL_LASTOUT
|
|
1841
|
+
const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
|
|
1842
|
+
const COL_LASTOUT = 9; // '999m ago' = 8 + space
|
|
1377
1843
|
// Why gets the remainder
|
|
1378
|
-
const fixedCols =
|
|
1844
|
+
const fixedCols =
|
|
1845
|
+
COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
|
|
1379
1846
|
const COL_WHY = Math.max(4, inner - fixedCols);
|
|
1380
1847
|
|
|
1381
1848
|
function cell(text, width_) {
|
|
1382
|
-
return clip(String(text ??
|
|
1849
|
+
return clip(String(text ?? ""), width_);
|
|
1383
1850
|
}
|
|
1384
1851
|
|
|
1385
1852
|
function buildRow(id, agent, host, healthCell, lastOut, restarts, why) {
|
|
1386
|
-
const idC
|
|
1387
|
-
const agentC
|
|
1388
|
-
const hostC
|
|
1853
|
+
const idC = cell(id, COL_ID);
|
|
1854
|
+
const agentC = cell(agent, COL_AGENT);
|
|
1855
|
+
const hostC = cell(host, COL_HOST);
|
|
1389
1856
|
const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
|
|
1390
|
-
const lastOutC
|
|
1391
|
-
const whyC
|
|
1857
|
+
const lastOutC = clip(lastOut, COL_LASTOUT);
|
|
1858
|
+
const whyC = cell(why, COL_WHY);
|
|
1392
1859
|
// healthCell already has ANSI codes; pad its visible width manually
|
|
1393
1860
|
const healthVis = wcswidth(stripAnsi(healthCell));
|
|
1394
1861
|
const healthPad = Math.max(0, COL_HEALTH - healthVis);
|
|
1395
|
-
const healthC
|
|
1862
|
+
const healthC = healthCell + " ".repeat(healthPad);
|
|
1396
1863
|
|
|
1397
1864
|
return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
|
|
1398
1865
|
}
|
|
@@ -1407,40 +1874,60 @@ export function renderConductorTier(snapshot, cols = 100) {
|
|
|
1407
1874
|
const dashLeft = 1;
|
|
1408
1875
|
const dashRight = Math.max(0, dashLen - dashLeft);
|
|
1409
1876
|
const borderSeq = MOCHA.border;
|
|
1410
|
-
const topBorder =
|
|
1411
|
-
`${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
|
|
1877
|
+
const topBorder = `${borderSeq}┌${"─".repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${"─".repeat(dashRight)}┐${RESET}`;
|
|
1412
1878
|
|
|
1413
1879
|
// ── ヘッダー行 ───────────────────────────────────────────
|
|
1414
|
-
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
|
+
);
|
|
1415
1889
|
const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
|
|
1416
1890
|
|
|
1417
1891
|
// ── データ行 ────────────────────────────────────────────
|
|
1418
1892
|
const dataLines = [];
|
|
1419
1893
|
if (!snapshot || snapshot.length === 0) {
|
|
1420
|
-
const emptyMsg = color(
|
|
1421
|
-
const
|
|
1422
|
-
|
|
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
|
+
);
|
|
1423
1902
|
} else {
|
|
1424
1903
|
for (const s of snapshot) {
|
|
1425
|
-
const id
|
|
1426
|
-
const agent
|
|
1427
|
-
const host
|
|
1428
|
-
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";
|
|
1429
1908
|
const healthCell = conductorHealthCell(state);
|
|
1430
|
-
const lastOut
|
|
1909
|
+
const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
|
|
1431
1910
|
const restarts = s.restarts ?? 0;
|
|
1432
1911
|
// derive "why" from last state transition context
|
|
1433
1912
|
const why = s.health?.inputWaitPattern
|
|
1434
1913
|
? String(s.health.inputWaitPattern).slice(0, COL_WHY)
|
|
1435
|
-
:
|
|
1436
|
-
|
|
1437
|
-
const rowText = buildRow(
|
|
1914
|
+
: "";
|
|
1915
|
+
|
|
1916
|
+
const rowText = buildRow(
|
|
1917
|
+
id,
|
|
1918
|
+
agent,
|
|
1919
|
+
host,
|
|
1920
|
+
healthCell,
|
|
1921
|
+
lastOut,
|
|
1922
|
+
restarts,
|
|
1923
|
+
why,
|
|
1924
|
+
);
|
|
1438
1925
|
dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
|
|
1439
1926
|
}
|
|
1440
1927
|
}
|
|
1441
1928
|
|
|
1442
1929
|
// ── Bottom border ─────────────────────────────────────
|
|
1443
|
-
const botBorder = `${borderSeq}└${
|
|
1930
|
+
const botBorder = `${borderSeq}└${"─".repeat(boxWidth)}┘${RESET}`;
|
|
1444
1931
|
|
|
1445
1932
|
return [topBorder, headerLine, ...dataLines, botBorder];
|
|
1446
1933
|
}
|