Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/tui/components/LiveLog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<LiveLog logs={['[worker] [de-artifact | INT-1918 | worktree/0cc4e232] Codex turn completed']} />,
).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(<LiveLog logs={[]} />).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(<LiveLog logs={logs} max={3} />).lastFrame()!;
expect(f).toContain('line 19');
expect(f).not.toContain('line 16');
});
});
9 changes: 4 additions & 5 deletions src/tui/components/LiveLog.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -15,9 +16,7 @@ export function LiveLog({ logs, max = 12 }: LiveLogProps) {
{shown.length === 0 ? (
<Text dimColor>(no log output yet)</Text>
) : (
shown.map((line, i) => (
<Text key={i} dimColor>{line}</Text>
))
shown.map((line, i) => <LogLine key={i} line={line} />)
)}
</Box>
);
Expand Down
15 changes: 15 additions & 0 deletions src/tui/components/LogLine.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text>
{parseLogLine(line).map((s, i) => (
<Text key={i} color={s.color} bold={s.bold} dimColor={s.dim}>
{s.text}
</Text>
))}
</Text>
);
}
45 changes: 45 additions & 0 deletions src/tui/logFormat.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
91 changes: 91 additions & 0 deletions src/tui/logFormat.ts
Original file line number Diff line number Diff line change
@@ -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 <Text> 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<string, string> = {
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 }];
}
Loading