triflux 8.6.0 → 8.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.
package/hub/team/ansi.mjs CHANGED
@@ -65,16 +65,92 @@ export function color(text, fg, bg) {
65
65
  export function bold(text) { return `${BOLD}${text}${RESET}`; }
66
66
  export function dim(text) { return `${DIM}${text}${RESET}`; }
67
67
 
68
+ export function lerpRgb(a, b, t) {
69
+ return {
70
+ r: Math.round(a.r + (b.r - a.r) * t),
71
+ g: Math.round(a.g + (b.g - a.g) * t),
72
+ b: Math.round(a.b + (b.b - a.b) * t),
73
+ };
74
+ }
75
+
76
+ function rgbSeq(rgb, mode = 38) {
77
+ return `${ESC}[${mode};2;${rgb.r};${rgb.g};${rgb.b}m`;
78
+ }
79
+
80
+ function brightenRgb(rgb, amount = 0.3) {
81
+ return lerpRgb(rgb, { r: 255, g: 255, b: 255 }, amount);
82
+ }
83
+
84
+ function parseRgbSeq(seq) {
85
+ const match = typeof seq === "string"
86
+ ? seq.match(/\x1b\[(?:38|48);2;(\d+);(\d+);(\d+)m/)
87
+ : null;
88
+ if (!match) return null;
89
+ return {
90
+ r: Number.parseInt(match[1], 10),
91
+ g: Number.parseInt(match[2], 10),
92
+ b: Number.parseInt(match[3], 10),
93
+ };
94
+ }
95
+
96
+ function reapplyBackground(text, bgSeq) {
97
+ if (!bgSeq) return text;
98
+ return `${bgSeq}${String(text).replaceAll(RESET, `${RESET}${bgSeq}`)}${RESET}`;
99
+ }
100
+
68
101
  // ── 박스 그리기 (유니코드 테두리) ──
69
102
  const BOX = { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│", ml: "├", mr: "┤" };
70
103
 
71
- export function box(lines, width, borderColor = "") {
72
- const bc = borderColor;
73
- const rst = bc ? RESET : "";
74
- const top = `${bc}${BOX.tl}${BOX.h.repeat(width - 2)}${BOX.tr}${rst}`;
75
- const bot = `${bc}${BOX.bl}${BOX.h.repeat(width - 2)}${BOX.br}${rst}`;
76
- const mid = `${bc}${BOX.ml}${BOX.h.repeat(width - 2)}${BOX.mr}${rst}`;
77
- const body = lines.map((l) => `${bc}${BOX.v}${rst} ${padRight(l, width - 4)} ${bc}${BOX.v}${rst}`);
104
+ function borderHighlightCell(width, totalRows, highlightPos) {
105
+ if (!Number.isFinite(highlightPos)) return null;
106
+ const perimeter = 2 * (width - 2) + 2 * totalRows;
107
+ if (perimeter <= 0) return null;
108
+ let pos = Math.floor(highlightPos) % perimeter;
109
+ if (pos < 0) pos += perimeter;
110
+
111
+ if (pos < width - 2) return { row: 0, col: pos + 1 };
112
+ pos -= width - 2;
113
+ if (pos < totalRows) return { row: pos, col: width - 1 };
114
+ pos -= totalRows;
115
+ if (pos < width - 2) return { row: totalRows - 1, col: width - 2 - pos };
116
+ pos -= width - 2;
117
+ return { row: totalRows - 1 - pos, col: 0 };
118
+ }
119
+
120
+ function renderBorderChar(glyph, row, col, highlightCell, borderSeq, highlightSeq) {
121
+ if (highlightCell && highlightCell.row === row && highlightCell.col === col) {
122
+ return `${highlightSeq}${glyph}${RESET}`;
123
+ }
124
+ return borderSeq ? `${borderSeq}${glyph}${RESET}` : glyph;
125
+ }
126
+
127
+ export function box(lines, width, borderColor = "", options = {}) {
128
+ const isFn = typeof borderColor === "function";
129
+ const totalRows = lines.length + 2;
130
+ const bc = isFn ? (row) => borderColor(row, totalRows) : () => borderColor;
131
+ const rst = (isFn || borderColor) ? RESET : "";
132
+ const highlightCell = borderHighlightCell(width, totalRows, options.highlightPos);
133
+ const highlightSeq = options.highlightColor
134
+ || (() => {
135
+ const parsed = parseRgbSeq(typeof borderColor === "string" ? borderColor : "");
136
+ return parsed ? rgbSeq(brightenRgb(parsed, 0.45)) : `${BOLD}${FG.white}`;
137
+ })();
138
+ const topChars = [BOX.tl, ...Array.from({ length: width - 2 }, () => BOX.h), BOX.tr];
139
+ const botChars = [BOX.bl, ...Array.from({ length: width - 2 }, () => BOX.h), BOX.br];
140
+ const top = topChars
141
+ .map((glyph, col) => renderBorderChar(glyph, 0, col, highlightCell, bc(0), highlightSeq))
142
+ .join("");
143
+ const bot = botChars
144
+ .map((glyph, col) => renderBorderChar(glyph, totalRows - 1, col, highlightCell, bc(totalRows - 1), highlightSeq))
145
+ .join("");
146
+ const mid = `${bc(Math.floor(totalRows / 2))}${BOX.ml}${BOX.h.repeat(width - 2)}${BOX.mr}${rst}`;
147
+ const body = lines.map((l, i) => {
148
+ const row = i + 1;
149
+ const content = options.titleFlashBg && i === 0
150
+ ? reapplyBackground(padRight(l, width - 4), options.titleFlashBg)
151
+ : padRight(l, width - 4);
152
+ return `${renderBorderChar(BOX.v, row, 0, highlightCell, bc(row), highlightSeq)} ${content} ${renderBorderChar(BOX.v, row, width - 1, highlightCell, bc(row), highlightSeq)}`;
153
+ });
78
154
  return { top, body, bot, mid };
79
155
  }
80
156
 
@@ -205,6 +281,12 @@ export const MOCHA = {
205
281
  surface0: `${ESC}[38;2;49;50;68m`, // #313244 surface0
206
282
  };
207
283
 
284
+ const MOCHA_RGB = {
285
+ ok: { r: 166, g: 227, b: 161 },
286
+ partial: { r: 250, g: 179, b: 135 },
287
+ fail: { r: 243, g: 139, b: 168 },
288
+ };
289
+
208
290
  // ── badge 헬퍼 ──
209
291
  // statusBadge(status) → ANSI 색상 문자열
210
292
  export function statusBadge(status) {
@@ -231,13 +313,48 @@ export function statusBadge(status) {
231
313
  }
232
314
 
233
315
  // ── 진행률 바 ──
234
- // progressBar(percent, width) — percent: 0~100, ANSI colored bar string 반환
235
- export function progressBar(percent, width = 20) {
316
+ // progressBar(percent, width, time) — percent: 0~100, time 전달 shimmer sweep
317
+ export function progressBar(percent, width = 20, time) {
318
+ const ratio = Math.max(0, Math.min(100, percent)) / 100;
319
+ const filled = Math.round(ratio * width);
320
+ const empty = width - filled;
321
+ const fillRgb = percent >= 100 ? MOCHA_RGB.ok : percent >= 50 ? MOCHA_RGB.partial : MOCHA_RGB.fail;
322
+ const fillColor = rgbSeq(fillRgb);
323
+ let fillText = "█".repeat(filled);
324
+
325
+ if (filled > 0 && Number.isFinite(time)) {
326
+ const shinePos = Math.min(
327
+ filled - 1,
328
+ Math.floor(((((time % 2000) + 2000) % 2000) / 2000) * filled),
329
+ );
330
+ const shineColor = rgbSeq(brightenRgb(fillRgb, 0.3));
331
+ fillText = Array.from({ length: filled }, (_, idx) =>
332
+ idx === shinePos ? `${shineColor}█${RESET}` : `${fillColor}█${RESET}`
333
+ ).join("");
334
+ } else if (filled > 0) {
335
+ fillText = `${fillColor}${fillText}${RESET}`;
336
+ }
337
+
338
+ const emptyText = empty > 0 ? `${MOCHA.border}${"░".repeat(empty)}${RESET}` : "";
339
+ return `${fillText}${emptyText}`;
340
+ }
341
+
342
+ // ── 애니메이션 진행률 바 (shimmer sweep) ──
343
+ export function animatedProgressBar(percent, width = 20, tick = 0) {
236
344
  const ratio = Math.max(0, Math.min(100, percent)) / 100;
237
345
  const filled = Math.round(ratio * width);
238
346
  const empty = width - filled;
239
- const fillColor = percent >= 100 ? MOCHA.ok : percent >= 50 ? MOCHA.partial : MOCHA.fail;
240
- return `${fillColor}${"█".repeat(filled)}${MOCHA.border}${"░".repeat(empty)}${RESET}`;
347
+ if (filled === 0 || percent >= 100) return progressBar(percent, width);
348
+ const baseClr = percent >= 50 ? MOCHA.partial : MOCHA.fail;
349
+ const pos = tick % (filled + 3);
350
+ let bar = "";
351
+ for (let i = 0; i < filled; i++) {
352
+ const d = Math.abs(i - pos);
353
+ if (d === 0) bar += `${ESC}[97m█`;
354
+ else if (d === 1) bar += `${baseClr}▓`;
355
+ else bar += `${baseClr}█`;
356
+ }
357
+ return `${bar}${MOCHA.border}${"░".repeat(empty)}${RESET}`;
241
358
  }
242
359
 
243
360
  // ── 상태 아이콘 ──
@@ -253,3 +370,9 @@ export const CLI_ICON = {
253
370
  gemini: `${FG.gemini}🔵${RESET}`,
254
371
  claude: `${FG.claude}🟠${RESET}`,
255
372
  };
373
+
374
+ // ── 로딩 도트 (braille spinner) ──
375
+ const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
376
+ export function loadingDots(tick = 0, clr = MOCHA.thinking) {
377
+ return `${clr}${BRAILLE_FRAMES[tick % BRAILLE_FRAMES.length]}${RESET}`;
378
+ }
package/hub/team/tui.mjs CHANGED
@@ -61,24 +61,59 @@ function lerpRgb(a, b, t) {
61
61
  };
62
62
  }
63
63
 
64
- function heartbeat(status, shimmerIntensity = 0) {
64
+ function rgbSeq(rgb, mode = 38) {
65
+ return `\x1b[${mode};2;${rgb.r};${rgb.g};${rgb.b}m`;
66
+ }
67
+
68
+ function pseudoRandomFrame(step, seed) {
69
+ return Math.abs(Math.imul(step + seed, 2654435761)) % SPINNER_FRAMES.length;
70
+ }
71
+
72
+ function heartbeat(status, shimmerIntensity = 0, statusChangedAt = 0, time = Date.now()) {
73
+ const transitionElapsed = statusChangedAt ? Math.max(0, time - statusChangedAt) : Number.POSITIVE_INFINITY;
74
+ if (transitionElapsed < 500) {
75
+ const step = Math.floor(transitionElapsed / 50);
76
+ const idx = pseudoRandomFrame(step, statusChangedAt % 997);
77
+ const targetColor = status === "failed" || status === "error"
78
+ ? MOCHA.fail
79
+ : status === "done" || status === "completed"
80
+ ? MOCHA.ok
81
+ : shimmerIntensity > 0
82
+ ? rgbSeq(lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity))
83
+ : MOCHA.executing;
84
+ return `${targetColor}${SPINNER_FRAMES[idx]}${RESET}`;
85
+ }
86
+
65
87
  if (status === "done" || status === "completed") return color("✓", MOCHA.ok);
66
88
  if (status === "failed" || status === "error") return color("✗", MOCHA.fail);
67
89
  if (status !== "running") return dim("○");
68
- const elapsed = Date.now() - spinnerStart;
90
+ const elapsed = time - spinnerStart;
69
91
  const idx = Math.floor((elapsed / SPINNER_CYCLE_MS) * SPINNER_FRAMES.length) % SPINNER_FRAMES.length;
70
92
  const c = shimmerIntensity > 0
71
93
  ? lerpRgb(SPINNER_BASE_COLOR, SPINNER_SHIMMER, shimmerIntensity)
72
94
  : SPINNER_BASE_COLOR;
73
- return `\x1b[38;2;${c.r};${c.g};${c.b}m${SPINNER_FRAMES[idx]}${RESET}`;
95
+ return `${rgbSeq(c)}${SPINNER_FRAMES[idx]}${RESET}`;
74
96
  }
75
97
 
76
- function currentShimmer() {
77
- const elapsed = Date.now() - spinnerStart;
78
- const t = (elapsed % SPINNER_CYCLE_MS) / SPINNER_CYCLE_MS;
98
+ function currentShimmer(time = Date.now()) {
99
+ const elapsed = time - spinnerStart;
100
+ const quantized = Math.floor(elapsed / 80) * 80;
101
+ const t = (quantized % SPINNER_CYCLE_MS) / SPINNER_CYCLE_MS;
79
102
  return 0.5 * (1 + Math.sin(t * Math.PI * 2));
80
103
  }
81
104
 
105
+ // ── activity wave — Tier1 헤더용 미니 파형 ──
106
+ const WAVE_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
107
+ function activityWave(tick, count = 4) {
108
+ let wave = "";
109
+ for (let i = 0; i < count; i++) {
110
+ const phase = tick * 0.3 + i * 1.5;
111
+ const idx = Math.floor((Math.sin(phase) * 0.5 + 0.5) * (WAVE_CHARS.length - 1));
112
+ wave += WAVE_CHARS[idx];
113
+ }
114
+ return `${MOCHA.executing}${wave}${RESET}`;
115
+ }
116
+
82
117
  const GRID_GAP = 2;
83
118
  const DEFAULT_DETAIL_LINES = 10;
84
119
  // Tier1 상단 고정 행 수
@@ -200,6 +235,13 @@ const MOCHA_RGB = {
200
235
  executing: { r: 116, g: 199, b: 236 },
201
236
  muted: { r: 147, g: 153, b: 178 },
202
237
  border: { r: 69, g: 71, b: 90 },
238
+ blue: { r: 137, g: 180, b: 250 },
239
+ sky: { r: 116, g: 199, b: 236 },
240
+ yellow: { r: 249, g: 226, b: 175 },
241
+ peach: { r: 250, g: 179, b: 135 },
242
+ maroon: { r: 235, g: 160, b: 172 },
243
+ surface0: { r: 49, g: 50, b: 68 },
244
+ thinking: { r: 203, g: 166, b: 247 },
203
245
  };
204
246
 
205
247
  function statusToRgb(status) {
@@ -211,17 +253,63 @@ function statusToRgb(status) {
211
253
  }
212
254
 
213
255
  const FADE_DURATION_MS = 1500;
256
+ const FLASH_PHASE_MS = 250;
257
+ const CARD_GLOW_MS = 3000;
258
+
259
+ // Effect 1: Pulse border — running 워커 보더가 heartbeat 동기 breathing
260
+ function pulseBorderColor(statusRgb, time = Date.now()) {
261
+ const intensity = 0.3 + 0.7 * currentShimmer(time);
262
+ const c = lerpRgb(MOCHA_RGB.border, statusRgb, intensity);
263
+ return rgbSeq(c);
264
+ }
214
265
 
215
- function fadeBorderColor(currentStatus, prevStatus, changedAt) {
266
+ // Effect 2: Gradient border — focus pane 보더 상단→하단 그라데이션
267
+ function gradientBorderFn(topRgb, bottomRgb) {
268
+ return (row, totalRows) => {
269
+ const t = totalRows <= 1 ? 0 : row / (totalRows - 1);
270
+ const c = lerpRgb(topRgb, bottomRgb, t);
271
+ return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
272
+ };
273
+ }
274
+
275
+ // Effect 3: Flash-fade border — 상태 변경 시 백색 플래시 → 페이드아웃
276
+ function flashFadeBorderColor(currentStatus, prevStatus, changedAt) {
216
277
  const elapsed = Date.now() - (changedAt || 0);
217
- if (elapsed >= FADE_DURATION_MS || !prevStatus) return MOCHA.border;
218
- const t = Math.min(1, elapsed / FADE_DURATION_MS);
219
- const from = statusToRgb(currentStatus);
220
- const to = MOCHA_RGB.border;
221
- const c = lerpRgb(from, to, t);
278
+ if (elapsed >= FADE_DURATION_MS || !prevStatus) return null;
279
+ const statusRgb = statusToRgb(currentStatus);
280
+ if (elapsed < FLASH_PHASE_MS) {
281
+ const t = elapsed / FLASH_PHASE_MS;
282
+ const bright = { r: 255, g: 255, b: 255 };
283
+ const c = lerpRgb(bright, statusRgb, t);
284
+ return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
285
+ }
286
+ const t = (elapsed - FLASH_PHASE_MS) / (FADE_DURATION_MS - FLASH_PHASE_MS);
287
+ const c = lerpRgb(statusRgb, MOCHA_RGB.border, t);
222
288
  return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
223
289
  }
224
290
 
291
+ function easeOutCubic(t) {
292
+ return 1 - ((1 - t) ** 3);
293
+ }
294
+
295
+ function borderHighlightPosition(width, bodyLines, time = Date.now()) {
296
+ const totalRows = bodyLines + 2;
297
+ const perimeter = 2 * (width - 2) + 2 * totalRows;
298
+ if (perimeter <= 0) return undefined;
299
+ return Math.floor(time / 120) % perimeter;
300
+ }
301
+
302
+ function titleFlash(status, changeElapsed) {
303
+ const isCompleted = status === "completed" || status === "done" || status === "ok";
304
+ const isFailed = status === "failed" || status === "error" || status === "fail";
305
+ if ((!isCompleted && !isFailed) || changeElapsed > 800) return null;
306
+ const flashRgb = isCompleted ? MOCHA_RGB.ok : MOCHA_RGB.fail;
307
+ const bgRgb = changeElapsed <= 300
308
+ ? flashRgb
309
+ : lerpRgb(flashRgb, MOCHA_RGB.surface0, clamp((changeElapsed - 300) / 500, 0, 1));
310
+ return rgbSeq(bgRgb, 48);
311
+ }
312
+
225
313
  function dedupeRole(role, name, cli) {
226
314
  if (!role) return "";
227
315
  let r = role;
@@ -320,22 +408,28 @@ function countStatuses(names, workers) {
320
408
  }
321
409
 
322
410
  // ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
323
- function phaseColor(phase) {
324
- if (phase === "exec" || phase === "executing") return MOCHA.blue;
325
- if (phase === "verify" || phase === "verifying") return MOCHA.yellow;
326
- if (phase === "fix" || phase === "fixing") return MOCHA.red;
411
+ function phaseColor(phase, time = Date.now()) {
412
+ const shimmer = currentShimmer(time);
413
+ if (phase === "exec" || phase === "executing") return rgbSeq(lerpRgb(MOCHA_RGB.blue, MOCHA_RGB.sky, shimmer));
414
+ if (phase === "verify" || phase === "verifying") return rgbSeq(lerpRgb(MOCHA_RGB.yellow, MOCHA_RGB.peach, shimmer));
415
+ if (phase === "fix" || phase === "fixing") return rgbSeq(lerpRgb(MOCHA_RGB.fail, MOCHA_RGB.maroon, shimmer));
327
416
  return FG.accent;
328
417
  }
329
418
 
330
- function buildTier1(names, workers, pipeline, elapsed, width, version) {
419
+ function buildTier1(names, workers, pipeline, elapsed, width, version, time = Date.now()) {
331
420
  const { ok, partial, failed, running } = countStatuses(names, workers);
332
421
  const phase = pipeline.phase || "exec";
333
422
  const row1 = truncate(
334
- `${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
335
- `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)} ${color("Tab:focus j/k/↑↓:nav f:follow r:raw • l:tab • 1-9:jump", MOCHA.subtext)}`,
423
+ `${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase, time))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
424
+ `${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)}${running > 0 ? ` ${activityWave(spinnerTick)}` : ""}`,
336
425
  width,
337
426
  );
338
- return [row1];
427
+ const keysHint = color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext);
428
+ const hintWidth = wcswidth(stripAnsi(keysHint));
429
+ const row2 = hintWidth >= width
430
+ ? truncate(keysHint, width)
431
+ : padRight(`${" ".repeat(width - hintWidth)}${keysHint}`, width);
432
+ return [row1, row2];
339
433
  }
340
434
 
341
435
  // ── 카드 렌더러 (Tier2 worker rail) ─────────────────────────────────────
@@ -368,33 +462,46 @@ function buildWorkerRail(name, st, opts = {}) {
368
462
  width,
369
463
  selected = false,
370
464
  focused = false, // rail 포커스 여부
465
+ previousSelected = false,
371
466
  rawMode = false,
372
467
  compact = false,
468
+ time = Date.now(),
373
469
  } = opts;
374
470
  const innerWidth = Math.max(12, width - 4);
375
471
  const cli = st.cli || "codex";
376
472
  const role = sanitizeOneLine(st.role);
377
473
  const status = runtimeStatus(st);
378
474
  const sec = Number.isFinite(st._logSec) ? st._logSec : 0;
475
+ const changeElapsed = st._statusChangedAt ? Math.max(0, time - st._statusChangedAt) : Number.POSITIVE_INFINITY;
379
476
 
380
477
  // Tier2 행 1: 이름 + CLI + role
381
- const selMark = selected ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux)) : " ";
382
- const hb = heartbeat(status, status === "running" ? currentShimmer() : 0);
478
+ const selMark = selected
479
+ ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux))
480
+ : previousSelected
481
+ ? dim("~")
482
+ : " ";
483
+ const hb = heartbeat(status, status === "running" ? currentShimmer(time) : 0, st._statusChangedAt, time);
383
484
  const displayRole = dedupeRole(role, name, cli);
384
485
  const title = truncate(
385
486
  `${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
386
487
  innerWidth,
387
488
  );
388
489
 
389
- // status-specific border: focused→mauve, selected→bright, non-selected→dimmed tint
490
+ const cardWidth = Math.max(MIN_CARD_WIDTH, width);
491
+ const borderHighlight = focused ? borderHighlightPosition(cardWidth, compact ? 2 : 6, time) : undefined;
492
+ const titleFlashBg = titleFlash(status, changeElapsed);
493
+
494
+ // status-specific border: focused→mauve, selected→bright, non-selected→glow decay
390
495
  const statusBorderColor = (() => {
391
496
  if (focused) return MOCHA.thinking;
497
+ if (selected && (status === "running" || status === "in_progress")) {
498
+ return pulseBorderColor(statusToRgb(status), time);
499
+ }
392
500
  if (selected) return statusColor(status);
393
- // Non-selected: status-tinted border (50% blend toward border gray)
394
501
  const from = statusToRgb(status);
395
- const to = MOCHA_RGB.border;
396
- const c = lerpRgb(from, to, 0.5);
397
- return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
502
+ const decayBase = st._statusChangedAt ? clamp(changeElapsed / CARD_GLOW_MS, 0, 1) : 1;
503
+ const decayT = easeOutCubic(decayBase);
504
+ return rgbSeq(lerpRgb(from, MOCHA_RGB.border, 0.5 + (0.5 * decayT)));
398
505
  })();
399
506
 
400
507
  if (compact) {
@@ -407,7 +514,10 @@ function buildWorkerRail(name, st, opts = {}) {
407
514
  );
408
515
  const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
409
516
  const compactLine2 = truncate(color(verdict, MOCHA.text), innerWidth);
410
- const framed = box([compactLine1, compactLine2], Math.max(MIN_CARD_WIDTH, width), statusBorderColor);
517
+ const framed = box([compactLine1, compactLine2], cardWidth, statusBorderColor, {
518
+ highlightPos: borderHighlight,
519
+ titleFlashBg,
520
+ });
411
521
  return [framed.top, ...framed.body, framed.bot];
412
522
  }
413
523
 
@@ -422,8 +532,9 @@ function buildWorkerRail(name, st, opts = {}) {
422
532
  const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
423
533
  const percent = Math.round(progress * 100);
424
534
  const barWidth = clamp(Math.floor(innerWidth * 0.3), 8, 16);
535
+ const bar = progressBar(percent, barWidth, time);
425
536
  const progressLine = truncate(
426
- `${progressBar(percent, barWidth)} ${color(`${String(percent).padStart(3)}%`, MOCHA.text)}`,
537
+ `${bar} ${color(`${String(percent).padStart(3)}%`, MOCHA.text)}`,
427
538
  innerWidth,
428
539
  );
429
540
 
@@ -442,7 +553,10 @@ function buildWorkerRail(name, st, opts = {}) {
442
553
  truncate(`${color("files", MOCHA.overlay)} ${color(files, MOCHA.subtext)}`, innerWidth),
443
554
  ];
444
555
 
445
- const framed = box(lines, Math.max(MIN_CARD_WIDTH, width), statusBorderColor);
556
+ const framed = box(lines, cardWidth, statusBorderColor, {
557
+ highlightPos: borderHighlight,
558
+ titleFlashBg,
559
+ });
446
560
  return [framed.top, ...framed.body, framed.bot];
447
561
  }
448
562
 
@@ -455,6 +569,7 @@ function buildFocusPane(name, st, opts = {}) {
455
569
  followTail = false,
456
570
  rawMode = false,
457
571
  focused = false,
572
+ time = Date.now(),
458
573
  } = opts;
459
574
  const innerWidth = Math.max(12, width - 4);
460
575
 
@@ -523,9 +638,14 @@ function buildFocusPane(name, st, opts = {}) {
523
638
  truncate(scrollInfo, innerWidth),
524
639
  ];
525
640
 
526
- // focused pane gets bright border, unfocused gets dim
527
- const borderColor = focused ? MOCHA.blue : MOCHA.border;
528
- const framed = box(contentLines, Math.max(MIN_CARD_WIDTH, width), borderColor);
641
+ // Effect 2: focused pane gets gradient border (blue→border), unfocused gets dim
642
+ const borderColor = focused
643
+ ? gradientBorderFn(MOCHA_RGB.blue, MOCHA_RGB.border)
644
+ : MOCHA.border;
645
+ const paneWidth = Math.max(MIN_CARD_WIDTH, width);
646
+ const framed = box(contentLines, paneWidth, borderColor, {
647
+ highlightPos: focused ? borderHighlightPosition(paneWidth, contentLines.length, time) : undefined,
648
+ });
529
649
  return [framed.top, ...framed.body, framed.bot];
530
650
  }
531
651
 
@@ -540,11 +660,52 @@ function buildSummaryBar(names, workers, selectedWorker, pipeline, width, versio
540
660
  return padRight(truncate(label, maxChipWidth), maxChipWidth);
541
661
  });
542
662
  const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
543
- const keysLine = truncate(color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • 1-9:jump", MOCHA.subtext), width - 4);
663
+ const keysLine = truncate(color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • n:recent • 1-9:jump", MOCHA.subtext), width - 4);
544
664
  const framed = box([chipsLine, keysLine], width);
545
665
  return [framed.top, ...framed.body, framed.bot];
546
666
  }
547
667
 
668
+ // ── help overlay ──────────────────────────────────────────────────────────
669
+ function buildHelpOverlay(width, height) {
670
+ const innerWidth = Math.min(50, width - 6);
671
+ const helpLines = [
672
+ color(" Keyboard Shortcuts", FG.triflux),
673
+ "",
674
+ ` ${color("Tab", MOCHA.blue)} rail ↔ detail 포커스 전환`,
675
+ ` ${color("j/↓", MOCHA.blue)} 다음 워커 / 스크롤 아래`,
676
+ ` ${color("k/↑", MOCHA.blue)} 이전 워커 / 스크롤 위`,
677
+ ` ${color("1-9", MOCHA.blue)} 워커 직접 선택`,
678
+ ` ${color("n", MOCHA.blue)} 최근 상태 변경 워커 선택`,
679
+ ` ${color("f", MOCHA.blue)} follow-tail 토글`,
680
+ ` ${color("r", MOCHA.blue)} raw mode 토글`,
681
+ ` ${color("l", MOCHA.blue)} 탭 전환 (Log/Detail/Files)`,
682
+ ` ${color("g", MOCHA.blue)} focus pane 상단 점프`,
683
+ ` ${color("G", MOCHA.blue)} focus pane 하단 점프`,
684
+ ` ${color("PgUp", MOCHA.blue)} 페이지 위 스크롤`,
685
+ ` ${color("PgDn", MOCHA.blue)} 페이지 아래 스크롤`,
686
+ ` ${color("Shift+↑↓", MOCHA.blue)} 워커 선택 + 포커스 이동`,
687
+ ` ${color("Shift+←→", MOCHA.blue)} rail ↔ detail 포커스`,
688
+ ` ${color("h/?", MOCHA.blue)} 이 도움말 토글`,
689
+ ` ${color("q", MOCHA.blue)} 대시보드 종료`,
690
+ "",
691
+ dim(" 아무 키나 눌러 닫기"),
692
+ ];
693
+ const framed = box(helpLines, innerWidth + 4, MOCHA.blue);
694
+ const framedRows = [framed.top, ...framed.body, framed.bot];
695
+ const topPad = Math.max(0, Math.floor((height - framedRows.length) / 2));
696
+ const leftPad = " ".repeat(Math.max(0, Math.floor((width - innerWidth - 4) / 2)));
697
+ const result = [];
698
+ for (let i = 0; i < height; i++) {
699
+ const fi = i - topPad;
700
+ if (fi >= 0 && fi < framedRows.length) {
701
+ result.push(`${leftPad}${framedRows[fi]}`);
702
+ } else {
703
+ result.push("");
704
+ }
705
+ }
706
+ return result;
707
+ }
708
+
548
709
  // ── joinColumns ───────────────────────────────────────────────────────────
549
710
  function joinColumns(blocks, gap = GRID_GAP) {
550
711
  const maxHeight = Math.max(...blocks.map((b) => b.length));
@@ -628,12 +789,14 @@ export function createLogDashboard(opts = {}) {
628
789
  let closed = false;
629
790
  let frameCount = 0;
630
791
  let selectedWorker = null;
792
+ let previousSelectedWorker = null;
631
793
  // focus: "rail" | "detail"
632
794
  let focus = "rail";
633
795
  let detailScrollOffset = 0;
634
796
  let followTail = false;
635
797
  let rawMode = false;
636
798
  let focusTab = "log"; // "log" | "detail" | "files"
799
+ let helpOverlay = false;
637
800
  let inputAttached = false;
638
801
  let rawModeEnabled = false;
639
802
 
@@ -678,13 +841,34 @@ export function createLogDashboard(opts = {}) {
678
841
  if (!selectedWorker || !workers.has(selectedWorker)) selectedWorker = names[0];
679
842
  }
680
843
 
844
+ function setSelectedWorker(nextWorker, { preserveTrail = true } = {}) {
845
+ if (!nextWorker || nextWorker === selectedWorker) return;
846
+ if (preserveTrail && selectedWorker && workers.has(selectedWorker)) {
847
+ previousSelectedWorker = selectedWorker;
848
+ }
849
+ selectedWorker = nextWorker;
850
+ detailScrollOffset = 0;
851
+ }
852
+
681
853
  function selectRelative(offset) {
682
854
  const names = visibleWorkerNames();
683
855
  if (names.length === 0) return;
684
856
  ensureSelectedWorker(names);
685
857
  const idx = Math.max(0, names.indexOf(selectedWorker));
686
- selectedWorker = names[(idx + offset + names.length) % names.length];
687
- detailScrollOffset = 0;
858
+ setSelectedWorker(names[(idx + offset + names.length) % names.length]);
859
+ render();
860
+ }
861
+
862
+ function selectMostRecentChangedWorker() {
863
+ const names = visibleWorkerNames();
864
+ if (names.length === 0) return;
865
+ ensureSelectedWorker(names);
866
+ const target = names.reduce((best, name) => {
867
+ const changedAt = workers.get(name)?._statusChangedAt || 0;
868
+ const bestChangedAt = workers.get(best)?._statusChangedAt || 0;
869
+ return changedAt > bestChangedAt ? name : best;
870
+ }, names[0]);
871
+ setSelectedWorker(target);
688
872
  render();
689
873
  }
690
874
 
@@ -694,11 +878,29 @@ export function createLogDashboard(opts = {}) {
694
878
  render();
695
879
  }
696
880
 
881
+ // ── doClose (내부 함수) ─────────────────────────────────────────────
882
+ function doClose() {
883
+ if (closed) return;
884
+ if (timer) clearInterval(timer);
885
+ if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
886
+ if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
887
+ if (inputAttached && typeof input?.pause === "function") input.pause();
888
+ exitAltScreen();
889
+ closed = true;
890
+ }
891
+
697
892
  // ── 키 입력 ──────────────────────────────────────────────────────────
698
893
  function handleInput(chunk) {
699
894
  const key = String(chunk);
700
895
  if (key === "\u0003") return; // Ctrl-C
701
896
 
897
+ // Help overlay: 아무 키나 누르면 닫기
898
+ if (helpOverlay) {
899
+ helpOverlay = false;
900
+ render();
901
+ return;
902
+ }
903
+
702
904
  // Tab: rail ↔ detail 포커스 전환
703
905
  if (key === "\t") {
704
906
  focus = focus === "rail" ? "detail" : "rail";
@@ -722,6 +924,14 @@ export function createLogDashboard(opts = {}) {
722
924
  if (key === "k" || key === "\u001b[A") { selectRelative(-1); return; }
723
925
  }
724
926
 
927
+ // g: focus pane 상단 점프
928
+ if (key === "g") { followTail = false; detailScrollOffset = 0; render(); return; }
929
+ // G: focus pane 하단 점프
930
+ if (key === "G") { followTail = true; detailScrollOffset = 0; render(); return; }
931
+ // PgUp/PgDn: 페이지 단위 스크롤
932
+ const pageSize = Math.max(1, Math.floor(getViewportRows() / 2));
933
+ if (key === "\x1b[5~") { scrollDetail(-pageSize); return; } // PgUp
934
+ if (key === "\x1b[6~") { scrollDetail(pageSize); return; } // PgDn
725
935
  // f: follow-tail 토글
726
936
  if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
727
937
  // r: raw mode 토글
@@ -734,11 +944,17 @@ export function createLogDashboard(opts = {}) {
734
944
  render();
735
945
  return;
736
946
  }
947
+ // n: 가장 최근 상태 변경 워커로 이동
948
+ if (key === "n") { selectMostRecentChangedWorker(); return; }
949
+ // h/?: 도움말 오버레이 토글
950
+ if (key === "h" || key === "?") { helpOverlay = true; render(); return; }
951
+ // q: 대시보드 종료
952
+ if (key === "q") { doClose(); return; }
737
953
  // 1-9: 워커 직접 선택
738
954
  if (/^[1-9]$/.test(key)) {
739
955
  const names = visibleWorkerNames();
740
956
  const target = names[Number.parseInt(key, 10) - 1];
741
- if (target) { selectedWorker = target; detailScrollOffset = 0; render(); }
957
+ if (target) { setSelectedWorker(target); render(); }
742
958
  return;
743
959
  }
744
960
  }
@@ -773,10 +989,17 @@ export function createLogDashboard(opts = {}) {
773
989
 
774
990
  const totalCols = getViewportColumns();
775
991
  const totalRows = getViewportRows();
992
+
993
+ // Help overlay: 전체 화면 오버레이
994
+ if (helpOverlay) {
995
+ return buildHelpOverlay(totalCols, totalRows);
996
+ }
997
+
776
998
  const elapsed = nowElapsedSec();
999
+ const renderTime = Date.now();
777
1000
 
778
1001
  // Tier1: 상단 고정 2행
779
- const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION);
1002
+ const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION, renderTime);
780
1003
 
781
1004
  // 레이아웃 결정
782
1005
  let effectiveLayout = layoutHint;
@@ -800,6 +1023,7 @@ export function createLogDashboard(opts = {}) {
800
1023
  rawMode,
801
1024
  focused: focus === "detail",
802
1025
  activeTab: focusTab,
1026
+ time: renderTime,
803
1027
  });
804
1028
  return [...tier1, ...summaryBar, ...focusPane];
805
1029
  }
@@ -807,12 +1031,14 @@ export function createLogDashboard(opts = {}) {
807
1031
  // 좌우 분할: Left Rail (30%) | Right Focus (70%)
808
1032
  // 목업: Tier2 Left Rail + Tier3 Focus 나란히 렌더링
809
1033
  const GAP = 1; // rail과 focus 사이 구분선
810
- const railWidth = Math.max(MIN_CARD_WIDTH, Math.floor(totalCols * 0.25));
1034
+ const railRatio = focus === "detail" ? 0.20 : 0.30;
1035
+ const railWidth = Math.max(MIN_CARD_WIDTH, Math.floor(totalCols * railRatio));
811
1036
  const focusWidth = totalCols - railWidth - GAP;
812
1037
  const bodyHeight = Math.max(6, totalRows - tier1.length - 1); // -1 for status bar
813
1038
 
814
- // compact 자동 적용: viewport 행이 20 미만이면 2-line 카드
815
- const useCompact = totalRows < 20;
1039
+ // 반응형 compact: 워커 카드가 가용 높이 초과 자동 전환
1040
+ const normalCardHeight = 8; // box top/bot + 6 content lines
1041
+ const useCompact = names.length * normalCardHeight > bodyHeight;
816
1042
 
817
1043
  // Left Rail: 워커 카드 세로 스택
818
1044
  const railLines = [];
@@ -820,9 +1046,11 @@ export function createLogDashboard(opts = {}) {
820
1046
  const card = buildWorkerRail(name, workers.get(name), {
821
1047
  width: railWidth,
822
1048
  selected: name === selectedWorker,
1049
+ previousSelected: name === previousSelectedWorker,
823
1050
  focused: focus === "rail" && name === selectedWorker,
824
1051
  rawMode,
825
1052
  compact: useCompact,
1053
+ time: renderTime,
826
1054
  });
827
1055
  railLines.push(...card);
828
1056
  }
@@ -841,6 +1069,7 @@ export function createLogDashboard(opts = {}) {
841
1069
  rawMode,
842
1070
  focused: focus === "detail",
843
1071
  activeTab: focusTab,
1072
+ time: renderTime,
844
1073
  });
845
1074
  }
846
1075
  while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
@@ -899,10 +1128,14 @@ export function createLogDashboard(opts = {}) {
899
1128
  if (closed) return;
900
1129
  frameCount++;
901
1130
  spinnerTick++;
902
- if (isTTY) {
903
- renderAltScreen();
904
- } else {
905
- renderAppendOnly();
1131
+ try {
1132
+ if (isTTY) {
1133
+ renderAltScreen();
1134
+ } else {
1135
+ renderAppendOnly();
1136
+ }
1137
+ } finally {
1138
+ previousSelectedWorker = null;
906
1139
  }
907
1140
  }
908
1141
 
@@ -950,7 +1183,7 @@ export function createLogDashboard(opts = {}) {
950
1183
 
951
1184
  selectWorker(name) {
952
1185
  if (!workers.has(name)) return;
953
- selectedWorker = name;
1186
+ setSelectedWorker(name);
954
1187
  },
955
1188
 
956
1189
  toggleDetail(force) {
@@ -994,14 +1227,16 @@ export function createLogDashboard(opts = {}) {
994
1227
  return layoutHint;
995
1228
  },
996
1229
 
1230
+ toggleHelp(force) {
1231
+ helpOverlay = typeof force === "boolean" ? force : !helpOverlay;
1232
+ },
1233
+
1234
+ isHelpVisible() {
1235
+ return helpOverlay;
1236
+ },
1237
+
997
1238
  close() {
998
- if (closed) return;
999
- if (timer) clearInterval(timer);
1000
- if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
1001
- if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
1002
- if (inputAttached && typeof input?.pause === "function") input.pause();
1003
- exitAltScreen();
1004
- closed = true;
1239
+ doClose();
1005
1240
  },
1006
1241
  };
1007
1242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "8.6.0",
3
+ "version": "8.7.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {