triflux 10.3.4 → 10.7.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.
Files changed (329) hide show
  1. package/LICENSE +21 -21
  2. package/bin/tfx-doctor-tui.mjs +1 -1
  3. package/bin/tfx-doctor.mjs +6 -1
  4. package/bin/tfx-profile.mjs +1 -1
  5. package/bin/tfx-setup-tui.mjs +1 -1
  6. package/bin/tfx-setup.mjs +6 -1
  7. package/bin/triflux.mjs +2396 -1140
  8. package/hooks/agent-route-guard.mjs +12 -8
  9. package/hooks/cross-review-tracker.mjs +21 -8
  10. package/hooks/error-context.mjs +19 -7
  11. package/hooks/hook-adaptive-collector.mjs +18 -16
  12. package/hooks/hook-manager.mjs +93 -32
  13. package/hooks/hook-orchestrator.mjs +108 -24
  14. package/hooks/hook-registry.json +11 -0
  15. package/hooks/keyword-rules.json +6 -10
  16. package/hooks/lib/resolve-root.mjs +1 -1
  17. package/hooks/mcp-config-watcher.mjs +6 -2
  18. package/hooks/pipeline-stop.mjs +3 -6
  19. package/hooks/safety-guard.mjs +99 -28
  20. package/hooks/session-start-fast.mjs +143 -0
  21. package/hooks/subagent-verifier.mjs +5 -4
  22. package/hub/account-broker.mjs +256 -60
  23. package/hub/adaptive-diagnostic.mjs +75 -48
  24. package/hub/adaptive-inject.mjs +95 -57
  25. package/hub/adaptive-memory.mjs +156 -42
  26. package/hub/adaptive.mjs +60 -31
  27. package/hub/assign-callbacks.mjs +67 -30
  28. package/hub/bridge.mjs +0 -1
  29. package/hub/cli-adapter-base.mjs +200 -48
  30. package/hub/codex-adapter.mjs +76 -96
  31. package/hub/codex-compat.mjs +3 -3
  32. package/hub/codex-preflight.mjs +63 -37
  33. package/hub/delegator/contracts.mjs +19 -23
  34. package/hub/delegator/index.mjs +3 -3
  35. package/hub/delegator/service.mjs +88 -64
  36. package/hub/delegator/tool-definitions.mjs +5 -5
  37. package/hub/fullcycle.mjs +33 -17
  38. package/hub/gemini-adapter.mjs +69 -94
  39. package/hub/hitl.mjs +89 -30
  40. package/hub/intent.mjs +161 -38
  41. package/hub/lib/cache-guard.mjs +43 -17
  42. package/hub/lib/mcp-response-cache.mjs +66 -32
  43. package/hub/lib/memory-store.mjs +285 -111
  44. package/hub/lib/path-utils.mjs +35 -37
  45. package/hub/lib/process-utils.mjs +106 -37
  46. package/hub/lib/spawn-trace.mjs +527 -0
  47. package/hub/lib/ssh-command.mjs +34 -4
  48. package/hub/lib/ssh-retry.mjs +5 -1
  49. package/hub/lib/uuidv7.mjs +4 -3
  50. package/hub/memory-doctor.mjs +266 -106
  51. package/hub/middleware/request-logger.mjs +61 -34
  52. package/hub/paths.mjs +9 -9
  53. package/hub/pipeline/gates/confidence.mjs +34 -15
  54. package/hub/pipeline/gates/consensus.mjs +27 -15
  55. package/hub/pipeline/gates/index.mjs +7 -3
  56. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  57. package/hub/pipeline/index.mjs +77 -42
  58. package/hub/pipeline/state.mjs +10 -10
  59. package/hub/pipeline/transitions.mjs +40 -23
  60. package/hub/platform.mjs +57 -48
  61. package/hub/promote-penalties.mjs +25 -7
  62. package/hub/quality/deslop.mjs +70 -49
  63. package/hub/research.mjs +32 -25
  64. package/hub/router.mjs +240 -107
  65. package/hub/routing/complexity.mjs +132 -29
  66. package/hub/routing/index.mjs +17 -12
  67. package/hub/routing/q-learning.mjs +76 -28
  68. package/hub/server.mjs +4 -4
  69. package/hub/session-fingerprint.mjs +126 -60
  70. package/hub/state.mjs +84 -43
  71. package/hub/store-adapter.mjs +59 -26
  72. package/hub/store.mjs +356 -153
  73. package/hub/team/agent-map.json +22 -7
  74. package/hub/team/ansi.mjs +186 -122
  75. package/hub/team/backend.mjs +28 -10
  76. package/hub/team/cli/commands/attach.mjs +29 -9
  77. package/hub/team/cli/commands/control.mjs +29 -8
  78. package/hub/team/cli/commands/debug.mjs +32 -11
  79. package/hub/team/cli/commands/focus.mjs +38 -11
  80. package/hub/team/cli/commands/interrupt.mjs +18 -6
  81. package/hub/team/cli/commands/kill.mjs +16 -5
  82. package/hub/team/cli/commands/list.mjs +11 -4
  83. package/hub/team/cli/commands/send.mjs +19 -6
  84. package/hub/team/cli/commands/start/index.mjs +154 -31
  85. package/hub/team/cli/commands/start/parse-args.mjs +38 -11
  86. package/hub/team/cli/commands/start/start-headless.mjs +112 -36
  87. package/hub/team/cli/commands/start/start-in-process.mjs +12 -2
  88. package/hub/team/cli/commands/start/start-mux.mjs +70 -21
  89. package/hub/team/cli/commands/start/start-wt.mjs +29 -12
  90. package/hub/team/cli/commands/status.mjs +43 -14
  91. package/hub/team/cli/commands/stop.mjs +11 -4
  92. package/hub/team/cli/commands/task.mjs +8 -3
  93. package/hub/team/cli/commands/tasks.mjs +1 -1
  94. package/hub/team/cli/index.mjs +2 -2
  95. package/hub/team/cli/manifest.mjs +38 -8
  96. package/hub/team/cli/render.mjs +30 -8
  97. package/hub/team/cli/services/attach-fallback.mjs +31 -11
  98. package/hub/team/cli/services/hub-client.mjs +42 -14
  99. package/hub/team/cli/services/member-selector.mjs +11 -4
  100. package/hub/team/cli/services/native-control.mjs +48 -21
  101. package/hub/team/cli/services/runtime-mode.mjs +2 -1
  102. package/hub/team/cli/services/state-store.mjs +25 -8
  103. package/hub/team/cli/services/task-model.mjs +16 -6
  104. package/hub/team/conductor-mesh-bridge.mjs +24 -23
  105. package/hub/team/conductor.mjs +8 -4
  106. package/hub/team/dashboard-anchor.mjs +4 -5
  107. package/hub/team/dashboard-layout.mjs +3 -1
  108. package/hub/team/dashboard-open.mjs +41 -21
  109. package/hub/team/dashboard.mjs +76 -28
  110. package/hub/team/event-log.mjs +18 -10
  111. package/hub/team/handoff.mjs +31 -15
  112. package/hub/team/headless.mjs +2 -1
  113. package/hub/team/health-probe.mjs +69 -54
  114. package/hub/team/launcher-template.mjs +16 -13
  115. package/hub/team/native-supervisor.mjs +65 -21
  116. package/hub/team/native.mjs +74 -35
  117. package/hub/team/nativeProxy.mjs +184 -113
  118. package/hub/team/notify.mjs +119 -76
  119. package/hub/team/orchestrator.mjs +9 -4
  120. package/hub/team/pane.mjs +12 -7
  121. package/hub/team/process-cleanup.mjs +25 -16
  122. package/hub/team/psmux.mjs +491 -201
  123. package/hub/team/remote-probe.mjs +68 -52
  124. package/hub/team/remote-session.mjs +117 -59
  125. package/hub/team/remote-watcher.mjs +61 -33
  126. package/hub/team/routing.mjs +51 -25
  127. package/hub/team/runtime-strategy.mjs +3 -1
  128. package/hub/team/session.mjs +98 -34
  129. package/hub/team/staleState.mjs +72 -30
  130. package/hub/team/swarm-locks.mjs +15 -13
  131. package/hub/team/swarm-planner.mjs +32 -21
  132. package/hub/team/swarm-reconciler.mjs +48 -23
  133. package/hub/team/tui-lite.mjs +266 -68
  134. package/hub/team/tui-remote-adapter.mjs +14 -10
  135. package/hub/team/tui-viewer.mjs +99 -43
  136. package/hub/team/tui.mjs +708 -271
  137. package/hub/team/worktree-lifecycle.mjs +152 -58
  138. package/hub/team/wt-manager.mjs +24 -14
  139. package/hub/token-mode.mjs +71 -71
  140. package/hub/tray.mjs +66 -23
  141. package/hub/workers/claude-worker.mjs +162 -118
  142. package/hub/workers/codex-mcp.mjs +192 -141
  143. package/hub/workers/delegator-mcp.mjs +507 -333
  144. package/hub/workers/factory.mjs +8 -8
  145. package/hub/workers/gemini-worker.mjs +115 -84
  146. package/hub/workers/interface.mjs +6 -1
  147. package/hub/workers/worker-utils.mjs +21 -14
  148. package/hud/colors.mjs +27 -9
  149. package/hud/constants.mjs +162 -26
  150. package/hud/context-monitor.mjs +82 -41
  151. package/hud/hud-qos-status.mjs +129 -49
  152. package/hud/mission-board.mjs +6 -3
  153. package/hud/providers/claude.mjs +226 -115
  154. package/hud/providers/codex.mjs +62 -22
  155. package/hud/providers/gemini.mjs +168 -56
  156. package/hud/renderers.mjs +384 -119
  157. package/hud/terminal.mjs +101 -31
  158. package/hud/utils.mjs +78 -38
  159. package/mesh/index.mjs +11 -5
  160. package/mesh/mesh-budget.mjs +18 -9
  161. package/mesh/mesh-heartbeat.mjs +1 -1
  162. package/mesh/mesh-queue.mjs +3 -5
  163. package/mesh/mesh-router.mjs +5 -4
  164. package/package.json +2 -1
  165. package/scripts/__tests__/gen-skill-docs.test.mjs +36 -7
  166. package/scripts/__tests__/keyword-detector.test.mjs +77 -28
  167. package/scripts/__tests__/mcp-guard-engine.test.mjs +58 -20
  168. package/scripts/__tests__/remote-spawn-transfer.test.mjs +30 -19
  169. package/scripts/__tests__/remote-spawn.test.mjs +10 -4
  170. package/scripts/__tests__/session-start-fast.test.mjs +36 -0
  171. package/scripts/__tests__/skill-template.test.mjs +98 -50
  172. package/scripts/__tests__/smoke.test.mjs +1 -1
  173. package/scripts/__tests__/spawn-trace.test.mjs +102 -0
  174. package/scripts/__tests__/tfx-doctor-diagnose.test.mjs +48 -0
  175. package/scripts/cache-doctor.mjs +11 -4
  176. package/scripts/cache-warmup.mjs +96 -37
  177. package/scripts/claudemd-sync.mjs +27 -17
  178. package/scripts/codex-gateway-preflight.mjs +52 -37
  179. package/scripts/codex-mcp-gateway-sync.mjs +59 -39
  180. package/scripts/completions/tfx.bash +47 -47
  181. package/scripts/completions/tfx.fish +44 -44
  182. package/scripts/completions/tfx.zsh +83 -83
  183. package/scripts/config-audit.mjs +232 -0
  184. package/scripts/convert-to-tmpl.mjs +54 -0
  185. package/scripts/cross-review-gate.mjs +35 -12
  186. package/scripts/cross-review-tracker.mjs +21 -8
  187. package/scripts/demo.mjs +35 -17
  188. package/scripts/doctor-diagnose.mjs +284 -0
  189. package/scripts/gen-skill-docs.mjs +7 -2
  190. package/scripts/gen-skill-manifest.mjs +2 -1
  191. package/scripts/headless-guard.mjs +86 -48
  192. package/scripts/hub-ensure.mjs +45 -26
  193. package/scripts/keyword-detector.mjs +41 -20
  194. package/scripts/keyword-rules-expander.mjs +47 -30
  195. package/scripts/lib/claudemd-scanner.mjs +6 -1
  196. package/scripts/lib/context.mjs +3 -3
  197. package/scripts/lib/cross-review-utils.mjs +6 -3
  198. package/scripts/lib/env-probe.mjs +47 -28
  199. package/scripts/lib/gemini-profiles.mjs +44 -10
  200. package/scripts/lib/handoff.mjs +33 -17
  201. package/scripts/lib/hook-utils.mjs +8 -6
  202. package/scripts/lib/keyword-rules.mjs +43 -19
  203. package/scripts/lib/logger.mjs +24 -24
  204. package/scripts/lib/mcp-filter.mjs +377 -239
  205. package/scripts/lib/mcp-guard-engine.mjs +194 -79
  206. package/scripts/lib/mcp-manifest.mjs +23 -13
  207. package/scripts/lib/mcp-server-catalog.mjs +300 -63
  208. package/scripts/lib/psmux-info.mjs +11 -6
  209. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  210. package/scripts/lib/skill-template.mjs +30 -7
  211. package/scripts/mcp-check.mjs +58 -39
  212. package/scripts/mcp-gateway-config.mjs +83 -39
  213. package/scripts/mcp-gateway-ensure.mjs +43 -35
  214. package/scripts/mcp-gateway-integration-test.mjs +70 -58
  215. package/scripts/mcp-gateway-start.mjs +126 -60
  216. package/scripts/mcp-gateway-verify.mjs +24 -22
  217. package/scripts/mcp-safety-guard.mjs +44 -11
  218. package/scripts/notion-read.mjs +199 -84
  219. package/scripts/pack.mjs +94 -89
  220. package/scripts/preflight-cache.mjs +27 -10
  221. package/scripts/preinstall.mjs +42 -13
  222. package/scripts/remote-spawn.mjs +309 -94
  223. package/scripts/run.cjs +8 -5
  224. package/scripts/session-spawn-helper.mjs +130 -39
  225. package/scripts/session-stale-cleanup.mjs +123 -0
  226. package/scripts/setup.mjs +941 -492
  227. package/scripts/test-lock.mjs +20 -7
  228. package/scripts/test-tfx-route-no-claude-native.mjs +16 -12
  229. package/scripts/tfx-batch-stats.mjs +32 -11
  230. package/scripts/tfx-gate-activate.mjs +11 -4
  231. package/scripts/tfx-route-post.mjs +87 -20
  232. package/scripts/tfx-route-worker.mjs +57 -51
  233. package/scripts/tfx-route.sh +41 -124
  234. package/scripts/tmp-cleanup.mjs +21 -7
  235. package/scripts/token-snapshot.mjs +204 -85
  236. package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +1 -0
  237. package/skills/.omc/state/idle-notif-cooldown.json +3 -0
  238. package/skills/.omc/state/last-tool-error.json +7 -0
  239. package/skills/.omc/state/subagent-tracking.json +7 -0
  240. package/skills/_templates/base.md +1 -6
  241. package/skills/merge-worktree/SKILL.md.tmpl +144 -0
  242. package/skills/shared/telemetry-segment.md +6 -0
  243. package/skills/star-prompt/SKILL.md.tmpl +222 -0
  244. package/skills/tfx-analysis/SKILL.md.tmpl +107 -0
  245. package/skills/tfx-analysis/skill.json +1 -6
  246. package/skills/tfx-auto/SKILL.md +1 -0
  247. package/skills/tfx-auto-codex/SKILL.md.tmpl +106 -0
  248. package/skills/tfx-auto-codex/skill.json +1 -3
  249. package/skills/tfx-autopilot/SKILL.md.tmpl +116 -0
  250. package/skills/tfx-autopilot/skill.json +1 -5
  251. package/skills/tfx-autoresearch/SKILL.md.tmpl +136 -0
  252. package/skills/tfx-autoroute/SKILL.md.tmpl +189 -0
  253. package/skills/tfx-autoroute/skill.json +1 -7
  254. package/skills/tfx-codex/SKILL.md +1 -0
  255. package/skills/tfx-codex/skill.json +1 -3
  256. package/skills/tfx-codex-swarm/SKILL.md.tmpl +16 -0
  257. package/skills/tfx-codex-swarm/evals/evals.json +1 -1
  258. package/skills/tfx-codex-swarm/skill.json +1 -4
  259. package/skills/tfx-codex-swarm-workspace/iteration-1/benchmark.json +54 -12
  260. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/grading.json +35 -7
  261. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/grading.json +35 -7
  262. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/grading.json +25 -5
  263. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/grading.json +25 -5
  264. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/grading.json +20 -4
  265. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/grading.json +16 -4
  266. package/skills/tfx-consensus/SKILL.md.tmpl +146 -0
  267. package/skills/tfx-debate/SKILL.md.tmpl +192 -0
  268. package/skills/tfx-debate/skill.json +1 -7
  269. package/skills/tfx-deep-analysis/SKILL.md.tmpl +228 -0
  270. package/skills/tfx-deep-analysis/skill.json +1 -5
  271. package/skills/tfx-deep-interview/SKILL.md.tmpl +203 -0
  272. package/skills/tfx-deep-plan/SKILL.md.tmpl +282 -0
  273. package/skills/tfx-deep-qa/SKILL.md.tmpl +165 -0
  274. package/skills/tfx-deep-qa/skill.json +1 -6
  275. package/skills/tfx-deep-research/SKILL.md.tmpl +217 -0
  276. package/skills/tfx-deep-review/SKILL.md.tmpl +179 -0
  277. package/skills/tfx-doctor/SKILL.md +21 -0
  278. package/skills/tfx-doctor/SKILL.md.tmpl +172 -0
  279. package/skills/tfx-doctor/skill.json +1 -3
  280. package/skills/tfx-find/SKILL.md +1 -0
  281. package/skills/tfx-forge/SKILL.md.tmpl +187 -0
  282. package/skills/tfx-fullcycle/SKILL.md.tmpl +286 -0
  283. package/skills/tfx-fullcycle/skill.json +1 -6
  284. package/skills/tfx-gemini/SKILL.md.tmpl +91 -0
  285. package/skills/tfx-gemini/skill.json +1 -3
  286. package/skills/tfx-hooks/SKILL.md.tmpl +216 -0
  287. package/skills/tfx-hooks/skill.json +1 -3
  288. package/skills/tfx-hub/SKILL.md.tmpl +212 -0
  289. package/skills/tfx-hub/skill.json +1 -3
  290. package/skills/tfx-index/SKILL.md +1 -0
  291. package/skills/tfx-index/skill.json +1 -6
  292. package/skills/tfx-interview/SKILL.md.tmpl +285 -0
  293. package/skills/tfx-multi/SKILL.md.tmpl +183 -0
  294. package/skills/tfx-multi/skill.json +1 -3
  295. package/skills/tfx-panel/SKILL.md.tmpl +189 -0
  296. package/skills/tfx-panel/skill.json +1 -7
  297. package/skills/tfx-persist/SKILL.md.tmpl +270 -0
  298. package/skills/tfx-persist/skill.json +1 -7
  299. package/skills/tfx-plan/SKILL.md +1 -0
  300. package/skills/tfx-plan/skill.json +1 -6
  301. package/skills/tfx-profile/SKILL.md.tmpl +239 -0
  302. package/skills/tfx-profile/skill.json +1 -3
  303. package/skills/tfx-prune/SKILL.md.tmpl +200 -0
  304. package/skills/tfx-prune/skill.json +1 -7
  305. package/skills/tfx-psmux-rules/SKILL.md.tmpl +326 -0
  306. package/skills/tfx-psmux-rules/skill.json +1 -4
  307. package/skills/tfx-qa/SKILL.md +1 -0
  308. package/skills/tfx-qa/skill.json +1 -6
  309. package/skills/tfx-ralph/SKILL.md.tmpl +28 -0
  310. package/skills/tfx-ralph/skill.json +1 -4
  311. package/skills/tfx-remote-setup/SKILL.md.tmpl +576 -0
  312. package/skills/tfx-remote-setup/skill.json +1 -3
  313. package/skills/tfx-remote-spawn/SKILL.md.tmpl +263 -0
  314. package/skills/tfx-remote-spawn/references/hosts.json +16 -0
  315. package/skills/tfx-remote-spawn/skill.json +1 -4
  316. package/skills/tfx-research/SKILL.md +1 -0
  317. package/skills/tfx-review/SKILL.md +1 -0
  318. package/skills/tfx-review/skill.json +1 -6
  319. package/skills/tfx-setup/SKILL.md.tmpl +504 -0
  320. package/skills/tfx-setup/skill.json +1 -3
  321. package/skills/tfx-swarm/SKILL.md +22 -0
  322. package/skills/tfx-swarm/SKILL.md.tmpl +218 -0
  323. package/tui/codex-profile.mjs +88 -33
  324. package/tui/core.mjs +45 -15
  325. package/tui/doctor.mjs +75 -28
  326. package/tui/gemini-profile.mjs +74 -29
  327. package/tui/monitor-data.mjs +8 -4
  328. package/tui/monitor.mjs +71 -27
  329. package/tui/setup.mjs +133 -42
