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 }];
+}