@triflux/remote 10.1.0 → 10.26.0
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 +2179 -709
- 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 +78 -17
- package/hub/team/build-worker-prompt.mjs +73 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +1130 -0
- package/hub/team/claude-native-bridge.mjs +1011 -0
- package/hub/team/claude-session-projection.mjs +74 -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 +151 -33
- package/hub/team/cli/commands/start/parse-args.mjs +97 -13
- package/hub/team/cli/commands/start/start-headless.mjs +155 -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/help.mjs +1 -1
- 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 +61 -4
- 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 +256 -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 +2213 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/interactive-native-launcher.mjs +106 -0
- package/hub/team/interactive-native-worker.mjs +80 -0
- package/hub/team/interactive-tui-transport.mjs +465 -0
- package/hub/team/launcher-template.mjs +49 -17
- 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 +120 -42
- 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 -20
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1022 -281
- 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 +336 -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 +574 -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 +1544 -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 +319 -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 +292 -0
- package/hub/team/tui-lite.mjs +237 -148
- package/hub/team/tui-remote-adapter.mjs +18 -34
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +108 -48
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +931 -439
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +97 -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 +477 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/antigravity-route-worker.mjs +169 -0
- 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 +570 -499
- package/hub/workers/factory.mjs +103 -21
- package/hub/workers/gemini-worker.mjs +126 -284
- package/hub/workers/interface.mjs +24 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +23 -17
- 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 +37 -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-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -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 +224 -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,
|
|
@@ -469,29 +573,42 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
469
573
|
const innerWidth = Math.max(12, width - 4);
|
|
470
574
|
const cli = st.cli || "codex";
|
|
471
575
|
const role = sanitizeOneLine(st.role);
|
|
576
|
+
const displayName = sanitizeOneLine(st.displayName, name);
|
|
472
577
|
const status = runtimeStatus(st);
|
|
473
578
|
const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
|
|
474
|
-
const changeElapsed = st._statusChangedAt
|
|
579
|
+
const changeElapsed = st._statusChangedAt
|
|
580
|
+
? Math.max(0, time - st._statusChangedAt)
|
|
581
|
+
: Number.POSITIVE_INFINITY;
|
|
475
582
|
|
|
476
583
|
// Tier2 행 1: 이름 + CLI + role
|
|
477
584
|
const selMark = selected
|
|
478
|
-
?
|
|
585
|
+
? focused
|
|
586
|
+
? color("▶", MOCHA.blue)
|
|
587
|
+
: color(">", FG.triflux)
|
|
479
588
|
: previousSelected
|
|
480
589
|
? dim("~")
|
|
481
590
|
: " ";
|
|
482
|
-
const hb = heartbeat(
|
|
591
|
+
const hb = heartbeat(
|
|
592
|
+
status,
|
|
593
|
+
status === "running" ? currentShimmer(time) : 0,
|
|
594
|
+
st._statusChangedAt,
|
|
595
|
+
time,
|
|
596
|
+
);
|
|
483
597
|
// host 배지 (원격 워커용)
|
|
484
|
-
const hostBadge =
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
598
|
+
const hostBadge =
|
|
599
|
+
st.host && st.host !== "local"
|
|
600
|
+
? color(`[${st.host}]`, MOCHA.mauve) + " "
|
|
601
|
+
: "";
|
|
602
|
+
const displayRole = dedupeRole(role, displayName, cli);
|
|
488
603
|
const title = truncate(
|
|
489
|
-
`${selMark} ${hb} ${hostBadge}${color(
|
|
604
|
+
`${selMark} ${hb} ${hostBadge}${color(displayName, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
|
|
490
605
|
innerWidth,
|
|
491
606
|
);
|
|
492
607
|
|
|
493
608
|
const cardWidth = Math.max(MIN_CARD_WIDTH, width);
|
|
494
|
-
const borderHighlight = focused
|
|
609
|
+
const borderHighlight = focused
|
|
610
|
+
? borderHighlightPosition(cardWidth, compact ? 2 : 6, time)
|
|
611
|
+
: undefined;
|
|
495
612
|
const titleFlashBg = titleFlash(status, changeElapsed);
|
|
496
613
|
|
|
497
614
|
// status-specific border: focused→mauve, selected→bright, non-selected→glow decay
|
|
@@ -502,37 +619,58 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
502
619
|
}
|
|
503
620
|
if (selected) return statusColor(status);
|
|
504
621
|
const from = statusToRgb(status);
|
|
505
|
-
const decayBase = st._statusChangedAt
|
|
622
|
+
const decayBase = st._statusChangedAt
|
|
623
|
+
? clamp(changeElapsed / CARD_GLOW_MS, 0, 1)
|
|
624
|
+
: 1;
|
|
506
625
|
const decayT = easeOutCubic(decayBase);
|
|
507
|
-
return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 +
|
|
626
|
+
return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + 0.5 * decayT));
|
|
508
627
|
})();
|
|
509
628
|
|
|
510
629
|
if (compact) {
|
|
511
630
|
// compact 2-line 카드
|
|
512
|
-
const progress = Number.isFinite(st.progress)
|
|
631
|
+
const progress = Number.isFinite(st.progress)
|
|
632
|
+
? clamp(st.progress, 0, 1)
|
|
633
|
+
: status === "running"
|
|
634
|
+
? 0.3
|
|
635
|
+
: 1;
|
|
513
636
|
const percent = Math.round(progress * 100);
|
|
514
637
|
const compactLine1 = truncate(
|
|
515
638
|
`${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
|
|
516
639
|
innerWidth,
|
|
517
640
|
);
|
|
518
|
-
const verdict = sanitizeOneLine(
|
|
641
|
+
const verdict = sanitizeOneLine(
|
|
642
|
+
st.handoff?.verdict || st.summary || st.snapshot,
|
|
643
|
+
status,
|
|
644
|
+
);
|
|
519
645
|
const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
|
|
520
|
-
const framed = box(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
646
|
+
const framed = box(
|
|
647
|
+
[compactLine1, compactLine2],
|
|
648
|
+
cardWidth,
|
|
649
|
+
statusBorderColor,
|
|
650
|
+
{
|
|
651
|
+
highlightPos: borderHighlight,
|
|
652
|
+
titleFlashBg,
|
|
653
|
+
},
|
|
654
|
+
);
|
|
524
655
|
return [framed.top, ...framed.body, framed.bot];
|
|
525
656
|
}
|
|
526
657
|
|
|
527
658
|
// Tier2 행 2: 상태 배지 + elapsed + tokens + conf
|
|
528
|
-
const confidence = sanitizeOneLine(
|
|
659
|
+
const confidence = sanitizeOneLine(
|
|
660
|
+
st.handoff?.confidence || st.confidence,
|
|
661
|
+
"n/a",
|
|
662
|
+
);
|
|
529
663
|
const statusLine = truncate(
|
|
530
664
|
`${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
665
|
innerWidth,
|
|
532
666
|
);
|
|
533
667
|
|
|
534
668
|
// Tier2 행 3: progress bar
|
|
535
|
-
const progress = Number.isFinite(st.progress)
|
|
669
|
+
const progress = Number.isFinite(st.progress)
|
|
670
|
+
? clamp(st.progress, 0, 1)
|
|
671
|
+
: status === "running"
|
|
672
|
+
? 0.3
|
|
673
|
+
: 1;
|
|
536
674
|
const percent = Math.round(progress * 100);
|
|
537
675
|
const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
|
|
538
676
|
const bar = progressBar(percent, barWidth, time);
|
|
@@ -542,18 +680,33 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
542
680
|
);
|
|
543
681
|
|
|
544
682
|
// Tier2 행 4-6: verdict / findings / files
|
|
545
|
-
const verdict = sanitizeOneLine(
|
|
546
|
-
|
|
547
|
-
|
|
683
|
+
const verdict = sanitizeOneLine(
|
|
684
|
+
st.handoff?.verdict || st.summary || st.snapshot,
|
|
685
|
+
status,
|
|
686
|
+
);
|
|
687
|
+
const findings =
|
|
688
|
+
detailHighlights(st).join(" / ") || "no notable findings yet";
|
|
689
|
+
const files =
|
|
690
|
+
sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") ||
|
|
691
|
+
"none";
|
|
548
692
|
|
|
549
693
|
const verdictClr = statusColor(status);
|
|
550
694
|
const lines = [
|
|
551
695
|
title,
|
|
552
696
|
statusLine,
|
|
553
697
|
progressLine,
|
|
554
|
-
truncate(
|
|
555
|
-
|
|
556
|
-
|
|
698
|
+
truncate(
|
|
699
|
+
`${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`,
|
|
700
|
+
innerWidth,
|
|
701
|
+
),
|
|
702
|
+
truncate(
|
|
703
|
+
`${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`,
|
|
704
|
+
innerWidth,
|
|
705
|
+
),
|
|
706
|
+
truncate(
|
|
707
|
+
`${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`,
|
|
708
|
+
innerWidth,
|
|
709
|
+
),
|
|
557
710
|
];
|
|
558
711
|
|
|
559
712
|
const framed = box(lines, cardWidth, statusBorderColor, {
|
|
@@ -577,23 +730,47 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
577
730
|
const innerWidth = Math.max(12, width - 4);
|
|
578
731
|
|
|
579
732
|
// verdict sticky 4행
|
|
580
|
-
const verdict = sanitizeOneLine(
|
|
581
|
-
|
|
733
|
+
const verdict = sanitizeOneLine(
|
|
734
|
+
st.handoff?.verdict || st.summary || st.snapshot,
|
|
735
|
+
"—",
|
|
736
|
+
);
|
|
737
|
+
const confidence = sanitizeOneLine(
|
|
738
|
+
st.handoff?.confidence || st.confidence,
|
|
739
|
+
"n/a",
|
|
740
|
+
);
|
|
582
741
|
const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
|
|
583
742
|
const status = runtimeStatus(st);
|
|
584
743
|
|
|
585
744
|
// Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
|
|
586
745
|
const activeTab = opts.activeTab || "log";
|
|
587
|
-
const tabLog =
|
|
588
|
-
|
|
589
|
-
|
|
746
|
+
const tabLog =
|
|
747
|
+
activeTab === "log"
|
|
748
|
+
? `${MOCHA.blue}${bold("[Log]")}`
|
|
749
|
+
: color("[Log]", MOCHA.overlay);
|
|
750
|
+
const tabDetail =
|
|
751
|
+
activeTab === "detail"
|
|
752
|
+
? `${MOCHA.blue}${bold("[Detail]")}`
|
|
753
|
+
: color("[Detail]", MOCHA.overlay);
|
|
754
|
+
const tabFiles =
|
|
755
|
+
activeTab === "files"
|
|
756
|
+
? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}`
|
|
757
|
+
: color(`[Files ${files.length}]`, MOCHA.overlay);
|
|
590
758
|
const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
|
|
591
759
|
|
|
592
760
|
const stickyLines = [
|
|
593
|
-
truncate(
|
|
761
|
+
truncate(
|
|
762
|
+
`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`,
|
|
763
|
+
innerWidth,
|
|
764
|
+
),
|
|
594
765
|
tabBar,
|
|
595
|
-
truncate(
|
|
596
|
-
|
|
766
|
+
truncate(
|
|
767
|
+
`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`,
|
|
768
|
+
innerWidth,
|
|
769
|
+
),
|
|
770
|
+
truncate(
|
|
771
|
+
`${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`,
|
|
772
|
+
innerWidth,
|
|
773
|
+
),
|
|
597
774
|
color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
|
|
598
775
|
];
|
|
599
776
|
|
|
@@ -605,17 +782,22 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
605
782
|
const summaryLines = [];
|
|
606
783
|
for (const key of SUMMARY_KEYS) {
|
|
607
784
|
const value = st.handoff?.[key];
|
|
608
|
-
if (Array.isArray(value) && value.length > 0)
|
|
785
|
+
if (Array.isArray(value) && value.length > 0)
|
|
786
|
+
summaryLines.push(`${key}: ${value.join(", ")}`);
|
|
609
787
|
else if (value) summaryLines.push(`${key}: ${value}`);
|
|
610
788
|
}
|
|
611
|
-
allBodyLines =
|
|
612
|
-
|
|
613
|
-
|
|
789
|
+
allBodyLines =
|
|
790
|
+
summaryLines.length > 0
|
|
791
|
+
? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
|
|
792
|
+
: [dim("no structured data")];
|
|
614
793
|
} else if (activeTab === "files") {
|
|
615
|
-
const filesList = sanitizeFiles(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
794
|
+
const filesList = sanitizeFiles(
|
|
795
|
+
st.handoff?.files_changed || st.files_changed,
|
|
796
|
+
);
|
|
797
|
+
allBodyLines =
|
|
798
|
+
filesList.length > 0
|
|
799
|
+
? filesList.map((f, i) => `${i + 1}. ${f}`)
|
|
800
|
+
: [dim("no files changed")];
|
|
619
801
|
} else {
|
|
620
802
|
allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
|
|
621
803
|
}
|
|
@@ -624,16 +806,24 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
624
806
|
if (followTail) {
|
|
625
807
|
startIdx = Math.max(0, allBodyLines.length - bodyAvail);
|
|
626
808
|
} else {
|
|
627
|
-
startIdx = clamp(
|
|
809
|
+
startIdx = clamp(
|
|
810
|
+
scrollOffset,
|
|
811
|
+
0,
|
|
812
|
+
Math.max(0, allBodyLines.length - bodyAvail),
|
|
813
|
+
);
|
|
628
814
|
}
|
|
629
815
|
|
|
630
816
|
const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
|
|
631
817
|
if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
|
|
632
818
|
|
|
633
819
|
// scroll indicator — MOCHA.overlay for position
|
|
634
|
-
const scrollInfo =
|
|
635
|
-
|
|
636
|
-
|
|
820
|
+
const scrollInfo =
|
|
821
|
+
allBodyLines.length > bodyAvail
|
|
822
|
+
? color(
|
|
823
|
+
`${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`,
|
|
824
|
+
MOCHA.overlay,
|
|
825
|
+
)
|
|
826
|
+
: color(`${allBodyLines.length} lines`, MOCHA.overlay);
|
|
637
827
|
|
|
638
828
|
const contentLines = [
|
|
639
829
|
...stickyLines,
|
|
@@ -647,23 +837,49 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
647
837
|
: MOCHA.border;
|
|
648
838
|
const paneWidth = Math.max(MIN_CARD_WIDTH, width);
|
|
649
839
|
const framed = box(contentLines, paneWidth, borderColor, {
|
|
650
|
-
highlightPos: focused
|
|
840
|
+
highlightPos: focused
|
|
841
|
+
? borderHighlightPosition(paneWidth, contentLines.length, time)
|
|
842
|
+
: undefined,
|
|
651
843
|
});
|
|
652
844
|
return [framed.top, ...framed.body, framed.bot];
|
|
653
845
|
}
|
|
654
846
|
|
|
655
847
|
// ── summary bar (≥4 workers) ──────────────────────────────────────────────
|
|
656
|
-
function buildSummaryBar(
|
|
657
|
-
|
|
848
|
+
function buildSummaryBar(
|
|
849
|
+
names,
|
|
850
|
+
workers,
|
|
851
|
+
selectedWorker,
|
|
852
|
+
pipeline,
|
|
853
|
+
width,
|
|
854
|
+
version,
|
|
855
|
+
) {
|
|
856
|
+
const maxChipWidth = clamp(
|
|
857
|
+
Math.floor((width - 6) / Math.min(names.length, 4)),
|
|
858
|
+
16,
|
|
859
|
+
26,
|
|
860
|
+
);
|
|
658
861
|
const chips = names.map((name, idx) => {
|
|
659
862
|
const st = workers.get(name);
|
|
660
863
|
const status = runtimeStatus(st);
|
|
661
|
-
const progress = Number.isFinite(st.progress)
|
|
864
|
+
const progress = Number.isFinite(st.progress)
|
|
865
|
+
? clamp(st.progress, 0, 1)
|
|
866
|
+
: status === "running"
|
|
867
|
+
? 0.3
|
|
868
|
+
: 1;
|
|
662
869
|
const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
|
|
663
870
|
return padRight(truncate(label, maxChipWidth), maxChipWidth);
|
|
664
871
|
});
|
|
665
|
-
const chipsLine = truncate(
|
|
666
|
-
|
|
872
|
+
const chipsLine = truncate(
|
|
873
|
+
chips.join(color(" │ ", MOCHA.overlay)),
|
|
874
|
+
width - 4,
|
|
875
|
+
);
|
|
876
|
+
const keysLine = truncate(
|
|
877
|
+
color(
|
|
878
|
+
"Tab:focus • j/k:nav • gg/G:jump • /:search • n/N:next • H/L:resize • f:follow • l:tab",
|
|
879
|
+
MOCHA.subtext,
|
|
880
|
+
),
|
|
881
|
+
width - 4,
|
|
882
|
+
);
|
|
667
883
|
const framed = box([chipsLine, keysLine], width);
|
|
668
884
|
return [framed.top, ...framed.body, framed.bot];
|
|
669
885
|
}
|
|
@@ -696,7 +912,9 @@ function buildHelpOverlay(width, height) {
|
|
|
696
912
|
const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
|
|
697
913
|
const framedRows = [framed.top, ...framed.body, framed.bot];
|
|
698
914
|
const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
|
|
699
|
-
const leftPad = " ".repeat(
|
|
915
|
+
const leftPad = " ".repeat(
|
|
916
|
+
Math.max(0, Math.floor((width - innerWidth - 4) / 2)),
|
|
917
|
+
);
|
|
700
918
|
const result = [];
|
|
701
919
|
for (let i = 0; i < height; i++) {
|
|
702
920
|
const fi = i - topPad;
|
|
@@ -714,52 +932,23 @@ function _joinColumns(blocks, gap = GRID_GAP) {
|
|
|
714
932
|
const maxHeight = Math.max(...blocks.map((b) => b.length));
|
|
715
933
|
return Array.from({ length: maxHeight }, (_, rowIdx) =>
|
|
716
934
|
blocks
|
|
717
|
-
.map(
|
|
935
|
+
.map(
|
|
936
|
+
(block) =>
|
|
937
|
+
block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))),
|
|
938
|
+
)
|
|
718
939
|
.join(" ".repeat(gap)),
|
|
719
940
|
);
|
|
720
941
|
}
|
|
721
942
|
|
|
722
943
|
// ── normalizeWorkerState ──────────────────────────────────────────────────
|
|
723
944
|
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
|
-
};
|
|
945
|
+
return coreNormalizeWorkerState(
|
|
946
|
+
existing || { cli: "codex", status: "pending" },
|
|
947
|
+
state,
|
|
948
|
+
{
|
|
949
|
+
trackChanges: true,
|
|
950
|
+
},
|
|
951
|
+
);
|
|
763
952
|
}
|
|
764
953
|
|
|
765
954
|
// ── createLogDashboard ────────────────────────────────────────────────────
|
|
@@ -774,6 +963,7 @@ function normalizeWorkerState(existing, state) {
|
|
|
774
963
|
* @returns {LogDashboardHandle}
|
|
775
964
|
*/
|
|
776
965
|
export function createLogDashboard(opts = {}) {
|
|
966
|
+
const deps = opts.deps || {};
|
|
777
967
|
const {
|
|
778
968
|
stream = process.stdout,
|
|
779
969
|
input = process.stdin,
|
|
@@ -784,10 +974,26 @@ export function createLogDashboard(opts = {}) {
|
|
|
784
974
|
} = opts;
|
|
785
975
|
|
|
786
976
|
const isTTY = forceTTY || !!stream?.isTTY;
|
|
977
|
+
const now = deps.now || Date.now;
|
|
978
|
+
const setTimeoutFn = deps.setTimeout || setTimeout;
|
|
979
|
+
const clearTimeoutFn = deps.clearTimeout || clearTimeout;
|
|
980
|
+
const openTab =
|
|
981
|
+
deps.openTab ||
|
|
982
|
+
((request) => {
|
|
983
|
+
const wtm = createWtManager();
|
|
984
|
+
return wtm.createTab({ title: request.title, command: request.command });
|
|
985
|
+
});
|
|
986
|
+
const attachLimiter =
|
|
987
|
+
deps.attachLimiter ||
|
|
988
|
+
createTransientTabLimiter({
|
|
989
|
+
now,
|
|
990
|
+
ttlMs: deps.attachTabTtlMs,
|
|
991
|
+
limits: deps.attachLimits,
|
|
992
|
+
});
|
|
787
993
|
|
|
788
994
|
const workers = new Map();
|
|
789
995
|
let pipeline = { phase: "exec", fix_attempt: 0 };
|
|
790
|
-
let startedAt =
|
|
996
|
+
let startedAt = now();
|
|
791
997
|
let timer = null;
|
|
792
998
|
let closed = false;
|
|
793
999
|
let frameCount = 0;
|
|
@@ -803,6 +1009,27 @@ export function createLogDashboard(opts = {}) {
|
|
|
803
1009
|
let inputAttached = false;
|
|
804
1010
|
let rawModeEnabled = false;
|
|
805
1011
|
|
|
1012
|
+
// UX 위젯 (ISSUE-14)
|
|
1013
|
+
const tokenTracker = createTokenTracker();
|
|
1014
|
+
const searchState = createSearchState();
|
|
1015
|
+
const vimMotion = createVimMotion();
|
|
1016
|
+
const panelResizer = createPanelResizer({
|
|
1017
|
+
initialRatio: focus === "detail" ? 0.2 : 0.3,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// Synapse 실시간 관제 (Phase 3)
|
|
1021
|
+
const synapseMetrics = deps.synapseMetrics || createMetricsCollector();
|
|
1022
|
+
const synapseStream =
|
|
1023
|
+
deps.synapseStream ||
|
|
1024
|
+
createSynapseEventStream({
|
|
1025
|
+
onEvent(event) {
|
|
1026
|
+
synapseMetrics.ingest(event);
|
|
1027
|
+
},
|
|
1028
|
+
fetchImpl: deps.synapseFetch,
|
|
1029
|
+
});
|
|
1030
|
+
// Synapse 자동 시작 (옵트인: deps.enableSynapse)
|
|
1031
|
+
if (deps.enableSynapse) synapseStream.start();
|
|
1032
|
+
|
|
806
1033
|
// virtual row buffer (altScreen 전용)
|
|
807
1034
|
const rowBuf = new RowBuffer();
|
|
808
1035
|
|
|
@@ -816,22 +1043,26 @@ export function createLogDashboard(opts = {}) {
|
|
|
816
1043
|
}
|
|
817
1044
|
|
|
818
1045
|
function nowElapsedSec() {
|
|
819
|
-
return Math.max(0, Math.round((
|
|
1046
|
+
return Math.max(0, Math.round((now() - startedAt) / 1000));
|
|
820
1047
|
}
|
|
821
1048
|
|
|
822
1049
|
function getViewportColumns() {
|
|
823
1050
|
const v = Number.isFinite(columns)
|
|
824
1051
|
? columns
|
|
825
|
-
:
|
|
826
|
-
|
|
827
|
-
|
|
1052
|
+
: Number.isFinite(stream?.columns)
|
|
1053
|
+
? stream.columns
|
|
1054
|
+
: Number.isFinite(process.stdout?.columns)
|
|
1055
|
+
? process.stdout.columns
|
|
1056
|
+
: FALLBACK_COLUMNS;
|
|
828
1057
|
return Math.max(48, v || FALLBACK_COLUMNS);
|
|
829
1058
|
}
|
|
830
1059
|
|
|
831
1060
|
function getViewportRows() {
|
|
832
1061
|
const v = Number.isFinite(stream?.rows)
|
|
833
1062
|
? stream.rows
|
|
834
|
-
:
|
|
1063
|
+
: Number.isFinite(process.stdout?.rows)
|
|
1064
|
+
? process.stdout.rows
|
|
1065
|
+
: FALLBACK_ROWS;
|
|
835
1066
|
return Math.max(10, v || FALLBACK_ROWS);
|
|
836
1067
|
}
|
|
837
1068
|
|
|
@@ -840,8 +1071,12 @@ export function createLogDashboard(opts = {}) {
|
|
|
840
1071
|
}
|
|
841
1072
|
|
|
842
1073
|
function ensureSelectedWorker(names) {
|
|
843
|
-
if (names.length === 0) {
|
|
844
|
-
|
|
1074
|
+
if (names.length === 0) {
|
|
1075
|
+
selectedWorker = null;
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (!selectedWorker || !workers.has(selectedWorker))
|
|
1079
|
+
selectedWorker = names[0];
|
|
845
1080
|
}
|
|
846
1081
|
|
|
847
1082
|
function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
|
|
@@ -885,8 +1120,11 @@ export function createLogDashboard(opts = {}) {
|
|
|
885
1120
|
function doClose() {
|
|
886
1121
|
if (closed) return;
|
|
887
1122
|
if (timer) clearInterval(timer);
|
|
888
|
-
|
|
889
|
-
if (
|
|
1123
|
+
synapseStream.stop();
|
|
1124
|
+
if (inputAttached && typeof input?.off === "function")
|
|
1125
|
+
input.off("data", handleInput);
|
|
1126
|
+
if (rawModeEnabled && typeof input?.setRawMode === "function")
|
|
1127
|
+
input.setRawMode(false);
|
|
890
1128
|
if (inputAttached && typeof input?.pause === "function") input.pause();
|
|
891
1129
|
exitAltScreen();
|
|
892
1130
|
closed = true;
|
|
@@ -895,7 +1133,18 @@ export function createLogDashboard(opts = {}) {
|
|
|
895
1133
|
// ── 키 입력 ──────────────────────────────────────────────────────────
|
|
896
1134
|
function handleInput(chunk) {
|
|
897
1135
|
const key = String(chunk);
|
|
898
|
-
if (key === "\u0003")
|
|
1136
|
+
if (key === "\u0003") {
|
|
1137
|
+
doClose();
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// 검색 모드 활성 중: 키를 검색 상태에 위임
|
|
1142
|
+
if (searchState.active) {
|
|
1143
|
+
if (searchState.handleKey(key)) {
|
|
1144
|
+
render();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
899
1148
|
|
|
900
1149
|
// Help overlay: 아무 키나 누르면 닫기
|
|
901
1150
|
if (helpOverlay) {
|
|
@@ -910,7 +1159,10 @@ export function createLogDashboard(opts = {}) {
|
|
|
910
1159
|
const w = workers.get(selectedWorker);
|
|
911
1160
|
if (!w) return;
|
|
912
1161
|
const sessionTarget = w.sessionName || w.paneName;
|
|
913
|
-
if (!sessionTarget)
|
|
1162
|
+
if (!sessionTarget) {
|
|
1163
|
+
showFlash("세션 정보 없음 — 아직 준비 중");
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
914
1166
|
attachToSession(w);
|
|
915
1167
|
return;
|
|
916
1168
|
}
|
|
@@ -923,33 +1175,97 @@ export function createLogDashboard(opts = {}) {
|
|
|
923
1175
|
}
|
|
924
1176
|
|
|
925
1177
|
// Shift+Arrow: 포커스 이동 + 워커 선택
|
|
926
|
-
if (key === "\x1b[1;2A") {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1178
|
+
if (key === "\x1b[1;2A") {
|
|
1179
|
+
selectRelative(-1);
|
|
1180
|
+
return;
|
|
1181
|
+
} // Shift+Up → 워커 위
|
|
1182
|
+
if (key === "\x1b[1;2B") {
|
|
1183
|
+
selectRelative(1);
|
|
1184
|
+
return;
|
|
1185
|
+
} // Shift+Down → 워커 아래
|
|
1186
|
+
if (key === "\x1b[1;2D") {
|
|
1187
|
+
focus = "rail";
|
|
1188
|
+
render();
|
|
1189
|
+
return;
|
|
1190
|
+
} // Shift+Left → rail
|
|
1191
|
+
if (key === "\x1b[1;2C") {
|
|
1192
|
+
focus = "detail";
|
|
1193
|
+
render();
|
|
1194
|
+
return;
|
|
1195
|
+
} // Shift+Right → detail
|
|
930
1196
|
|
|
931
1197
|
if (focus === "detail") {
|
|
932
1198
|
// detail 포커스: j/k/ArrowDown/Up = 스크롤
|
|
933
|
-
if (key === "j" || key === "\u001b[B") {
|
|
934
|
-
|
|
1199
|
+
if (key === "j" || key === "\u001b[B") {
|
|
1200
|
+
scrollDetail(1);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (key === "k" || key === "\u001b[A") {
|
|
1204
|
+
scrollDetail(-1);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
935
1207
|
} else {
|
|
936
1208
|
// rail 포커스: j/k = 워커 선택
|
|
937
|
-
if (key === "j" || key === "\u001b[B") {
|
|
938
|
-
|
|
1209
|
+
if (key === "j" || key === "\u001b[B") {
|
|
1210
|
+
selectRelative(1);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (key === "k" || key === "\u001b[A") {
|
|
1214
|
+
selectRelative(-1);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
939
1217
|
}
|
|
940
1218
|
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1219
|
+
// vim 모션: gg(첫 워커/상단), G(마지막 워커/하단)
|
|
1220
|
+
const motion = vimMotion.handleKey(key);
|
|
1221
|
+
if (motion === "gg") {
|
|
1222
|
+
if (focus === "detail") {
|
|
1223
|
+
followTail = false;
|
|
1224
|
+
detailScrollOffset = 0;
|
|
1225
|
+
} else {
|
|
1226
|
+
const names = visibleWorkerNames();
|
|
1227
|
+
if (names.length > 0) setSelectedWorker(names[0]);
|
|
1228
|
+
}
|
|
1229
|
+
render();
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (motion === "G") {
|
|
1233
|
+
if (focus === "detail") {
|
|
1234
|
+
followTail = true;
|
|
1235
|
+
detailScrollOffset = 0;
|
|
1236
|
+
} else {
|
|
1237
|
+
const names = visibleWorkerNames();
|
|
1238
|
+
if (names.length > 0) setSelectedWorker(names[names.length - 1]);
|
|
1239
|
+
}
|
|
1240
|
+
render();
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
// 단일 g는 vimMotion 대기 → return하지 않음 (이전 동작 유지)
|
|
1244
|
+
if (key === "g") return;
|
|
1245
|
+
|
|
945
1246
|
// PgUp/PgDn: 페이지 단위 스크롤
|
|
946
1247
|
const pageSize = Math.max(1, Math.floor(getViewportRows() / 2));
|
|
947
|
-
if (key === "\x1b[5~") {
|
|
948
|
-
|
|
1248
|
+
if (key === "\x1b[5~") {
|
|
1249
|
+
scrollDetail(-pageSize);
|
|
1250
|
+
return;
|
|
1251
|
+
} // PgUp
|
|
1252
|
+
if (key === "\x1b[6~") {
|
|
1253
|
+
scrollDetail(pageSize);
|
|
1254
|
+
return;
|
|
1255
|
+
} // PgDn
|
|
949
1256
|
// f: follow-tail 토글
|
|
950
|
-
if (key === "f") {
|
|
1257
|
+
if (key === "f") {
|
|
1258
|
+
followTail = !followTail;
|
|
1259
|
+
if (followTail) detailScrollOffset = 0;
|
|
1260
|
+
render();
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
951
1263
|
// r: raw mode 토글
|
|
952
|
-
if (key === "r") {
|
|
1264
|
+
if (key === "r") {
|
|
1265
|
+
rawMode = !rawMode;
|
|
1266
|
+
render();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
953
1269
|
// l: 탭 전환 (Log → Detail → Files)
|
|
954
1270
|
if (key === "l") {
|
|
955
1271
|
const tabs = ["log", "detail", "files"];
|
|
@@ -958,54 +1274,123 @@ export function createLogDashboard(opts = {}) {
|
|
|
958
1274
|
render();
|
|
959
1275
|
return;
|
|
960
1276
|
}
|
|
961
|
-
//
|
|
962
|
-
if (key === "
|
|
1277
|
+
// /: 검색 모드 활성화 (vim 패턴)
|
|
1278
|
+
if (key === "/") {
|
|
1279
|
+
searchState.activate();
|
|
1280
|
+
render();
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
// n: 검색 결과 다음 / 최근 변경 워커
|
|
1284
|
+
if (key === "n") {
|
|
1285
|
+
if (searchState.query) {
|
|
1286
|
+
const names = visibleWorkerNames();
|
|
1287
|
+
const idx = names.indexOf(selectedWorker);
|
|
1288
|
+
const match = searchState.findMatch(names, idx, 1);
|
|
1289
|
+
if (match >= 0) {
|
|
1290
|
+
setSelectedWorker(names[match]);
|
|
1291
|
+
render();
|
|
1292
|
+
} else {
|
|
1293
|
+
showFlash(`검색 결과 없음: ${searchState.query}`);
|
|
1294
|
+
}
|
|
1295
|
+
} else {
|
|
1296
|
+
selectMostRecentChangedWorker();
|
|
1297
|
+
}
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
// N: 검색 결과 이전
|
|
1301
|
+
if (key === "N") {
|
|
1302
|
+
if (searchState.query) {
|
|
1303
|
+
const names = visibleWorkerNames();
|
|
1304
|
+
const idx = names.indexOf(selectedWorker);
|
|
1305
|
+
const match = searchState.findMatch(names, idx, -1);
|
|
1306
|
+
if (match >= 0) {
|
|
1307
|
+
setSelectedWorker(names[match]);
|
|
1308
|
+
render();
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
// H: rail 축소 (패널 리사이즈)
|
|
1314
|
+
if (key === "H") {
|
|
1315
|
+
panelResizer.shrinkRail();
|
|
1316
|
+
render();
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
// L: rail 확대 (패널 리사이즈)
|
|
1320
|
+
if (key === "L") {
|
|
1321
|
+
panelResizer.expandRail();
|
|
1322
|
+
render();
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
963
1325
|
// h/?: 도움말 오버레이 토글
|
|
964
|
-
if (key === "h" || key === "?") {
|
|
1326
|
+
if (key === "h" || key === "?") {
|
|
1327
|
+
helpOverlay = true;
|
|
1328
|
+
render();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
965
1331
|
// q: 대시보드 종료
|
|
966
|
-
if (key === "q") {
|
|
1332
|
+
if (key === "q") {
|
|
1333
|
+
doClose();
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
967
1336
|
// 1-9: 워커 직접 선택
|
|
968
1337
|
if (/^[1-9]$/.test(key)) {
|
|
969
1338
|
const names = visibleWorkerNames();
|
|
970
1339
|
const target = names[Number.parseInt(key, 10) - 1];
|
|
971
|
-
if (target) {
|
|
1340
|
+
if (target) {
|
|
1341
|
+
setSelectedWorker(target);
|
|
1342
|
+
render();
|
|
1343
|
+
}
|
|
972
1344
|
return;
|
|
973
1345
|
}
|
|
974
1346
|
}
|
|
975
1347
|
|
|
976
1348
|
// ── Enter→attach (k9s 패턴) ───────────────────────────────────────────
|
|
977
|
-
function attachToSession(worker) {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1349
|
+
async function attachToSession(worker) {
|
|
1350
|
+
let request;
|
|
1351
|
+
try {
|
|
1352
|
+
request = buildDashboardAttachRequest(worker);
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
showFlash(`[attach] ${error.message}`);
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
984
1357
|
|
|
985
|
-
const
|
|
986
|
-
if (
|
|
987
|
-
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1358
|
+
const lease = attachLimiter.acquire(request.kind);
|
|
1359
|
+
if (!lease.ok) {
|
|
1360
|
+
const seconds = Math.max(1, Math.ceil(lease.retryAfterMs / 1000));
|
|
1361
|
+
const label = lease.kind === "remote" ? "원격" : "로컬";
|
|
1362
|
+
showFlash(
|
|
1363
|
+
`[attach] ${label} 탭 제한(${lease.limit}/30초) — ${seconds}초 후 다시 시도`,
|
|
1364
|
+
);
|
|
1365
|
+
return false;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
try {
|
|
1369
|
+
await openTab(request);
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
lease.release?.();
|
|
1372
|
+
const reason = error?.reasonCode || error?.message || "unknown";
|
|
1373
|
+
showFlash(`[attach] 탭 열기 실패: ${reason}`);
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
if (rawModeEnabled && typeof input?.setRawMode === "function") {
|
|
1378
|
+
input.setRawMode(false);
|
|
1379
|
+
rawModeEnabled = false;
|
|
1000
1380
|
}
|
|
1381
|
+
if (typeof input?.pause === "function") input.pause();
|
|
1382
|
+
exitAltScreen();
|
|
1001
1383
|
|
|
1002
|
-
|
|
1003
|
-
setTimeout(() => {
|
|
1384
|
+
setTimeoutFn(() => {
|
|
1004
1385
|
enterAltScreen();
|
|
1005
|
-
if (typeof input?.setRawMode === "function") {
|
|
1386
|
+
if (typeof input?.setRawMode === "function") {
|
|
1387
|
+
input.setRawMode(true);
|
|
1388
|
+
rawModeEnabled = true;
|
|
1389
|
+
}
|
|
1006
1390
|
if (typeof input?.resume === "function") input.resume();
|
|
1007
1391
|
render();
|
|
1008
1392
|
}, 200);
|
|
1393
|
+
return true;
|
|
1009
1394
|
}
|
|
1010
1395
|
|
|
1011
1396
|
// ── flash 메시지 (완료/실패 알림용) ────────────────────────────────────
|
|
@@ -1013,16 +1398,27 @@ export function createLogDashboard(opts = {}) {
|
|
|
1013
1398
|
let flashTimer = null;
|
|
1014
1399
|
function showFlash(msg, durationMs = 5000) {
|
|
1015
1400
|
flashMessage = msg;
|
|
1016
|
-
if (flashTimer)
|
|
1017
|
-
flashTimer =
|
|
1401
|
+
if (flashTimer) clearTimeoutFn(flashTimer);
|
|
1402
|
+
flashTimer = setTimeoutFn(() => {
|
|
1403
|
+
flashMessage = "";
|
|
1404
|
+
render();
|
|
1405
|
+
}, durationMs);
|
|
1018
1406
|
render();
|
|
1019
1407
|
}
|
|
1020
1408
|
|
|
1021
1409
|
function attachInput() {
|
|
1022
1410
|
if (inputAttached) return;
|
|
1023
|
-
if (
|
|
1411
|
+
if (
|
|
1412
|
+
!isTTY ||
|
|
1413
|
+
(!forceTTY && !input?.isTTY) ||
|
|
1414
|
+
typeof input?.on !== "function"
|
|
1415
|
+
)
|
|
1416
|
+
return;
|
|
1024
1417
|
inputAttached = true;
|
|
1025
|
-
if (typeof input.setRawMode === "function") {
|
|
1418
|
+
if (typeof input.setRawMode === "function") {
|
|
1419
|
+
input.setRawMode(true);
|
|
1420
|
+
rawModeEnabled = true;
|
|
1421
|
+
}
|
|
1026
1422
|
if (typeof input.resume === "function") input.resume();
|
|
1027
1423
|
input.on("data", handleInput);
|
|
1028
1424
|
}
|
|
@@ -1058,10 +1454,32 @@ export function createLogDashboard(opts = {}) {
|
|
|
1058
1454
|
const renderTime = Date.now();
|
|
1059
1455
|
|
|
1060
1456
|
// Tier1: 상단 고정 2행
|
|
1061
|
-
const tier1 = buildTier1(
|
|
1457
|
+
const tier1 = buildTier1(
|
|
1458
|
+
names,
|
|
1459
|
+
workers,
|
|
1460
|
+
pipeline,
|
|
1461
|
+
elapsed,
|
|
1462
|
+
totalCols,
|
|
1463
|
+
VERSION,
|
|
1464
|
+
renderTime,
|
|
1465
|
+
);
|
|
1062
1466
|
// flash 메시지 (완료/실패 알림)
|
|
1063
1467
|
if (flashMessage) {
|
|
1064
|
-
tier1.push(
|
|
1468
|
+
tier1.push(
|
|
1469
|
+
truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols),
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
// Synapse 실시간 메트릭 (활성 시)
|
|
1473
|
+
if (synapseStream.isRunning) {
|
|
1474
|
+
const metricsSnap = synapseMetrics.snapshot();
|
|
1475
|
+
tier1.push(
|
|
1476
|
+
truncate(renderMetricsTier1(metricsSnap, totalCols), totalCols),
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
// 검색 프롬프트 (/ 모드)
|
|
1480
|
+
const searchPrompt = searchState.renderPrompt(totalCols);
|
|
1481
|
+
if (searchPrompt) {
|
|
1482
|
+
tier1.push(truncate(searchPrompt, totalCols));
|
|
1065
1483
|
}
|
|
1066
1484
|
|
|
1067
1485
|
// 레이아웃 결정
|
|
@@ -1075,9 +1493,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1075
1493
|
|
|
1076
1494
|
// summary+detail: summaryBar + focus pane
|
|
1077
1495
|
if (effectiveLayout === "summary+detail") {
|
|
1078
|
-
const summaryBar = buildSummaryBar(
|
|
1496
|
+
const summaryBar = buildSummaryBar(
|
|
1497
|
+
names,
|
|
1498
|
+
workers,
|
|
1499
|
+
selectedWorker,
|
|
1500
|
+
pipeline,
|
|
1501
|
+
totalCols,
|
|
1502
|
+
VERSION,
|
|
1503
|
+
);
|
|
1079
1504
|
const selectedState = workers.get(selectedWorker);
|
|
1080
|
-
const focusPaneHeight = Math.max(
|
|
1505
|
+
const focusPaneHeight = Math.max(
|
|
1506
|
+
8,
|
|
1507
|
+
totalRows - tier1.length - summaryBar.length,
|
|
1508
|
+
);
|
|
1081
1509
|
const focusPane = buildFocusPane(selectedWorker, selectedState, {
|
|
1082
1510
|
width: totalCols,
|
|
1083
1511
|
height: focusPaneHeight,
|
|
@@ -1091,11 +1519,12 @@ export function createLogDashboard(opts = {}) {
|
|
|
1091
1519
|
return [...tier1, ...summaryBar, ...focusPane];
|
|
1092
1520
|
}
|
|
1093
1521
|
|
|
1094
|
-
// 좌우 분할: Left Rail
|
|
1095
|
-
// 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
|
|
1522
|
+
// 좌우 분할: Left Rail | Right Focus (H/L로 비율 조정 가능)
|
|
1096
1523
|
const GAP = 1; // rail과 focus 사이 구분선
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1524
|
+
const railWidth = Math.max(
|
|
1525
|
+
MIN_CARD_WIDTH,
|
|
1526
|
+
Math.floor(totalCols * panelResizer.ratio),
|
|
1527
|
+
);
|
|
1099
1528
|
const focusWidth = totalCols - railWidth - GAP;
|
|
1100
1529
|
const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
|
|
1101
1530
|
|
|
@@ -1118,7 +1547,8 @@ export function createLogDashboard(opts = {}) {
|
|
|
1118
1547
|
railLines.push(...card);
|
|
1119
1548
|
}
|
|
1120
1549
|
// rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
|
|
1121
|
-
while (railLines.length < bodyHeight)
|
|
1550
|
+
while (railLines.length < bodyHeight)
|
|
1551
|
+
railLines.push(padRight("", railWidth));
|
|
1122
1552
|
if (railLines.length > bodyHeight) railLines.length = bodyHeight;
|
|
1123
1553
|
|
|
1124
1554
|
// Right Focus: 선택된 워커 상세
|
|
@@ -1135,7 +1565,8 @@ export function createLogDashboard(opts = {}) {
|
|
|
1135
1565
|
time: renderTime,
|
|
1136
1566
|
});
|
|
1137
1567
|
}
|
|
1138
|
-
while (focusLines.length < bodyHeight)
|
|
1568
|
+
while (focusLines.length < bodyHeight)
|
|
1569
|
+
focusLines.push(padRight("", focusWidth));
|
|
1139
1570
|
if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
|
|
1140
1571
|
|
|
1141
1572
|
// 좌우 합성: rail[i] + separator + focus[i]
|
|
@@ -1148,15 +1579,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1148
1579
|
}
|
|
1149
1580
|
|
|
1150
1581
|
// 하단 상태바
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
);
|
|
1582
|
+
const allDone = names.every((n) => {
|
|
1583
|
+
const s = runtimeStatus(workers.get(n));
|
|
1584
|
+
return s === "ok" || s === "completed" || s === "failed";
|
|
1585
|
+
});
|
|
1586
|
+
const statusText = allDone
|
|
1587
|
+
? " Enter: attach • q: 종료"
|
|
1588
|
+
: " Enter: attach • Tab: 포커스 • j/k: 이동 • h: 도움말";
|
|
1589
|
+
const statusBar = truncate(color(statusText, MOCHA.subtext), totalCols);
|
|
1155
1590
|
|
|
1156
1591
|
return [...tier1, ...composedRows, statusBar];
|
|
1157
1592
|
}
|
|
1158
1593
|
|
|
1159
|
-
// ── altScreen diff render
|
|
1594
|
+
// ── altScreen diff render (batched single write → 깜빡임 방지) ───────
|
|
1160
1595
|
function renderAltScreen() {
|
|
1161
1596
|
const newRows = buildRows();
|
|
1162
1597
|
rowBuf.set(newRows);
|
|
@@ -1165,16 +1600,16 @@ export function createLogDashboard(opts = {}) {
|
|
|
1165
1600
|
|
|
1166
1601
|
if (dirty.length === 0 && newRows.length === prevLen) return;
|
|
1167
1602
|
|
|
1168
|
-
|
|
1169
|
-
? Array.from({ length: prevLen - newRows.length }, (_, i) => newRows.length + i)
|
|
1170
|
-
: [];
|
|
1171
|
-
|
|
1603
|
+
let buf = "";
|
|
1172
1604
|
for (const i of dirty) {
|
|
1173
|
-
|
|
1605
|
+
buf += moveTo(i + 1, 1) + clearLine + (newRows[i] || "");
|
|
1174
1606
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1607
|
+
if (prevLen > newRows.length) {
|
|
1608
|
+
for (let i = newRows.length; i < prevLen; i++) {
|
|
1609
|
+
buf += moveTo(i + 1, 1) + clearLine;
|
|
1610
|
+
}
|
|
1177
1611
|
}
|
|
1612
|
+
if (buf) write(buf);
|
|
1178
1613
|
|
|
1179
1614
|
rowBuf.commit();
|
|
1180
1615
|
}
|
|
@@ -1215,22 +1650,40 @@ export function createLogDashboard(opts = {}) {
|
|
|
1215
1650
|
// ── 공개 API ─────────────────────────────────────────────────────────
|
|
1216
1651
|
return {
|
|
1217
1652
|
updateWorker(paneName, state) {
|
|
1218
|
-
const existing = workers.get(paneName) || {
|
|
1653
|
+
const existing = workers.get(paneName) || {
|
|
1654
|
+
cli: "codex",
|
|
1655
|
+
status: "pending",
|
|
1656
|
+
};
|
|
1219
1657
|
const merged = normalizeWorkerState(existing, state);
|
|
1220
1658
|
const nextSig = JSON.stringify({
|
|
1221
|
-
cli: merged.cli,
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1659
|
+
cli: merged.cli,
|
|
1660
|
+
status: merged.status,
|
|
1661
|
+
role: merged.role,
|
|
1662
|
+
displayName: merged.displayName,
|
|
1663
|
+
snapshot: merged.snapshot,
|
|
1664
|
+
summary: merged.summary,
|
|
1665
|
+
detail: merged.detail,
|
|
1666
|
+
findings: merged.findings,
|
|
1667
|
+
files_changed: merged.files_changed,
|
|
1668
|
+
confidence: merged.confidence,
|
|
1669
|
+
tokens: merged.tokens,
|
|
1670
|
+
progress: merged.progress,
|
|
1671
|
+
handoff: merged.handoff,
|
|
1226
1672
|
});
|
|
1227
1673
|
const sigChanged = nextSig !== existing._sig;
|
|
1228
|
-
const explicitElapsed = Number.isFinite(state.elapsed)
|
|
1674
|
+
const explicitElapsed = Number.isFinite(state.elapsed)
|
|
1675
|
+
? Math.max(0, Math.round(state.elapsed))
|
|
1676
|
+
: null;
|
|
1229
1677
|
merged._sig = nextSig;
|
|
1230
1678
|
merged._logSec = sigChanged
|
|
1231
1679
|
? (explicitElapsed ?? nowElapsedSec())
|
|
1232
|
-
:
|
|
1680
|
+
: Number.isFinite(existing._logSec)
|
|
1681
|
+
? existing._logSec
|
|
1682
|
+
: (explicitElapsed ?? nowElapsedSec());
|
|
1233
1683
|
workers.set(paneName, merged);
|
|
1684
|
+
// 토큰 히스토리 추적 (스파크라인용)
|
|
1685
|
+
if (merged.tokens !== undefined)
|
|
1686
|
+
tokenTracker.record(paneName, merged.tokens);
|
|
1234
1687
|
ensureSelectedWorker(visibleWorkerNames());
|
|
1235
1688
|
// follow-tail: 새 데이터 → 자동 scroll 재계산
|
|
1236
1689
|
if (followTail) detailScrollOffset = 0;
|
|
@@ -1283,7 +1736,10 @@ export function createLogDashboard(opts = {}) {
|
|
|
1283
1736
|
|
|
1284
1737
|
setFocusTab(tab) {
|
|
1285
1738
|
const valid = ["log", "detail", "files"];
|
|
1286
|
-
if (valid.includes(tab)) {
|
|
1739
|
+
if (valid.includes(tab)) {
|
|
1740
|
+
focusTab = tab;
|
|
1741
|
+
detailScrollOffset = 0;
|
|
1742
|
+
}
|
|
1287
1743
|
},
|
|
1288
1744
|
|
|
1289
1745
|
getLayout() {
|
|
@@ -1302,7 +1758,19 @@ export function createLogDashboard(opts = {}) {
|
|
|
1302
1758
|
|
|
1303
1759
|
attachWorker(name) {
|
|
1304
1760
|
const w = workers.get(name);
|
|
1305
|
-
if (w)
|
|
1761
|
+
if (!w) return false;
|
|
1762
|
+
return attachToSession(w);
|
|
1763
|
+
},
|
|
1764
|
+
|
|
1765
|
+
// Synapse 관제 API
|
|
1766
|
+
startSynapse() {
|
|
1767
|
+
synapseStream.start();
|
|
1768
|
+
},
|
|
1769
|
+
stopSynapse() {
|
|
1770
|
+
synapseStream.stop();
|
|
1771
|
+
},
|
|
1772
|
+
getSynapseMetrics() {
|
|
1773
|
+
return synapseMetrics.snapshot();
|
|
1306
1774
|
},
|
|
1307
1775
|
|
|
1308
1776
|
close() {
|
|
@@ -1327,27 +1795,30 @@ export function createLogDashboard(opts = {}) {
|
|
|
1327
1795
|
// failed=red, dead/init/starting=dim
|
|
1328
1796
|
|
|
1329
1797
|
const CONDUCTOR_STATE_LABEL = {
|
|
1330
|
-
init:
|
|
1331
|
-
starting:
|
|
1332
|
-
healthy:
|
|
1333
|
-
stalled:
|
|
1334
|
-
input_wait:
|
|
1335
|
-
failed:
|
|
1336
|
-
restarting:
|
|
1337
|
-
dead:
|
|
1338
|
-
completed:
|
|
1798
|
+
init: { label: "INIT", seq: MOCHA.subtext },
|
|
1799
|
+
starting: { label: "START", seq: MOCHA.executing },
|
|
1800
|
+
healthy: { label: "OK", seq: MOCHA.ok },
|
|
1801
|
+
stalled: { label: "STALL", seq: MOCHA.yellow },
|
|
1802
|
+
input_wait: { label: "INPUT_WAIT", seq: FG.cyan },
|
|
1803
|
+
failed: { label: "FAIL", seq: MOCHA.fail },
|
|
1804
|
+
restarting: { label: "RESTART", seq: MOCHA.partial },
|
|
1805
|
+
dead: { label: "DEAD", seq: FG.gray },
|
|
1806
|
+
completed: { label: "DONE", seq: MOCHA.ok },
|
|
1339
1807
|
};
|
|
1340
1808
|
|
|
1341
1809
|
function conductorHealthCell(state) {
|
|
1342
|
-
const entry = CONDUCTOR_STATE_LABEL[state] || {
|
|
1810
|
+
const entry = CONDUCTOR_STATE_LABEL[state] || {
|
|
1811
|
+
label: state.toUpperCase(),
|
|
1812
|
+
seq: FG.gray,
|
|
1813
|
+
};
|
|
1343
1814
|
return `${entry.seq}■ ${entry.label}${RESET}`;
|
|
1344
1815
|
}
|
|
1345
1816
|
|
|
1346
1817
|
function conductorRelTime(ms) {
|
|
1347
|
-
if (!ms) return
|
|
1818
|
+
if (!ms) return "—";
|
|
1348
1819
|
const sec = Math.round((Date.now() - ms) / 1000);
|
|
1349
|
-
if (sec < 0)
|
|
1350
|
-
if (sec < 60)
|
|
1820
|
+
if (sec < 0) return "—";
|
|
1821
|
+
if (sec < 60) return `${sec}s ago`;
|
|
1351
1822
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
1352
1823
|
return `${Math.floor(sec / 3600)}h ago`;
|
|
1353
1824
|
}
|
|
@@ -1365,31 +1836,32 @@ export function renderConductorTier(snapshot, cols = 100) {
|
|
|
1365
1836
|
|
|
1366
1837
|
// ── 열 너비 계산 ────────────────────────────────────────
|
|
1367
1838
|
// 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
|
|
1839
|
+
const COL_ID = 8;
|
|
1840
|
+
const COL_AGENT = 7;
|
|
1841
|
+
const COL_HOST = 6;
|
|
1371
1842
|
const COL_RESTARTS = 4;
|
|
1372
|
-
const COL_HEALTH
|
|
1373
|
-
const COL_LASTOUT
|
|
1843
|
+
const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
|
|
1844
|
+
const COL_LASTOUT = 9; // '999m ago' = 8 + space
|
|
1374
1845
|
// Why gets the remainder
|
|
1375
|
-
const fixedCols =
|
|
1846
|
+
const fixedCols =
|
|
1847
|
+
COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
|
|
1376
1848
|
const COL_WHY = Math.max(4, inner - fixedCols);
|
|
1377
1849
|
|
|
1378
1850
|
function cell(text, width_) {
|
|
1379
|
-
return clip(String(text ??
|
|
1851
|
+
return clip(String(text ?? ""), width_);
|
|
1380
1852
|
}
|
|
1381
1853
|
|
|
1382
1854
|
function buildRow(id, agent, host, healthCell, lastOut, restarts, why) {
|
|
1383
|
-
const idC
|
|
1384
|
-
const agentC
|
|
1385
|
-
const hostC
|
|
1855
|
+
const idC = cell(id, COL_ID);
|
|
1856
|
+
const agentC = cell(agent, COL_AGENT);
|
|
1857
|
+
const hostC = cell(host, COL_HOST);
|
|
1386
1858
|
const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
|
|
1387
|
-
const lastOutC
|
|
1388
|
-
const whyC
|
|
1859
|
+
const lastOutC = clip(lastOut, COL_LASTOUT);
|
|
1860
|
+
const whyC = cell(why, COL_WHY);
|
|
1389
1861
|
// healthCell already has ANSI codes; pad its visible width manually
|
|
1390
1862
|
const healthVis = wcswidth(stripAnsi(healthCell));
|
|
1391
1863
|
const healthPad = Math.max(0, COL_HEALTH - healthVis);
|
|
1392
|
-
const healthC
|
|
1864
|
+
const healthC = healthCell + " ".repeat(healthPad);
|
|
1393
1865
|
|
|
1394
1866
|
return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
|
|
1395
1867
|
}
|
|
@@ -1404,40 +1876,60 @@ export function renderConductorTier(snapshot, cols = 100) {
|
|
|
1404
1876
|
const dashLeft = 1;
|
|
1405
1877
|
const dashRight = Math.max(0, dashLen - dashLeft);
|
|
1406
1878
|
const borderSeq = MOCHA.border;
|
|
1407
|
-
const topBorder =
|
|
1408
|
-
`${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
|
|
1879
|
+
const topBorder = `${borderSeq}┌${"─".repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${"─".repeat(dashRight)}┐${RESET}`;
|
|
1409
1880
|
|
|
1410
1881
|
// ── ヘッダー行 ───────────────────────────────────────────
|
|
1411
|
-
const headerRow = buildRow(
|
|
1882
|
+
const headerRow = buildRow(
|
|
1883
|
+
"ID",
|
|
1884
|
+
"Agent",
|
|
1885
|
+
"Host",
|
|
1886
|
+
clip("Health", COL_HEALTH),
|
|
1887
|
+
"Last Out",
|
|
1888
|
+
"Rst",
|
|
1889
|
+
"Why",
|
|
1890
|
+
);
|
|
1412
1891
|
const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
|
|
1413
1892
|
|
|
1414
1893
|
// ── データ行 ────────────────────────────────────────────
|
|
1415
1894
|
const dataLines = [];
|
|
1416
1895
|
if (!snapshot || snapshot.length === 0) {
|
|
1417
|
-
const emptyMsg = color(
|
|
1418
|
-
const _emptyPad = clip(
|
|
1419
|
-
|
|
1896
|
+
const emptyMsg = color("(no sessions)", FG.muted);
|
|
1897
|
+
const _emptyPad = clip(
|
|
1898
|
+
stripAnsi(emptyMsg) === "(no sessions)" ? emptyMsg : emptyMsg,
|
|
1899
|
+
inner,
|
|
1900
|
+
);
|
|
1901
|
+
dataLines.push(
|
|
1902
|
+
`${borderSeq}│${RESET} ${padRight(emptyMsg, inner - 2)} ${borderSeq}│${RESET}`,
|
|
1903
|
+
);
|
|
1420
1904
|
} else {
|
|
1421
1905
|
for (const s of snapshot) {
|
|
1422
|
-
const id
|
|
1423
|
-
const agent
|
|
1424
|
-
const host
|
|
1425
|
-
const state
|
|
1906
|
+
const id = String(s.id ?? "").slice(0, COL_ID);
|
|
1907
|
+
const agent = String(s.agent ?? "unknown").slice(0, COL_AGENT);
|
|
1908
|
+
const host = "local";
|
|
1909
|
+
const state = s.state ?? "init";
|
|
1426
1910
|
const healthCell = conductorHealthCell(state);
|
|
1427
|
-
const lastOut
|
|
1911
|
+
const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
|
|
1428
1912
|
const restarts = s.restarts ?? 0;
|
|
1429
1913
|
// derive "why" from last state transition context
|
|
1430
1914
|
const why = s.health?.inputWaitPattern
|
|
1431
1915
|
? String(s.health.inputWaitPattern).slice(0, COL_WHY)
|
|
1432
|
-
:
|
|
1433
|
-
|
|
1434
|
-
const rowText = buildRow(
|
|
1916
|
+
: "";
|
|
1917
|
+
|
|
1918
|
+
const rowText = buildRow(
|
|
1919
|
+
id,
|
|
1920
|
+
agent,
|
|
1921
|
+
host,
|
|
1922
|
+
healthCell,
|
|
1923
|
+
lastOut,
|
|
1924
|
+
restarts,
|
|
1925
|
+
why,
|
|
1926
|
+
);
|
|
1435
1927
|
dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
|
|
1436
1928
|
}
|
|
1437
1929
|
}
|
|
1438
1930
|
|
|
1439
1931
|
// ── Bottom border ─────────────────────────────────────
|
|
1440
|
-
const botBorder = `${borderSeq}└${
|
|
1932
|
+
const botBorder = `${borderSeq}└${"─".repeat(boxWidth)}┘${RESET}`;
|
|
1441
1933
|
|
|
1442
1934
|
return [topBorder, headerLine, ...dataLines, botBorder];
|
|
1443
1935
|
}
|