package/hub/team/tui.mjs CHANGED
@@ -2,40 +2,41 @@
2
2
  // virtual row buffer 기반. dirty-row만 갱신. isTTY 아닐 때 append-only fallback.
3
3
  // Tier1(상단 고정) / Tier2(worker rail) / Tier3(focus pane) 3단 계층.
4
4
 
5
+ import { execFile as _execFile } from "../lib/spawn-trace.mjs";
5
6
  import {
6
- RESET,
7
- FG,
8
- MOCHA,
9
- color,
10
- dim,
7
+ altScreenOff,
8
+ altScreenOn,
11
9
  bold,
12
10
  box,
13
- padRight,
14
- truncate,
15
- clip,
16
- stripAnsi,
17
- wcswidth,
18
- progressBar,
19
- statusBadge,
20
- altScreenOn,
21
- altScreenOff,
11
+ clearLine,
22
12
  clearScreen,
23
- cursorHome,
13
+ clip,
14
+ color,
24
15
  cursorHide,
16
+ cursorHome,
25
17
  cursorShow,
18
+ dim,
19
+ FG,
20
+ MOCHA,
26
21
  moveTo,
27
- clearLine,
22
+ padRight,
23
+ progressBar,
24
+ RESET,
25
+ statusBadge,
26
+ stripAnsi,
27
+ truncate,
28
+ wcswidth,
28
29
  } from "./ansi.mjs";
