From 94f1161f141b883dacf8984eedba33ca9d92a3e4 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 27 Jun 2026 10:22:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(tui):=20Logs/LiveLog=20=EC=83=89=EC=83=81?= =?UTF-8?q?=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8C=85=20(INT-1974)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs 탭(7) 라이브 로그가 흑백이라 가독성 낮던 문제. Ink로 토큰별 색상. - tui/logFormat.ts(순수): parseLogLine(line) → LogSegment[]. 스테이지 태그 스테이지별 색, 컨텍스트 그룹의 이슈ID(노랑 bold)·worktree(dim)·project(초록), body 레벨 색 (error/halt→빨강, ✓/completed/success→초록), inline `code`→cyan. lossless(원문 보존). - tui/components/LogLine.tsx: 세그먼트를 Ink span으로 렌더. - LiveLog: 각 줄을 LogLine으로 렌더 → Logs + Pipeline 탭 동시 개선. 테스트: parseLogLine 6건(스테이지/이슈/worktree/project/레벨/코드/lossless) + LiveLog 렌더 3건. 전체 1171 green. INT-1962 후속(사용자 피드백). --- src/tui/components/LiveLog.test.tsx | 26 +++++++++ src/tui/components/LiveLog.tsx | 9 ++- src/tui/components/LogLine.tsx | 15 +++++ src/tui/logFormat.test.ts | 45 ++++++++++++++ src/tui/logFormat.ts | 91 +++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 src/tui/components/LiveLog.test.tsx create mode 100644 src/tui/components/LogLine.tsx create mode 100644 src/tui/logFormat.test.ts create mode 100644 src/tui/logFormat.ts diff --git a/src/tui/components/LiveLog.test.tsx b/src/tui/components/LiveLog.test.tsx new file mode 100644 index 0000000..b5f7dd2 --- /dev/null +++ b/src/tui/components/LiveLog.test.tsx @@ -0,0 +1,26 @@ +// Purpose: LiveLog renders highlighted log lines (INT-1974). +import { describe, it, expect } from 'vitest'; +import { render } from 'ink-testing-library'; +import { LiveLog } from './LiveLog.js'; + +describe('LiveLog (INT-1974)', () => { + it('renders each line with its content (highlighted via LogLine)', () => { + const f = render( + , + ).lastFrame()!; + expect(f).toContain('[worker]'); + expect(f).toContain('INT-1918'); + expect(f).toContain('Codex turn completed'); + }); + + it('shows the empty placeholder when there are no logs', () => { + expect(render().lastFrame()).toContain('no log output yet'); + }); + + it('caps to the most recent `max` lines', () => { + const logs = Array.from({ length: 20 }, (_, i) => `line ${i}`); + const f = render().lastFrame()!; + expect(f).toContain('line 19'); + expect(f).not.toContain('line 16'); + }); +}); diff --git a/src/tui/components/LiveLog.tsx b/src/tui/components/LiveLog.tsx index 12d680a..3170260 100644 --- a/src/tui/components/LiveLog.tsx +++ b/src/tui/components/LiveLog.tsx @@ -1,6 +1,7 @@ -// LiveLog — recent daemon log lines (EPIC INT-1813 S5). Presentational; parity -// with the dashboard's renderLog. +// LiveLog — recent daemon log lines (EPIC INT-1813 S5). Each line is syntax- +// highlighted via LogLine (stage/issue/worktree/code/level colors). (INT-1974) import { Box, Text } from 'ink'; +import { LogLine } from './LogLine.js'; export interface LiveLogProps { logs: string[]; @@ -15,9 +16,7 @@ export function LiveLog({ logs, max = 12 }: LiveLogProps) { {shown.length === 0 ? ( (no log output yet) ) : ( - shown.map((line, i) => ( - {line} - )) + shown.map((line, i) => ) )} ); diff --git a/src/tui/components/LogLine.tsx b/src/tui/components/LogLine.tsx new file mode 100644 index 0000000..41e5dba --- /dev/null +++ b/src/tui/components/LogLine.tsx @@ -0,0 +1,15 @@ +// LogLine — render one daemon log line as colored Ink spans (INT-1974). +import { Text } from 'ink'; +import { parseLogLine } from '../logFormat.js'; + +export function LogLine({ line }: { line: string }) { + return ( + + {parseLogLine(line).map((s, i) => ( + + {s.text} + + ))} + + ); +} diff --git a/src/tui/logFormat.test.ts b/src/tui/logFormat.test.ts new file mode 100644 index 0000000..6312204 --- /dev/null +++ b/src/tui/logFormat.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { parseLogLine, type LogSegment } from './logFormat.js'; + +const find = (segs: LogSegment[], text: string) => segs.find((s) => s.text === text); +const joined = (segs: LogSegment[]) => segs.map((s) => s.text).join(''); + +describe('parseLogLine (INT-1974)', () => { + it('colors the stage tag by stage and preserves the full text', () => { + const line = '[worker] [de-artifact | INT-1918 | worktree/0cc4e232] Codex turn completed'; + const segs = parseLogLine(line); + expect(joined(segs)).toBe(line); // lossless + expect(find(segs, '[worker]')).toMatchObject({ color: 'cyan', bold: true }); + expect(find(segs, '[reviewer]')).toBeUndefined(); + }); + + it('highlights issue id (yellow bold), worktree (dim), project (green)', () => { + const segs = parseLogLine('[worker] [de-artifact | INT-1918 | worktree/0cc4e232] body'); + expect(find(segs, 'INT-1918')).toMatchObject({ color: 'yellow', bold: true }); + expect(find(segs, 'worktree/0cc4e232')).toMatchObject({ dim: true }); + expect(find(segs, 'de-artifact')).toMatchObject({ color: 'green' }); + }); + + it('handles AUD-style ids and project names with spaces', () => { + const segs = parseLogLine('[worker] [WAVE - Rust synth | AUD-160 | worktree/8fZ2ea25] x'); + expect(find(segs, 'AUD-160')).toMatchObject({ color: 'yellow' }); + expect(find(segs, 'WAVE - Rust synth')).toMatchObject({ color: 'green' }); + }); + + it('colors error/halt bodies red and success bodies green', () => { + const err = parseLogLine('[worker] [p | INT-1 | worktree/a] {"success": false, "haltReason": "x"}'); + expect(err.some((s) => s.color === 'red')).toBe(true); + const ok = parseLogLine('[reviewer] [p | INT-1 | worktree/a] review approved'); + expect(ok.some((s) => s.color === 'green')).toBe(true); + }); + + it('highlights inline `code` spans in cyan within the body', () => { + const segs = parseLogLine('[worker] [p | INT-1 | worktree/a] edit `ab-player.js` now'); + expect(find(segs, '`ab-player.js`')).toMatchObject({ color: 'cyan' }); + }); + + it('falls back gracefully for a plain line', () => { + const segs = parseLogLine('just a plain message'); + expect(joined(segs)).toBe('just a plain message'); + }); +}); diff --git a/src/tui/logFormat.ts b/src/tui/logFormat.ts new file mode 100644 index 0000000..922d761 --- /dev/null +++ b/src/tui/logFormat.ts @@ -0,0 +1,91 @@ +// ============================================ +// OpenSwarm - log line highlighter (INT-1974) +// ============================================ +// +// Daemon log lines look like: +// [worker] [de-artifact | INT-1918 | worktree/0cc4e232] 1) `ab-player.js`: … +// Parse one into colored segments so the Logs/Pipeline tabs render structured, +// readable output instead of a monochrome wall. Pure — the Ink spans are +// produced by components/LogLine.tsx from these segments. + +export interface LogSegment { + text: string; + /** Ink color name (cyan/green/yellow/red/magenta/blue…). Omit for default. */ + color?: string; + bold?: boolean; + dim?: boolean; +} + +/** Per-pipeline-stage tag color. */ +const STAGE_COLORS: Record = { + worker: 'cyan', + reviewer: 'magenta', + tester: 'yellow', + planner: 'blue', + documenter: 'green', + auditor: 'red', + 'skill-documenter': 'green', +}; + +const ISSUE_RE = /^[A-Z]{2,}-\d+$/; + +/** Level color for the message body. */ +function bodyColor(body: string): string | undefined { + const l = body.toLowerCase(); + if (/(\berror\b|\bfail|✖|✗|"success":\s*false|halt)/.test(l)) return 'red'; + if (/(✓|completed|succeed|success|done|approved|passed)/.test(l)) return 'green'; + return undefined; +} + +/** Split a body into default/level-colored text with `inline code` highlighted cyan. */ +function formatBody(body: string): LogSegment[] { + if (!body) return []; + const color = bodyColor(body); + const out: LogSegment[] = []; + const re = /`([^`]+)`/g; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(body))) { + if (m.index > last) out.push({ text: body.slice(last, m.index), color }); + out.push({ text: `\`${m[1]}\``, color: 'cyan' }); + last = m.index + m[0].length; + } + if (last < body.length) out.push({ text: body.slice(last), color }); + return out; +} + +/** Parse a daemon log line into colored segments. (INT-1974) */ +export function parseLogLine(line: string): LogSegment[] { + const segs: LogSegment[] = []; + let rest = line; + + // 1) leading [stage] + const stageM = rest.match(/^\[([a-z][\w-]*)\]\s*/i); + if (stageM) { + const stage = stageM[1].toLowerCase(); + segs.push({ text: `[${stageM[1]}]`, color: STAGE_COLORS[stage] ?? 'white', bold: true }); + segs.push({ text: ' ' }); + rest = rest.slice(stageM[0].length); + } + + // 2) context group [project | ISSUE | worktree/hash] + const ctxM = rest.match(/^\[([^\]]*)\]\s*/); + if (ctxM) { + segs.push({ text: '[', dim: true }); + const parts = ctxM[1].split('|').map((p) => p.trim()); + parts.forEach((p, i) => { + if (i > 0) segs.push({ text: ' | ', dim: true }); + if (ISSUE_RE.test(p)) segs.push({ text: p, color: 'yellow', bold: true }); + else if (p.startsWith('worktree/')) segs.push({ text: p, dim: true }); + else segs.push({ text: p, color: 'green' }); + }); + segs.push({ text: ']', dim: true }); + segs.push({ text: ' ' }); + rest = rest.slice(ctxM[0].length); + } + + // 3) body + segs.push(...formatBody(rest)); + // A line that was only a (stage/context) prefix still needs at least one segment. + return segs.length ? segs : [{ text: line }]; +}