29
30
 
30
- import { execFile as _execFile } from "node:child_process";
31
-
32
31
  // package.json에서 동적 로드 (실패 시 fallback)
33
32
  let VERSION = "7.x";
34
33
  try {
35
34
  const { createRequire } = await import("node:module");
36
35
  const require = createRequire(import.meta.url);
37
36
  VERSION = require("../../package.json").version;
38
- } catch { /* fallback */ }
37
+ } catch {
38
+ /* fallback */
39
+ }
39
40
 
40
41
  const FALLBACK_COLUMNS = 100;
41
42
  const FALLBACK_ROWS = 30;
@@ -45,10 +46,13 @@ const MIN_CARD_WIDTH = 28;
45
46
  // 프레임: ["·","✢","✳","✶","✻","✽"] + 역재생 = 12프레임 왕복
46
47
  // 타이밍: 2000ms/cycle, RGB truecolor 보간
47
48
  const SPINNER_FRAMES_RAW = ["·", "✢", "✳", "✶", "✻", "✽"];
48
- const SPINNER_FRAMES = [...SPINNER_FRAMES_RAW, ...[...SPINNER_FRAMES_RAW].reverse()];
49
+ const SPINNER_FRAMES = [
50
+ ...SPINNER_FRAMES_RAW,
51
+ ...[...SPINNER_FRAMES_RAW].reverse(),
52
+ ];
49
53
  const SPINNER_CYCLE_MS = 2000;
50
54
  const SPINNER_BASE_COLOR = { r: 203, g: 166, b: 247 }; // Catppuccin Mocha mauve
51
- const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
55
+ const SPINNER_SHIMMER = { r: 171, g: 43, b: 63 }; // Claude shimmer #ab2b3f
52
56
  const spinnerStart = Date.now();
53
57
  let spinnerTick = 0;
54
58
 
@@ -68,18 +72,28 @@ function pseudoRandomFrame(step, seed) {
68
72
  return Math.abs(Math.imul(step + seed, 2654435761)) % SPINNER_FRAMES.length;
69
73
  }
70
74
 
71
- function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Date.now()) {
72
- const transitionElapsed = statusChangedAt ? Math.max(0, time - statusChangedAt) : Number.POSITIVE_INFINITY;
75
+ function heartbeat(
76
+ status,
77
+ shimmerIntensity = 0,
78
+ statusChangedAt = 0,
79
+ time = Date.now(),
80
+ ) {
81
+ const transitionElapsed = statusChangedAt
82
+ ? Math.max(0, time - statusChangedAt)
83
+ : Number.POSITIVE_INFINITY;
73
84
  if (transitionElapsed < 500) {
74
85
  const step = Math.floor(transitionElapsed / 50);
75
86
  const idx = pseudoRandomFrame(step, statusChangedAt % 997);
76
- const targetColor = status === "failed" || status === "error"
77
- ? MOCHA.fail
78
- : status === "done" || status === "completed"
79
- ? MOCHA.ok
80
- : shimmerIntensity > 0
81
- ? rgbSeq(lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity))
82
- : MOCHA.executing;
87
+ const targetColor =
88
+ status === "failed" || status === "error"
89
+ ? MOCHA.fail
90
+ : status === "done" || status === "completed"
91
+ ? MOCHA.ok
92
+ : shimmerIntensity > 0
93
+ ? rgbSeq(
94
+ lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity),
95
+ )
96
+ : MOCHA.executing;
83
97
  return `${targetColor}${SPINNER_FRAMES[idx]}${RESET}`;
84
98
  }
85
99
 
@@ -87,10 +101,13 @@ function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Dat
87
101
  if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
88
102
  if (status !== "running") return dim("○");
89
103
  const elapsed = time - spinnerStart;
90
- const idx = Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) % SPINNER_FRAMES.length;
91
- const c = shimmerIntensity > 0
92
- ? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
93
- : SPINNER_BASE_COLOR;
104
+ const idx =
105
+ Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) %
106
+ SPINNER_FRAMES.length;
107
+ const c =
108
+ shimmerIntensity > 0
109
+ ? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
110
+ : SPINNER_BASE_COLOR;
94
111
  return `${rgbSeq(c)}${SPINNER_FRAMES[idx]}${RESET}`;
95
112
  }
96
113
 
@@ -107,7 +124,9 @@ function activityWave(tick, count = 4) {
107
124
  let wave = "";
108
125
  for (let i = 0; i < count; i++) {
109
126
  const phase = tick * 0.3 + i * 1.5;
110
- const idx = Math.floor((Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1));
127
+ const idx = Math.floor(
128
+ (Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1),
129
+ );
111
130
  wave += WAVE_CHARS[idx];
112
131
  }
113
132
  return `${MOCHA.executing}${wave}${RESET}`;
@@ -119,17 +138,33 @@ const DEFAULT_DETAIL_LINES = 10;
119
138
  const _TIER1_ROWS = 2;
120
139
 
121
140
  const SUMMARY_KEYS = [
122
- "status", "lead_action", "verdict", "files_changed",
123
- "confidence", "risk", "detail", "error_stage", "retryable", "partial_output",
141
+ "status",
142
+ "lead_action",
143
+ "verdict",
144
+ "files_changed",
145
+ "confidence",
146
+ "risk",
147
+ "detail",
148
+ "error_stage",
149
+ "retryable",
150
+ "partial_output",
124
151
  ];
125
152
 
126
153
  // ── 레이아웃 브레이크포인트 ──────────────────────────────────────────────
127
154
  // 80-119: 28col rail, 120-159: 36col rail, 160+: 균등
128
155
  function _resolveRailWidth(totalCols, columnCount) {
129
156
  if (columnCount <= 1) return totalCols;
130
- if (totalCols >= 160) return Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount);
131
- if (totalCols >= 120) return Math.min(36, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
132
- return Math.min(28, Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount));
157
+ if (totalCols >= 160)
158
+ return Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount);
159
+ if (totalCols >= 120)
160
+ return Math.min(
161
+ 36,
162
+ Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount),
163
+ );
164
+ return Math.min(
165
+ 28,
166
+ Math.floor((totalCols - GRID_GAP * (columnCount - 1)) / columnCount),
167
+ );
133
168
  }
134
169
 
135
170
  function _autoColumnCount(totalCols, workerCount) {
@@ -145,20 +180,24 @@ function clamp(value, min, max) {
145
180
  }
146
181
 
147
182
  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();
183
+ return (
184
+ String(text || "")
185
+ .replace(/\r/g, "")
186
+ // fenced code blocks
187
+ .replace(/```[\s\S]*?(?:```|$)/g, "\n")
188
+ .replace(/^\s*```.*$/gm, "")
189
+ // indented code blocks (4+ spaces or tab at line start)
190
+ .replace(/^(?: {4}|\t).+$/gm, "")
191
+ // shell prompts: PS C:\...>, >, $
192
+ .replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "")
193
+ .trim()
194
+ );
158
195
  }
159
196
 
160
197
  function sanitizeTextBlock(text, rawMode = false) {
161
- const normalized = rawMode ? String(text || "").replace(/\r/g, "") : stripCodeBlocks(text);
198
+ const normalized = rawMode
199
+ ? String(text || "").replace(/\r/g, "")
200
+ : stripCodeBlocks(text);
162
201
  return normalized
163
202
  .split("\n")
164
203
  .map((line) => line.trim())
@@ -228,26 +267,27 @@ function statusColor(status) {
228
267
 
229
268
  // ── MOCHA RGB (gradual fade 보간용) ──
230
269
  const MOCHA_RGB = {
231
- ok: { r: 166, g: 227, b: 161 },
232
- partial: { r: 250, g: 179, b: 135 },
233
- fail: { r: 243, g: 139, b: 168 },
270
+ ok: { r: 166, g: 227, b: 161 },
271
+ partial: { r: 250, g: 179, b: 135 },
272
+ fail: { r: 243, g: 139, b: 168 },
234
273
  executing: { r: 116, g: 199, b: 236 },
235
- muted: { r: 147, g: 153, b: 178 },
236
- border: { r: 69, g: 71, b: 90 },
237
- blue: { r: 137, g: 180, b: 250 },
238
- sky: { r: 116, g: 199, b: 236 },
239
- yellow: { r: 249, g: 226, b: 175 },
240
- peach: { r: 250, g: 179, b: 135 },
241
- maroon: { r: 235, g: 160, b: 172 },
242
- surface0: { r: 49, g: 50, b: 68 },
243
- thinking: { r: 203, g: 166, b: 247 },
274
+ muted: { r: 147, g: 153, b: 178 },
275
+ border: { r: 69, g: 71, b: 90 },
276
+ blue: { r: 137, g: 180, b: 250 },
277
+ sky: { r: 116, g: 199, b: 236 },
278
+ yellow: { r: 249, g: 226, b: 175 },
279
+ peach: { r: 250, g: 179, b: 135 },
280
+ maroon: { r: 235, g: 160, b: 172 },
281
+ surface0: { r: 49, g: 50, b: 68 },
282
+ thinking: { r: 203, g: 166, b: 247 },
244
283
  };
245
284
 
246
285
  function statusToRgb(status) {
247
286
  if (status === "ok" || status === "completed") return MOCHA_RGB.ok;
248
287
  if (status === "partial") return MOCHA_RGB.partial;
249
288
  if (status === "failed") return MOCHA_RGB.fail;
250
- if (status === "running" || status === "in_progress") return MOCHA_RGB.executing;
289
+ if (status === "running" || status === "in_progress")
290
+ return MOCHA_RGB.executing;
251
291
  return MOCHA_RGB.muted;
252
292
  }
253
293
 
@@ -288,7 +328,7 @@ function _flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
288
328
  }
289
329
 
290
330
  function easeOutCubic(t) {
291
- return 1 - ((1 - t) ** 3);
331
+ return 1 - (1 - t) ** 3;
292
332
  }
293
333
 
294
334
  function borderHighlightPosition(width, bodyLines, time = Date.now()) {
@@ -299,13 +339,20 @@ function borderHighlightPosition(width, bodyLines, time = Date.now()) {
299
339
  }
300
340
 
301
341
  function titleFlash(status, changeElapsed) {
302
- const isCompleted = status === "completed" || status === "done" || status === "ok";
303
- const isFailed = status === "failed" || status === "error" || status === "fail";
342
+ const isCompleted =
343
+ status === "completed" || status === "done" || status === "ok";
344
+ const isFailed =
345
+ status === "failed" || status === "error" || status === "fail";
304
346
  if ((!isCompleted && !isFailed) || changeElapsed > 800) return null;
305
347
  const flashRgb = isCompleted ? MOCHA_RGB.ok : MOCHA_RGB.fail;
306
- const bgRgb = changeElapsed <= 300
307
- ? flashRgb
308
- : lerpRgb(flashRgb, MOCHA_RGB.surface0, clamp((changeElapsed - 300) / 500, 0, 1));
348
+ const bgRgb =
349
+ changeElapsed <= 300
350
+ ? flashRgb
351
+ : lerpRgb(
352
+ flashRgb,
353
+ MOCHA_RGB.surface0,
354
+ clamp((changeElapsed - 300) / 500, 0, 1),
355
+ );
309
356
  return rgbSeq(bgRgb, 48);
310
357
  }
311
358
 
@@ -334,9 +381,18 @@ function wrapLine(text, width) {
334
381
  let current = "";
335
382
  for (const word of words) {
336
383
  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; }
384
+ if (wcswidth(candidate) <= limit) {
385
+ current = candidate;
386
+ continue;
387
+ }
388
+ if (current) {
389
+ lines.push(current);
390
+ current = "";
391
+ }
392
+ if (wcswidth(word) <= limit) {
393
+ current = word;
394
+ continue;
395
+ }
340
396
  let offset = 0;
341
397
  while (offset < word.length) {
342
398
  lines.push(word.slice(offset, offset + limit));
@@ -347,20 +403,34 @@ function wrapLine(text, width) {
347
403
  return lines.length > 0 ? lines : [source.slice(0, limit)];
348
404
  }
349
405
 
350
- function _wrapText(text, width, maxLines = DEFAULT_DETAIL_LINES, rawMode = false) {
406
+ function _wrapText(
407
+ text,
408
+ width,
409
+ maxLines = DEFAULT_DETAIL_LINES,
410
+ rawMode = false,
411
+ ) {
351
412
  if (maxLines <= 0) return [];
352
413
  const input = sanitizeTextBlock(text, rawMode);
353
414
  if (!input) return [];
354
- const wrapped = input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
415
+ const wrapped = input
416
+ .split("\n")
417
+ .flatMap((line) => wrapLine(line, width))
418
+ .filter(Boolean);
355
419
  if (wrapped.length <= maxLines) return wrapped;
356
- return [...wrapped.slice(0, maxLines - 1), truncate(wrapped[wrapped.length - 1], width)];
420
+ return [
421
+ ...wrapped.slice(0, maxLines - 1),
422
+ truncate(wrapped[wrapped.length - 1], width),
423
+ ];
357
424
  }
358
425
 
359
426
  // 스크롤 없이 전체 줄 반환 (focus pane용)
360
427
  function wrapTextAll(text, width, rawMode = false) {
361
428
  const input = sanitizeTextBlock(text, rawMode);
362
429
  if (!input) return [];
363
- return input.split("\n").flatMap((line) => wrapLine(line, width)).filter(Boolean);
430
+ return input
431
+ .split("\n")
432
+ .flatMap((line) => wrapLine(line, width))
433
+ .filter(Boolean);
364
434
  }
365
435
 
366
436
  // ── virtual row buffer ────────────────────────────────────────────────────
@@ -388,13 +458,20 @@ class RowBuffer {
388
458
  this._prev = [...this._rows];
389
459
  }
390
460
 
391
- get rows() { return this._rows; }
392
- get prevLen() { return this._prev.length; }
461
+ get rows() {
462
+ return this._rows;
463
+ }
464
+ get prevLen() {
465
+ return this._prev.length;
466
+ }
393
467
  }
394
468
 
395
469
  // ── 상태 집계 ─────────────────────────────────────────────────────────────
396
470
  function countStatuses(names, workers) {
397
- let ok = 0, partial = 0, failed = 0, running = 0;
471
+ let ok = 0,
472
+ partial = 0,
473
+ failed = 0,
474
+ running = 0;
398
475
  for (const name of names) {
399
476
  const st = workers.get(name);
400
477
  const s = runtimeStatus(st);
@@ -409,25 +486,40 @@ function countStatuses(names, workers) {
409
486
  // ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
410
487
  function phaseColor(phase, time = Date.now()) {
411
488
  const shimmer = currentShimmer(time);
412
- if (phase === "exec" || phase === "executing") return rgbSeq(lerpRgb(MOCHA_RGB.blue, MOCHA_RGB.sky, shimmer));
413
- if (phase === "verify" || phase === "verifying") return rgbSeq(lerpRgb(MOCHA_RGB.yellow, MOCHA_RGB.peach, shimmer));
414
- if (phase === "fix" || phase === "fixing") return rgbSeq(lerpRgb(MOCHA_RGB.fail, MOCHA_RGB.maroon, shimmer));
489
+ if (phase === "exec" || phase === "executing")
490
+ return rgbSeq(lerpRgb(MOCHA_RGB.blue, MOCHA_RGB.sky, shimmer));
491
+ if (phase === "verify" || phase === "verifying")
492
+ return rgbSeq(lerpRgb(MOCHA_RGB.yellow, MOCHA_RGB.peach, shimmer));
493
+ if (phase === "fix" || phase === "fixing")
494
+ return rgbSeq(lerpRgb(MOCHA_RGB.fail, MOCHA_RGB.maroon, shimmer));
415
495
  return FG.accent;
416
496
  }
417
497
 
418
- function buildTier1(names, workers, pipeline, elapsed, width, version, time = Date.now()) {
498
+ function buildTier1(
499
+ names,
500
+ workers,
501
+ pipeline,
502
+ elapsed,
503
+ width,
504
+ version,
505
+ time = Date.now(),
506
+ ) {
419
507
  const { ok, partial, failed, running } = countStatuses(names, workers);
420
508
  const phase = pipeline.phase || "exec";
421
509
  const row1 = truncate(
422
510
  `${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase, time))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
423
- `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)}${running > 0 ? ` ${activityWave(spinnerTick)}` : ""}`,
511
+ `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)}${running > 0 ? ` ${activityWave(spinnerTick)}` : ""}`,
424
512
  width,
425
513
  );
426
- const keysHint = color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext);
514
+ const keysHint = color(
515
+ "Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump",
516
+ MOCHA.subtext,
517
+ );
427
518
  const hintWidth = wcswidth(stripAnsi(keysHint));
428
- const row2 = hintWidth >= width
429
- ? truncate(keysHint, width)
430
- : padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
519
+ const row2 =
520
+ hintWidth >= width
521
+ ? truncate(keysHint, width)
522
+ : padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
431
523
  return [row1, row2];
432
524
  }
433
525
 
@@ -437,7 +529,8 @@ function detailText(st) {
437
529
  const lines = [];
438
530
  for (const key of SUMMARY_KEYS) {
439
531
  const value = st.handoff?.[key];
440
- if (Array.isArray(value) && value.length > 0) lines.push(`${key}: ${value.join(", ")}`);
532
+ if (Array.isArray(value) && value.length > 0)
533
+ lines.push(`${key}: ${value.join(", ")}`);
441
534
  else if (value) lines.push(`${key}: ${value}`);
442
535
  }
443
536
  if (st.snapshot) lines.unshift(st.snapshot);
@@ -452,7 +545,10 @@ function detailHighlights(st) {
452
545
  .map((line) => line.replace(/^verdict\s*:\s*/i, "").trim())
453
546
  .filter(Boolean)
454
547
  .filter((line) => line !== verdict)
455
- .filter((line) => !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)))
548
+ .filter(
549
+ (line) =>
550
+ !SUMMARY_KEYS.some((key) => line.toLowerCase().startsWith(`${key}:`)),
551
+ )
456
552
  .slice(0, 2);
457
553
  }
458
554
 
@@ -460,7 +556,7 @@ function buildWorkerRail(name, st, opts = {}) {
460
556
  const {
461
557
  width,
462
558
  selected = false,
463
- focused = false, // rail 포커스 여부
559
+ focused = false, // rail 포커스 여부
464
560
  previousSelected = false,
465
561
  _rawMode = false,
466
562
  compact = false,
@@ -471,19 +567,29 @@ function buildWorkerRail(name, st, opts = {}) {
471
567
  const role = sanitizeOneLine(st.role);
472
568
  const status = runtimeStatus(st);
473
569
  const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
474
- const changeElapsed = st._statusChangedAt ? Math.max(0, time - st._statusChangedAt) : Number.POSITIVE_INFINITY;
570
+ const changeElapsed = st._statusChangedAt
571
+ ? Math.max(0, time - st._statusChangedAt)
572
+ : Number.POSITIVE_INFINITY;
475
573
 
476
574
  // Tier2 행 1: 이름 + CLI + role
477
575
  const selMark = selected
478
- ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux))
576
+ ? focused
577
+ ? color("▶", MOCHA.blue)
578
+ : color(">", FG.triflux)
479
579
  : previousSelected
480
580
  ? dim("~")
481
581
  : " ";
482
- const hb = heartbeat(status, status === "running" ? currentShimmer(time) : 0, st._statusChangedAt, time);
582
+ const hb = heartbeat(
583
+ status,
584
+ status === "running" ? currentShimmer(time) : 0,
585
+ st._statusChangedAt,
586
+ time,
587
+ );
483
588
  // host 배지 (원격 워커용)
484
- const hostBadge = st.host && st.host !== "local"
485
- ? color(`[${st.host}]`, MOCHA.mauve) + " "
486
- : "";
589
+ const hostBadge =
590
+ st.host && st.host !== "local"
591
+ ? color(`[${st.host}]`, MOCHA.mauve) + " "
592
+ : "";
487
593
  const displayRole = dedupeRole(role, name, cli);
488
594
  const title = truncate(
489
595
  `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
@@ -491,7 +597,9 @@ function buildWorkerRail(name, st, opts = {}) {
491
597
  );
492
598
 
493
599
  const cardWidth = Math.max(MIN_CARD_WIDTH, width);
494
- const borderHighlight = focused ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time) : undefined;
600
+ const borderHighlight = focused
601
+ ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time)
602
+ : undefined;
495
603
  const titleFlashBg = titleFlash(status, changeElapsed);
496
604
 
497
605
  // status-specific border: focused→mauve, selected→bright, non-selected→glow decay
@@ -502,37 +610,58 @@ function buildWorkerRail(name, st, opts = {}) {
502
610
  }
503
611
  if (selected) return statusColor(status);
504
612
  const from = statusToRgb(status);
505
- const decayBase = st._statusChangedAt ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1) : 1;
613
+ const decayBase = st._statusChangedAt
614
+ ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1)
615
+ : 1;
506
616
  const decayT = easeOutCubic(decayBase);
507
- return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + (0.5 * decayT)));
617
+ return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + 0.5 * decayT));
508
618
  })();
509
619
 
510
620
  if (compact) {
511
621
  // compact 2-line 카드
512
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
622
+ const progress = Number.isFinite(st.progress)
623
+ ? clamp(st.progress, 0, 1)
624
+ : status === "running"
625
+ ? 0.3
626
+ : 1;
513
627
  const percent = Math.round(progress * 100);
514
628
  const compactLine1 = truncate(
515
629
  `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
516
630
  innerWidth,
517
631
  );
518
- const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
632
+ const verdict = sanitizeOneLine(
633
+ st.handoff?.verdict || st.summary || st.snapshot,
634
+ status,
635
+ );
519
636
  const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
520
- const framed = box([compactLine1, compactLine2], cardWidth, statusBorderColor, {
521
- highlightPos: borderHighlight,
522
- titleFlashBg,
523
- });
637
+ const framed = box(
638
+ [compactLine1, compactLine2],
639
+ cardWidth,
640
+ statusBorderColor,
641
+ {
642
+ highlightPos: borderHighlight,
643
+ titleFlashBg,
644
+ },
645
+ );
524
646
  return [framed.top, ...framed.body, framed.bot];
525
647
  }
526
648
 
527
649
  // Tier2 행 2: 상태 배지 + elapsed + tokens + conf
528
- const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
650
+ const confidence = sanitizeOneLine(
651
+ st.handoff?.confidence || st.confidence,
652
+ "n/a",
653
+ );
529
654
  const statusLine = truncate(
530
655
  `${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
656
  innerWidth,
532
657
  );
533
658
 
534
659
  // Tier2 행 3: progress bar
535
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
660
+ const progress = Number.isFinite(st.progress)
661
+ ? clamp(st.progress, 0, 1)
662
+ : status === "running"
663
+ ? 0.3
664
+ : 1;
536
665
  const percent = Math.round(progress * 100);
537
666
  const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
538
667
  const bar = progressBar(percent, barWidth, time);
@@ -542,18 +671,33 @@ function buildWorkerRail(name, st, opts = {}) {
542
671
  );
543
672
 
544
673
  // Tier2 행 4-6: verdict / findings / files
545
- const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
546
- const findings = detailHighlights(st).join(" / ") || "no notable findings yet";
547
- const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") || "none";
674
+ const verdict = sanitizeOneLine(
675
+ st.handoff?.verdict || st.summary || st.snapshot,
676
+ status,
677
+ );
678
+ const findings =
679
+ detailHighlights(st).join(" / ") || "no notable findings yet";
680
+ const files =
681
+ sanitizeFiles(st.handoff?.files_changed || st.files_changed).join(", ") ||
682
+ "none";
548
683
 
549
684
  const verdictClr = statusColor(status);
550
685
  const lines = [
551
686
  title,
552
687
  statusLine,
553
688
  progressLine,
554
- truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`, innerWidth),
555
- truncate(`${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`, innerWidth),
556
- truncate(`${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`, innerWidth),
689
+ truncate(
690
+ `${color("verdict", MOCHA.overlay)} ${color(verdict, verdictClr)}`,
691
+ innerWidth,
692
+ ),
693
+ truncate(
694
+ `${color("findings", MOCHA.overlay)} ${color(findings, MOCHA.subtext)}`,
695
+ innerWidth,
696
+ ),
697
+ truncate(
698
+ `${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`,
699
+ innerWidth,
700
+ ),
557
701
  ];
558
702
 
559
703
  const framed = box(lines, cardWidth, statusBorderColor, {
@@ -577,23 +721,47 @@ function buildFocusPane(name, st, opts = {}) {
577
721
  const innerWidth = Math.max(12, width - 4);
578
722
 
579
723
  // verdict sticky 4행
580
- const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, "—");
581
- const confidence = sanitizeOneLine(st.handoff?.confidence || st.confidence, "n/a");
724
+ const verdict = sanitizeOneLine(
725
+ st.handoff?.verdict || st.summary || st.snapshot,
726
+ "—",
727
+ );
728
+ const confidence = sanitizeOneLine(
729
+ st.handoff?.confidence || st.confidence,
730
+ "n/a",
731
+ );
582
732
  const files = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
583
733
  const status = runtimeStatus(st);
584
734
 
585
735
  // Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
586
736
  const activeTab = opts.activeTab || "log";
587
- const tabLog = activeTab === "log" ? `${MOCHA.blue}${bold("[Log]")}` : color("[Log]", MOCHA.overlay);
588
- const tabDetail = activeTab === "detail" ? `${MOCHA.blue}${bold("[Detail]")}` : color("[Detail]", MOCHA.overlay);
589
- const tabFiles = activeTab === "files" ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}` : color(`[Files ${files.length}]`, MOCHA.overlay);
737
+ const tabLog =
738
+ activeTab === "log"
739
+ ? `${MOCHA.blue}${bold("[Log]")}`
740
+ : color("[Log]", MOCHA.overlay);
741
+ const tabDetail =
742
+ activeTab === "detail"
743
+ ? `${MOCHA.blue}${bold("[Detail]")}`
744
+ : color("[Detail]", MOCHA.overlay);
745
+ const tabFiles =
746
+ activeTab === "files"
747
+ ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}`
748
+ : color(`[Files ${files.length}]`, MOCHA.overlay);
590
749
  const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
591
750
 
592
751
  const stickyLines = [
593
- truncate(`${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`, innerWidth),
752
+ truncate(
753
+ `${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${statusBadge(status)}`,
754
+ innerWidth,
755
+ ),
594
756
  tabBar,
595
- truncate(`${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`, innerWidth),
596
- truncate(`${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`, innerWidth),
757
+ truncate(
758
+ `${color("verdict", MOCHA.overlay)} ${color(verdict, statusColor(status))}`,
759
+ innerWidth,
760
+ ),
761
+ truncate(
762
+ `${color("conf", MOCHA.overlay)} ${color(confidence, MOCHA.text)}`,
763
+ innerWidth,
764
+ ),
597
765
  color("─", MOCHA.surface0).repeat(Math.max(4, innerWidth)),
598
766
  ];
599
767
 
@@ -605,17 +773,22 @@ function buildFocusPane(name, st, opts = {}) {
605
773
  const summaryLines = [];
606
774
  for (const key of SUMMARY_KEYS) {
607
775
  const value = st.handoff?.[key];
608
- if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
776
+ if (Array.isArray(value) && value.length > 0)
777
+ summaryLines.push(`${key}: ${value.join(", ")}`);
609
778
  else if (value) summaryLines.push(`${key}: ${value}`);
610
779
  }
611
- allBodyLines = summaryLines.length > 0
612
- ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
613
- : [dim("no structured data")];
780
+ allBodyLines =
781
+ summaryLines.length > 0
782
+ ? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
783
+ : [dim("no structured data")];
614
784
  } else if (activeTab === "files") {
615
- const filesList = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
616
- allBodyLines = filesList.length > 0
617
- ? filesList.map((f, i) => `${i + 1}. ${f}`)
618
- : [dim("no files changed")];
785
+ const filesList = sanitizeFiles(
786
+ st.handoff?.files_changed || st.files_changed,
787
+ );
788
+ allBodyLines =
789
+ filesList.length > 0
790
+ ? filesList.map((f, i) => `${i + 1}. ${f}`)
791
+ : [dim("no files changed")];
619
792
  } else {
620
793
  allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
621
794
  }
@@ -624,16 +797,24 @@ function buildFocusPane(name, st, opts = {}) {
624
797
  if (followTail) {
625
798
  startIdx = Math.max(0, allBodyLines.length - bodyAvail);
626
799
  } else {
627
- startIdx = clamp(scrollOffset, 0, Math.max(0, allBodyLines.length - bodyAvail));
800
+ startIdx = clamp(
801
+ scrollOffset,
802
+ 0,
803
+ Math.max(0, allBodyLines.length - bodyAvail),
804
+ );
628
805
  }
629
806
 
630
807
  const bodySlice = allBodyLines.slice(startIdx, startIdx + bodyAvail);
631
808
  if (bodySlice.length === 0) bodySlice.push(dim("no detail available"));
632
809
 
633
810
  // scroll indicator — MOCHA.overlay for position
634
- const scrollInfo = allBodyLines.length > bodyAvail
635
- ? color(`${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`, MOCHA.overlay)
636
- : color(`${allBodyLines.length} lines`, MOCHA.overlay);
811
+ const scrollInfo =
812
+ allBodyLines.length > bodyAvail
813
+ ? color(
814
+ `${startIdx + 1}-${Math.min(startIdx + bodyAvail, allBodyLines.length)}/${allBodyLines.length}`,
815
+ MOCHA.overlay,
816
+ )
817
+ : color(`${allBodyLines.length} lines`, MOCHA.overlay);
637
818
 
638
819
  const contentLines = [
639
820
  ...stickyLines,
@@ -647,23 +828,49 @@ function buildFocusPane(name, st, opts = {}) {
647
828
  : MOCHA.border;
648
829
  const paneWidth = Math.max(MIN_CARD_WIDTH, width);
649
830
  const framed = box(contentLines, paneWidth, borderColor, {
650
- highlightPos: focused ? borderHighlightPosition(paneWidth, contentLines.length, time) : undefined,
831
+ highlightPos: focused
832
+ ? borderHighlightPosition(paneWidth, contentLines.length, time)
833
+ : undefined,
651
834
  });
652
835
  return [framed.top, ...framed.body, framed.bot];
653
836
  }
654
837
 
655
838
  // ── summary bar (≥4 workers) ──────────────────────────────────────────────
656
- function buildSummaryBar(names, workers, selectedWorker, pipeline, width, version) {
657
- const maxChipWidth = clamp(Math.floor((width - 6) / Math.min(names.length, 4)), 16, 26);
839
+ function buildSummaryBar(
840
+ names,
841
+ workers,
842
+ selectedWorker,
843
+ pipeline,
844
+ width,
845
+ version,
846
+ ) {
847
+ const maxChipWidth = clamp(
848
+ Math.floor((width - 6) / Math.min(names.length, 4)),
849
+ 16,
850
+ 26,
851
+ );
658
852
  const chips = names.map((name, idx) => {
659
853
  const st = workers.get(name);
660
854
  const status = runtimeStatus(st);
661
- const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
855
+ const progress = Number.isFinite(st.progress)
856
+ ? clamp(st.progress, 0, 1)
857
+ : status === "running"
858
+ ? 0.3
859
+ : 1;
662
860
  const label = `${selectedWorker === name ? ">" : " "} ${idx + 1}.${name} ${status} ${Math.round(progress * 100)}%`;
663
861
  return padRight(truncate(label, maxChipWidth), maxChipWidth);
664
862
  });
665
- const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
666
- const keysLine = truncate(color("Tab:focus j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext), width - 4);
863
+ const chipsLine = truncate(
864
+ chips.join(color(" ", MOCHA.overlay)),
865
+ width - 4,
866
+ );
867
+ const keysLine = truncate(
868
+ color(
869
+ "Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump",
870
+ MOCHA.subtext,
871
+ ),
872
+ width - 4,
873
+ );
667
874
  const framed = box([chipsLine, keysLine], width);
668
875
  return [framed.top, ...framed.body, framed.bot];
669
876
  }
@@ -696,7 +903,9 @@ function buildHelpOverlay(width, height) {
696
903
  const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
697
904
  const framedRows = [framed.top, ...framed.body, framed.bot];
698
905
  const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
699
- const leftPad = " ".repeat(Math.max(0, Math.floor((width - innerWidth - 4) / 2)));
906
+ const leftPad = " ".repeat(
907
+ Math.max(0, Math.floor((width - innerWidth - 4) / 2)),
908
+ );
700
909
  const result = [];
701
910
  for (let i = 0; i < height; i++) {
702
911
  const fi = i - topPad;
@@ -714,51 +923,96 @@ function _joinColumns(blocks, gap = GRID_GAP) {
714
923
  const maxHeight = Math.max(...blocks.map((b) => b.length));
715
924
  return Array.from({ length: maxHeight }, (_, rowIdx) =>
716
925
  blocks
717
- .map((block) => block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))))
926
+ .map(
927
+ (block) =>
928
+ block[rowIdx] || " ".repeat(wcswidth(stripAnsi(block[0] || ""))),
929
+ )
718
930
  .join(" ".repeat(gap)),
719
931
  );
720
932
  }
721
933
 
722
934
  // ── normalizeWorkerState ──────────────────────────────────────────────────
723
935
  function normalizeWorkerState(existing, state) {
724
- const nextHandoff = state.handoff === undefined
725
- ? existing.handoff
726
- : {
727
- ...(existing.handoff || {}),
728
- ...(state.handoff || {}),
729
- verdict: state.handoff?.verdict !== undefined
730
- ? sanitizeOneLine(state.handoff.verdict)
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
- };
936
+ const nextHandoff =
937
+ state.handoff === undefined
938
+ ? existing.handoff
939
+ : {
940
+ ...(existing.handoff || {}),
941
+ ...(state.handoff || {}),
942
+ verdict:
943
+ state.handoff?.verdict !== undefined
944
+ ? sanitizeOneLine(state.handoff.verdict)
945
+ : existing.handoff?.verdict,
946
+ files_changed:
947
+ state.handoff?.files_changed !== undefined
948
+ ? sanitizeFiles(state.handoff.files_changed)
949
+ : existing.handoff?.files_changed,
950
+ confidence:
951
+ state.handoff?.confidence !== undefined
952
+ ? sanitizeOneLine(state.handoff.confidence)
953
+ : existing.handoff?.confidence,
954
+ status:
955
+ state.handoff?.status !== undefined
956
+ ? sanitizeOneLine(state.handoff.status)
957
+ : existing.handoff?.status,
958
+ };
742
959
 
743
960
  return {
744
961
  ...existing,
745
962
  ...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,
963
+ cli:
964
+ state.cli !== undefined
965
+ ? sanitizeOneLine(state.cli, existing.cli || "codex")
966
+ : existing.cli || "codex",
967
+ role:
968
+ state.role !== undefined ? sanitizeOneLine(state.role) : existing.role,
969
+ status:
970
+ state.status !== undefined
971
+ ? sanitizeOneLine(state.status, existing.status || "pending")
972
+ : existing.status || "pending",
973
+ snapshot:
974
+ state.snapshot !== undefined
975
+ ? sanitizeTextBlock(state.snapshot)
976
+ : existing.snapshot,
977
+ summary:
978
+ state.summary !== undefined
979
+ ? sanitizeTextBlock(state.summary)
980
+ : existing.summary,
981
+ detail:
982
+ state.detail !== undefined
983
+ ? sanitizeTextBlock(state.detail)
984
+ : existing.detail,
985
+ findings:
986
+ state.findings !== undefined
987
+ ? sanitizeFindings(state.findings)
988
+ : existing.findings,
989
+ files_changed:
990
+ state.files_changed !== undefined
991
+ ? sanitizeFiles(state.files_changed)
992
+ : existing.files_changed,
993
+ confidence:
994
+ state.confidence !== undefined
995
+ ? sanitizeOneLine(state.confidence)
996
+ : existing.confidence,
997
+ tokens:
998
+ state.tokens !== undefined
999
+ ? normalizeTokens(state.tokens)
1000
+ : existing.tokens,
1001
+ progress:
1002
+ state.progress !== undefined
1003
+ ? clamp(Number(state.progress) || 0, 0, 1)
1004
+ : existing.progress,
757
1005
  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),
1006
+ _prevStatus:
1007
+ state.status !== undefined &&
1008
+ sanitizeOneLine(state.status) !== existing.status
1009
+ ? existing.status
1010
+ : existing._prevStatus,
1011
+ _statusChangedAt:
1012
+ state.status !== undefined &&
1013
+ sanitizeOneLine(state.status) !== existing.status
1014
+ ? Date.now()
1015
+ : existing._statusChangedAt || 0,
762
1016
  };
763
1017
  }
764
1018
 
@@ -822,16 +1076,20 @@ export function createLogDashboard(opts = {}) {
822
1076
  function getViewportColumns() {
823
1077
  const v = Number.isFinite(columns)
824
1078
  ? columns
825
- : (Number.isFinite(stream?.columns)
826
- ? stream.columns
827
- : (Number.isFinite(process.stdout?.columns) ? process.stdout.columns : FALLBACK_COLUMNS));
1079
+ : Number.isFinite(stream?.columns)
1080
+ ? stream.columns
1081
+ : Number.isFinite(process.stdout?.columns)
1082
+ ? process.stdout.columns
1083
+ : FALLBACK_COLUMNS;
828
1084
  return Math.max(48, v || FALLBACK_COLUMNS);
829
1085
  }
830
1086
 
831
1087
  function getViewportRows() {
832
1088
  const v = Number.isFinite(stream?.rows)
833
1089
  ? stream.rows
834
- : (Number.isFinite(process.stdout?.rows) ? process.stdout.rows : FALLBACK_ROWS);
1090
+ : Number.isFinite(process.stdout?.rows)
1091
+ ? process.stdout.rows
1092
+ : FALLBACK_ROWS;
835
1093
  return Math.max(10, v || FALLBACK_ROWS);
836
1094
  }
837
1095
 
@@ -840,8 +1098,12 @@ export function createLogDashboard(opts = {}) {
840
1098
  }
841
1099
 
842
1100
  function ensureSelectedWorker(names) {
843
- if (names.length === 0) { selectedWorker = null; return; }
844
- if (!selectedWorker || !workers.has(selectedWorker)) selectedWorker = names[0];
1101
+ if (names.length === 0) {
1102
+ selectedWorker = null;
1103
+ return;
1104
+ }
1105
+ if (!selectedWorker || !workers.has(selectedWorker))
1106
+ selectedWorker = names[0];
845
1107
  }
846
1108
 
847
1109
  function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
@@ -885,8 +1147,10 @@ export function createLogDashboard(opts = {}) {
885
1147
  function doClose() {
886
1148
  if (closed) return;
887
1149
  if (timer) clearInterval(timer);
888
- if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
889
- if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
1150
+ if (inputAttached && typeof input?.off === "function")
1151
+ input.off("data", handleInput);
1152
+ if (rawModeEnabled && typeof input?.setRawMode === "function")
1153
+ input.setRawMode(false);
890
1154
  if (inputAttached && typeof input?.pause === "function") input.pause();
891
1155
  exitAltScreen();
892
1156
  closed = true;
@@ -923,33 +1187,84 @@ export function createLogDashboard(opts = {}) {
923
1187
  }
924
1188
 
925
1189
  // Shift+Arrow: 포커스 이동 + 워커 선택
926
- if (key === "\x1b[1;2A") { selectRelative(-1); return; } // Shift+Up → 워커 위
927
- if (key === "\x1b[1;2B") { selectRelative(1); return; } // Shift+Down → 워커 아래
928
- if (key === "\x1b[1;2D") { focus = "rail"; render(); return; } // Shift+Left → rail
929
- if (key === "\x1b[1;2C") { focus = "detail"; render(); return; } // Shift+Rightdetail
1190
+ if (key === "\x1b[1;2A") {
1191
+ selectRelative(-1);
1192
+ return;
1193
+ } // Shift+Up워커 위
1194
+ if (key === "\x1b[1;2B") {
1195
+ selectRelative(1);
1196
+ return;
1197
+ } // Shift+Down → 워커 아래
1198
+ if (key === "\x1b[1;2D") {
1199
+ focus = "rail";
1200
+ render();
1201
+ return;
1202
+ } // Shift+Left → rail
1203
+ if (key === "\x1b[1;2C") {
1204
+ focus = "detail";
1205
+ render();
1206
+ return;
1207
+ } // Shift+Right → detail
930
1208
 
931
1209
  if (focus === "detail") {
932
1210
  // detail 포커스: j/k/ArrowDown/Up = 스크롤
933
- if (key === "j" || key === "\u001b[B") { scrollDetail(1); return; }
934
- if (key === "k" || key === "\u001b[A") { scrollDetail(-1); return; }
1211
+ if (key === "j" || key === "\u001b[B") {
1212
+ scrollDetail(1);
1213
+ return;
1214
+ }
1215
+ if (key === "k" || key === "\u001b[A") {
1216
+ scrollDetail(-1);
1217
+ return;
1218
+ }
935
1219
  } else {
936
1220
  // rail 포커스: j/k = 워커 선택
937
- if (key === "j" || key === "\u001b[B") { selectRelative(1); return; }
938
- if (key === "k" || key === "\u001b[A") { selectRelative(-1); return; }
1221
+ if (key === "j" || key === "\u001b[B") {
1222
+ selectRelative(1);
1223
+ return;
1224
+ }
1225
+ if (key === "k" || key === "\u001b[A") {
1226
+ selectRelative(-1);
1227
+ return;
1228
+ }
939
1229
  }
940
1230
 
941
1231
  // g: focus pane 상단 점프
942
- if (key === "g") { followTail = false; detailScrollOffset = 0; render(); return; }
1232
+ if (key === "g") {
1233
+ followTail = false;
1234
+ detailScrollOffset = 0;
1235
+ render();
1236
+ return;
1237
+ }
943
1238
  // G: focus pane 하단 점프
944
- if (key === "G") { followTail = true; detailScrollOffset = 0; render(); return; }
1239
+ if (key === "G") {
1240
+ followTail = true;
1241
+ detailScrollOffset = 0;
1242
+ render();
1243
+ return;
1244
+ }
945
1245
  // PgUp/PgDn: 페이지 단위 스크롤
946
1246
  const pageSize = Math.max(1, Math.floor(getViewportRows() / 2));
947
- if (key === "\x1b[5~") { scrollDetail(-pageSize); return; } // PgUp
948
- if (key === "\x1b[6~") { scrollDetail(pageSize); return; } // PgDn
1247
+ if (key === "\x1b[5~") {
1248
+ scrollDetail(-pageSize);
1249
+ return;
1250
+ } // PgUp
1251
+ if (key === "\x1b[6~") {
1252
+ scrollDetail(pageSize);
1253
+ return;
1254
+ } // PgDn
949
1255
  // f: follow-tail 토글
950
- if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
1256
+ if (key === "f") {
1257
+ followTail = !followTail;
1258
+ if (followTail) detailScrollOffset = 0;
1259
+ render();
1260
+ return;
1261
+ }
951
1262
  // r: raw mode 토글
952
- if (key === "r") { rawMode = !rawMode; render(); return; }
1263
+ if (key === "r") {
1264
+ rawMode = !rawMode;
1265
+ render();
1266
+ return;
1267
+ }
953
1268
  // l: 탭 전환 (Log → Detail → Files)
954
1269
  if (key === "l") {
955
1270
  const tabs = ["log", "detail", "files"];
@@ -959,16 +1274,29 @@ export function createLogDashboard(opts = {}) {
959
1274
  return;
960
1275
  }
961
1276
  // n: 가장 최근 상태 변경 워커로 이동
962
- if (key === "n") { selectMostRecentChangedWorker(); return; }
1277
+ if (key === "n") {
1278
+ selectMostRecentChangedWorker();
1279
+ return;
1280
+ }
963
1281
  // h/?: 도움말 오버레이 토글
964
- if (key === "h" || key === "?") { helpOverlay = true; render(); return; }
1282
+ if (key === "h" || key === "?") {
1283
+ helpOverlay = true;
1284
+ render();
1285
+ return;
1286
+ }
965
1287
  // q: 대시보드 종료
966
- if (key === "q") { doClose(); return; }
1288
+ if (key === "q") {
1289
+ doClose();
1290
+ return;
1291
+ }
967
1292
  // 1-9: 워커 직접 선택
968
1293
  if (/^[1-9]$/.test(key)) {
969
1294
  const names = visibleWorkerNames();
970
1295
  const target = names[Number.parseInt(key, 10) - 1];
971
- if (target) { setSelectedWorker(target); render(); }
1296
+ if (target) {
1297
+ setSelectedWorker(target);
1298
+ render();
1299
+ }
972
1300
  return;
973
1301
  }
974
1302
  }
@@ -977,7 +1305,8 @@ export function createLogDashboard(opts = {}) {
977
1305
  function attachToSession(worker) {
978
1306
  const execFileFn = opts.deps?.execFile || _execFile;
979
1307
  // 1. rawMode 해제 + input 일시정지 (키 이벤트 차단)
980
- if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
1308
+ if (rawModeEnabled && typeof input?.setRawMode === "function")
1309
+ input.setRawMode(false);
981
1310
  if (typeof input?.pause === "function") input.pause();
982
1311
  // 2. altScreen 퇴장
983
1312
  exitAltScreen();
@@ -988,21 +1317,52 @@ export function createLogDashboard(opts = {}) {
988
1317
  const host = worker.host || "unknown";
989
1318
  const ip = worker._sshIp || host;
990
1319
  const title = `${host}:${worker.role || sessionName}`;
991
- execFileFn("wt.exe", ["-w", "0", "nt", "--title", title, "--",
992
- "ssh", `${worker.sshUser}@${ip}`, "-t", `psmux attach -t ${sessionName}`],
993
- { detached: true, stdio: "ignore", windowsHide: false }, () => {});
1320
+ execFileFn(
1321
+ "wt.exe",
1322
+ [
1323
+ "-w",
1324
+ "0",
1325
+ "nt",
1326
+ "--title",
1327
+ title,
1328
+ "--",
1329
+ "ssh",
1330
+ `${worker.sshUser}@${ip}`,
1331
+ "-t",
1332
+ `psmux attach -t ${sessionName}`,
1333
+ ],
1334
+ { detached: true, stdio: "ignore", windowsHide: false },
1335
+ () => {},
1336
+ );
994
1337
  } else {
995
1338
  // 로컬: psmux attach in new WT tab
996
1339
  const title = worker.role || sessionName;
997
- execFileFn("wt.exe", ["-w", "0", "nt", "--title", title, "--",
998
- "psmux", "attach", "-t", sessionName],
999
- { detached: true, stdio: "ignore", windowsHide: false }, () => {});
1340
+ execFileFn(
1341
+ "wt.exe",
1342
+ [
1343
+ "-w",
1344
+ "0",
1345
+ "nt",
1346
+ "--title",
1347
+ title,
1348
+ "--",
1349
+ "psmux",
1350
+ "attach",
1351
+ "-t",
1352
+ sessionName,
1353
+ ],
1354
+ { detached: true, stdio: "ignore", windowsHide: false },
1355
+ () => {},
1356
+ );
1000
1357
  }
1001
1358
 
1002
1359
  // 3. 200ms 후 altScreen 복귀 + rawMode 재활성화
1003
1360
  setTimeout(() => {
1004
1361
  enterAltScreen();
1005
- if (typeof input?.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1362
+ if (typeof input?.setRawMode === "function") {
1363
+ input.setRawMode(true);
1364
+ rawModeEnabled = true;
1365
+ }
1006
1366
  if (typeof input?.resume === "function") input.resume();
1007
1367
  render();
1008
1368
  }, 200);
@@ -1014,15 +1374,26 @@ export function createLogDashboard(opts = {}) {
1014
1374
  function showFlash(msg, durationMs = 5000) {
1015
1375
  flashMessage = msg;
1016
1376
  if (flashTimer) clearTimeout(flashTimer);
1017
- flashTimer = setTimeout(() => { flashMessage = ""; render(); }, durationMs);
1377
+ flashTimer = setTimeout(() => {
1378
+ flashMessage = "";
1379
+ render();
1380
+ }, durationMs);
1018
1381
  render();
1019
1382
  }
1020
1383
 
1021
1384
  function attachInput() {
1022
1385
  if (inputAttached) return;
1023
- if (!isTTY || (!forceTTY && !input?.isTTY) || typeof input?.on !== "function") return;
1386
+ if (
1387
+ !isTTY ||
1388
+ (!forceTTY && !input?.isTTY) ||
1389
+ typeof input?.on !== "function"
1390
+ )
1391
+ return;
1024
1392
  inputAttached = true;
1025
- if (typeof input.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1393
+ if (typeof input.setRawMode === "function") {
1394
+ input.setRawMode(true);
1395
+ rawModeEnabled = true;
1396
+ }
1026
1397
  if (typeof input.resume === "function") input.resume();
1027
1398
  input.on("data", handleInput);
1028
1399
  }
@@ -1058,10 +1429,20 @@ export function createLogDashboard(opts = {}) {
1058
1429
  const renderTime = Date.now();
1059
1430
 
1060
1431
  // Tier1: 상단 고정 2행
1061
- const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION, renderTime);
1432
+ const tier1 = buildTier1(
1433
+ names,
1434
+ workers,
1435
+ pipeline,
1436
+ elapsed,
1437
+ totalCols,
1438
+ VERSION,
1439
+ renderTime,
1440
+ );
1062
1441
  // flash 메시지 (완료/실패 알림)
1063
1442
  if (flashMessage) {
1064
- tier1.push(truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols));
1443
+ tier1.push(
1444
+ truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols),
1445
+ );
1065
1446
  }
1066
1447
 
1067
1448
  // 레이아웃 결정
@@ -1075,9 +1456,19 @@ export function createLogDashboard(opts = {}) {
1075
1456
 
1076
1457
  // summary+detail: summaryBar + focus pane
1077
1458
  if (effectiveLayout === "summary+detail") {
1078
- const summaryBar = buildSummaryBar(names, workers, selectedWorker, pipeline, totalCols, VERSION);
1459
+ const summaryBar = buildSummaryBar(
1460
+ names,
1461
+ workers,
1462
+ selectedWorker,
1463
+ pipeline,
1464
+ totalCols,
1465
+ VERSION,
1466
+ );
1079
1467
  const selectedState = workers.get(selectedWorker);
1080
- const focusPaneHeight = Math.max(8, totalRows - tier1.length - summaryBar.length);
1468
+ const focusPaneHeight = Math.max(
1469
+ 8,
1470
+ totalRows - tier1.length - summaryBar.length,
1471
+ );
1081
1472
  const focusPane = buildFocusPane(selectedWorker, selectedState, {
1082
1473
  width: totalCols,
1083
1474
  height: focusPaneHeight,
@@ -1094,8 +1485,11 @@ export function createLogDashboard(opts = {}) {
1094
1485
  // 좌우 분할: Left Rail (30%) | Right Focus (70%)
1095
1486
  // 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
1096
1487
  const GAP = 1; // rail과 focus 사이 구분선
1097
- const railRatio = focus === "detail" ? 0.20 : 0.30;
1098
- const railWidth = Math.max(MIN_CARD_WIDTH, Math.floor(totalCols * railRatio));
1488
+ const railRatio = focus === "detail" ? 0.2 : 0.3;
1489
+ const railWidth = Math.max(
1490
+ MIN_CARD_WIDTH,
1491
+ Math.floor(totalCols * railRatio),
1492
+ );
1099
1493
  const focusWidth = totalCols - railWidth - GAP;
1100
1494
  const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
1101
1495
 
@@ -1118,7 +1512,8 @@ export function createLogDashboard(opts = {}) {
1118
1512
  railLines.push(...card);
1119
1513
  }
1120
1514
  // rail 높이를 bodyHeight에 맞춤 (부족하면 빈 줄, 넘치면 자름)
1121
- while (railLines.length < bodyHeight) railLines.push(padRight("", railWidth));
1515
+ while (railLines.length < bodyHeight)
1516
+ railLines.push(padRight("", railWidth));
1122
1517
  if (railLines.length > bodyHeight) railLines.length = bodyHeight;
1123
1518
 
1124
1519
  // Right Focus: 선택된 워커 상세
@@ -1135,7 +1530,8 @@ export function createLogDashboard(opts = {}) {
1135
1530
  time: renderTime,
1136
1531
  });
1137
1532
  }
1138
- while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
1533
+ while (focusLines.length < bodyHeight)
1534
+ focusLines.push(padRight("", focusWidth));
1139
1535
  if (focusLines.length > bodyHeight) focusLines.length = bodyHeight;
1140
1536
 
1141
1537
  // 좌우 합성: rail[i] + separator + focus[i]
@@ -1219,21 +1615,35 @@ export function createLogDashboard(opts = {}) {
1219
1615
  // ── 공개 API ─────────────────────────────────────────────────────────
1220
1616
  return {
1221
1617
  updateWorker(paneName, state) {
1222
- const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
1618
+ const existing = workers.get(paneName) || {
1619
+ cli: "codex",
1620
+ status: "pending",
1621
+ };
1223
1622
  const merged = normalizeWorkerState(existing, state);
1224
1623
  const nextSig = JSON.stringify({
1225
- cli: merged.cli, status: merged.status, role: merged.role,
1226
- snapshot: merged.snapshot, summary: merged.summary, detail: merged.detail,
1227
- findings: merged.findings, files_changed: merged.files_changed,
1228
- confidence: merged.confidence, tokens: merged.tokens,
1229
- progress: merged.progress, handoff: merged.handoff,
1624
+ cli: merged.cli,
1625
+ status: merged.status,
1626
+ role: merged.role,
1627
+ snapshot: merged.snapshot,
1628
+ summary: merged.summary,
1629
+ detail: merged.detail,
1630
+ findings: merged.findings,
1631
+ files_changed: merged.files_changed,
1632
+ confidence: merged.confidence,
1633
+ tokens: merged.tokens,
1634
+ progress: merged.progress,
1635
+ handoff: merged.handoff,
1230
1636
  });
1231
1637
  const sigChanged = nextSig !== existing._sig;
1232
- const explicitElapsed = Number.isFinite(state.elapsed) ? Math.max(0, Math.round(state.elapsed)) : null;
1638
+ const explicitElapsed = Number.isFinite(state.elapsed)
1639
+ ? Math.max(0, Math.round(state.elapsed))
1640
+ : null;
1233
1641
  merged._sig = nextSig;
1234
1642
  merged._logSec = sigChanged
1235
1643
  ? (explicitElapsed ?? nowElapsedSec())
1236
- : (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
1644
+ : Number.isFinite(existing._logSec)
1645
+ ? existing._logSec
1646
+ : (explicitElapsed ?? nowElapsedSec());
1237
1647
  workers.set(paneName, merged);
1238
1648
  ensureSelectedWorker(visibleWorkerNames());
1239
1649
  // follow-tail: 새 데이터 → 자동 scroll 재계산
@@ -1287,7 +1697,10 @@ export function createLogDashboard(opts = {}) {
1287
1697
 
1288
1698
  setFocusTab(tab) {
1289
1699
  const valid = ["log", "detail", "files"];
1290
- if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
1700
+ if (valid.includes(tab)) {
1701
+ focusTab = tab;
1702
+ detailScrollOffset = 0;
1703
+ }
1291
1704
  },
1292
1705
 
1293
1706
  getLayout() {
@@ -1331,27 +1744,30 @@ export function createLogDashboard(opts = {}) {
1331
1744
  // failed=red, dead/init/starting=dim
1332
1745
 
1333
1746
  const CONDUCTOR_STATE_LABEL = {
1334
- init: { label: 'INIT', seq: MOCHA.subtext },
1335
- starting: { label: 'START', seq: MOCHA.executing },
1336
- healthy: { label: 'OK', seq: MOCHA.ok },
1337
- stalled: { label: 'STALL', seq: MOCHA.yellow },
1338
- input_wait: { label: 'INPUT_WAIT', seq: FG.cyan },
1339
- failed: { label: 'FAIL', seq: MOCHA.fail },
1340
- restarting: { label: 'RESTART', seq: MOCHA.partial },
1341
- dead: { label: 'DEAD', seq: FG.gray },
1342
- completed: { label: 'DONE', seq: MOCHA.ok },
1747
+ init: { label: "INIT", seq: MOCHA.subtext },
1748
+ starting: { label: "START", seq: MOCHA.executing },
1749
+ healthy: { label: "OK", seq: MOCHA.ok },
1750
+ stalled: { label: "STALL", seq: MOCHA.yellow },
1751
+ input_wait: { label: "INPUT_WAIT", seq: FG.cyan },
1752
+ failed: { label: "FAIL", seq: MOCHA.fail },
1753
+ restarting: { label: "RESTART", seq: MOCHA.partial },
1754
+ dead: { label: "DEAD", seq: FG.gray },
1755
+ completed: { label: "DONE", seq: MOCHA.ok },
1343
1756
  };
1344
1757
 
1345
1758
  function conductorHealthCell(state) {
1346
- const entry = CONDUCTOR_STATE_LABEL[state] || { label: state.toUpperCase(), seq: FG.gray };
1759
+ const entry = CONDUCTOR_STATE_LABEL[state] || {
1760
+ label: state.toUpperCase(),
1761
+ seq: FG.gray,
1762
+ };
1347
1763
  return `${entry.seq}■ ${entry.label}${RESET}`;
1348
1764
  }
1349
1765
 
1350
1766
  function conductorRelTime(ms) {
1351
- if (!ms) return '';
1767
+ if (!ms) return "";
1352
1768
  const sec = Math.round((Date.now() - ms) / 1000);
1353
- if (sec < 0) return '';
1354
- if (sec < 60) return `${sec}s ago`;
1769
+ if (sec < 0) return "";
1770
+ if (sec < 60) return `${sec}s ago`;
1355
1771
  if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
1356
1772
  return `${Math.floor(sec / 3600)}h ago`;
1357
1773
  }
@@ -1369,31 +1785,32 @@ export function renderConductorTier(snapshot, cols = 100) {
1369
1785
 
1370
1786
  // ── 열 너비 계산 ────────────────────────────────────────
1371
1787
  // ID(8) Agent(7) Host(6) Health(dyn) LastOut(dyn) Restarts(8) Why(rest)
1372
- const COL_ID = 8;
1373
- const COL_AGENT = 7;
1374
- const COL_HOST = 6;
1788
+ const COL_ID = 8;
1789
+ const COL_AGENT = 7;
1790
+ const COL_HOST = 6;
1375
1791
  const COL_RESTARTS = 4;
1376
- const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1377
- const COL_LASTOUT = 9; // '999m ago' = 8 + space
1792
+ const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1793
+ const COL_LASTOUT = 9; // '999m ago' = 8 + space
1378
1794
  // Why gets the remainder
1379
- const fixedCols = COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
1795
+ const fixedCols =
1796
+ COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
1380
1797
  const COL_WHY = Math.max(4, inner - fixedCols);
1381
1798
 
1382
1799
  function cell(text, width_) {
1383
- return clip(String(text ?? ''), width_);
1800
+ return clip(String(text ?? ""), width_);
1384
1801
  }
1385
1802
 
1386
1803
  function buildRow(id, agent, host, healthCell, lastOut, restarts, why) {
1387
- const idC = cell(id, COL_ID);
1388
- const agentC = cell(agent, COL_AGENT);
1389
- const hostC = cell(host, COL_HOST);
1804
+ const idC = cell(id, COL_ID);
1805
+ const agentC = cell(agent, COL_AGENT);
1806
+ const hostC = cell(host, COL_HOST);
1390
1807
  const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
1391
- const lastOutC = clip(lastOut, COL_LASTOUT);
1392
- const whyC = cell(why, COL_WHY);
1808
+ const lastOutC = clip(lastOut, COL_LASTOUT);
1809
+ const whyC = cell(why, COL_WHY);
1393
1810
  // healthCell already has ANSI codes; pad its visible width manually
1394
1811
  const healthVis = wcswidth(stripAnsi(healthCell));
1395
1812
  const healthPad = Math.max(0, COL_HEALTH - healthVis);
1396
- const healthC = healthCell + ' '.repeat(healthPad);
1813
+ const healthC = healthCell + " ".repeat(healthPad);
1397
1814
 
1398
1815
  return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
1399
1816
  }
@@ -1408,40 +1825,60 @@ export function renderConductorTier(snapshot, cols = 100) {
1408
1825
  const dashLeft = 1;
1409
1826
  const dashRight = Math.max(0, dashLen - dashLeft);
1410
1827
  const borderSeq = MOCHA.border;
1411
- const topBorder =
1412
- `${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
1828
+ const topBorder = `${borderSeq}┌${"─".repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${"─".repeat(dashRight)}┐${RESET}`;
1413
1829
 
1414
1830
  // ── ヘッダー行 ───────────────────────────────────────────
1415
- const headerRow = buildRow('ID', 'Agent', 'Host', clip('Health', COL_HEALTH), 'Last Out', 'Rst', 'Why');
1831
+ const headerRow = buildRow(
1832
+ "ID",
1833
+ "Agent",
1834
+ "Host",
1835
+ clip("Health", COL_HEALTH),
1836
+ "Last Out",
1837
+ "Rst",
1838
+ "Why",
1839
+ );
1416
1840
  const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
1417
1841
 
1418
1842
  // ── データ行 ────────────────────────────────────────────
1419
1843
  const dataLines = [];
1420
1844
  if (!snapshot || snapshot.length === 0) {
1421
- const emptyMsg = color('(no sessions)', FG.muted);
1422
- const _emptyPad = clip(stripAnsi(emptyMsg) === '(no sessions)' ? emptyMsg : emptyMsg, inner);
1423
- dataLines.push(`${borderSeq}│${RESET} ${padRight(emptyMsg, inner - 2)} ${borderSeq}│${RESET}`);
1845
+ const emptyMsg = color("(no sessions)", FG.muted);
1846
+ const _emptyPad = clip(
1847
+ stripAnsi(emptyMsg) === "(no sessions)" ? emptyMsg : emptyMsg,
1848
+ inner,
1849
+ );
1850
+ dataLines.push(
1851
+ `${borderSeq}│${RESET} ${padRight(emptyMsg, inner - 2)} ${borderSeq}│${RESET}`,
1852
+ );
1424
1853
  } else {
1425
1854
  for (const s of snapshot) {
1426
- const id = String(s.id ?? '').slice(0, COL_ID);
1427
- const agent = String(s.agent ?? 'unknown').slice(0, COL_AGENT);
1428
- const host = 'local';
1429
- const state = s.state ?? 'init';
1855
+ const id = String(s.id ?? "").slice(0, COL_ID);
1856
+ const agent = String(s.agent ?? "unknown").slice(0, COL_AGENT);
1857
+ const host = "local";
1858
+ const state = s.state ?? "init";
1430
1859
  const healthCell = conductorHealthCell(state);
1431
- const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1860
+ const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1432
1861
  const restarts = s.restarts ?? 0;
1433
1862
  // derive "why" from last state transition context
1434
1863
  const why = s.health?.inputWaitPattern
1435
1864
  ? String(s.health.inputWaitPattern).slice(0, COL_WHY)
1436
- : '';
1437
-
1438
- const rowText = buildRow(id, agent, host, healthCell, lastOut, restarts, why);
1865
+ : "";
1866
+
1867
+ const rowText = buildRow(
1868
+ id,
1869
+ agent,
1870
+ host,
1871
+ healthCell,
1872
+ lastOut,
1873
+ restarts,
1874
+ why,
1875
+ );
1439
1876
  dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
1440
1877
  }
1441
1878
  }
1442
1879
 
1443
1880
  // ── Bottom border ─────────────────────────────────────
1444
- const botBorder = `${borderSeq}└${''.repeat(boxWidth)}┘${RESET}`;
1881
+ const botBorder = `${borderSeq}└${"".repeat(boxWidth)}┘${RESET}`;
1445
1882
 
1446
1883
  return [topBorder, headerLine, ...dataLines, botBorder];
1447
1884
  }