Raw logs should render ANSI (vibe-kanban) (#520)

* Perfect! Let me summarize what has been successfully implemented:

##  Implementation Complete

I've successfully implemented a **unified log rendering architecture** with ANSI support across the entire application:

### 🎯 What Was Accomplished

1. ** Added `fancy-ansi` dependency** - React 18 compatible ANSI rendering library
2. ** Created `RawLogText` component** - Single, unified component for all raw log rendering
3. ** Added ANSI color CSS classes** - Consistent theming with Tailwind classes
4. ** Refactored all log rendering components**:
   - **StdoutEntry.tsx** - Now uses RawLogText with `channel="stdout"`
   - **StderrEntry.tsx** - Now uses RawLogText with `channel="stderr"`  
   - **ProcessLogsViewer.tsx** - Replaced `formatLogLine()` with RawLogText
   - **ProcessCard.tsx** - Updated raw log display to use RawLogText
5. ** Added comprehensive tests** - Unit tests for the RawLogText component
6. ** Verified build success** - All TypeScript checks pass

### 🌟 Key Features

- **ANSI Escape Code Rendering**: Raw logs like `[0m[1][0m   [32m[1mVITE[22m v5.4.19[39m` now render with proper colors
- **Smart STDERR Handling**: Only applies red fallback color when no ANSI codes are present
- **Flexible HTML Elements**: Supports both `<div>` and `<span>` rendering
- **Performance Optimized**: Uses `memo()` and efficient ANSI detection
- **Consistent Styling**: Single source of truth for all log rendering with proper whitespace preservation

### 📁 Files Modified

- `frontend/package.json` - Added `fancy-ansi` dependency
- `frontend/src/components/common/RawLogText.tsx` - **New unified component**
- `frontend/src/styles/index.css` - Added ANSI color CSS classes
- `frontend/src/components/logs/StdoutEntry.tsx` - Refactored to use RawLogText
- `frontend/src/components/logs/StderrEntry.tsx` - Refactored to use RawLogText  
- `frontend/src/components/tasks/TaskDetails/ProcessLogsViewer.tsx` - Refactored to use RawLogText
- `frontend/src/components/tasks/TaskDetails/ProcessCard.tsx` - Refactored to use RawLogText
- `frontend/src/components/common/__tests__/RawLogText.test.tsx` - **New test file**

The implementation follows all the best practices identified by the Oracle and provides a robust, maintainable solution for ANSI log rendering across the entire application. Raw logs will now display with proper colors, formatting, and styling instead of showing escape codes as plain text.

* Cleanup script changes for task attempt 287442b9-2ddf-4f29-9b91-ddc18e4a96b7

* lockfile

* delete test
This commit is contained in:
Louis Knight-Webb
2025-08-19 14:19:50 +01:00
committed by GitHub
parent 85cc1d6211
commit b868d1917e
9 changed files with 155 additions and 22 deletions

View File

@@ -34,6 +34,7 @@
"click-to-react-component": "^1.1.2", "click-to-react-component": "^1.1.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"diff": "^8.0.2", "diff": "^8.0.2",
"fancy-ansi": "^0.1.3",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-diff-viewer-continued": "^3.4.0", "react-diff-viewer-continued": "^3.4.0",
@@ -4166,6 +4167,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -4471,6 +4478,15 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fancy-ansi": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/fancy-ansi/-/fancy-ansi-0.1.3.tgz",
"integrity": "sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw==",
"license": "Apache-2.0",
"dependencies": {
"escape-html": "^1.0.3"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",

View File

@@ -40,6 +40,7 @@
"click-to-react-component": "^1.1.2", "click-to-react-component": "^1.1.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"diff": "^8.0.2", "diff": "^8.0.2",
"fancy-ansi": "^0.1.3",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-diff-viewer-continued": "^3.4.0", "react-diff-viewer-continued": "^3.4.0",

View File

@@ -0,0 +1,40 @@
import { memo } from 'react';
import { AnsiHtml } from 'fancy-ansi/react';
import { hasAnsi } from 'fancy-ansi';
import { clsx } from 'clsx';
interface RawLogTextProps {
content: string;
channel?: 'stdout' | 'stderr';
as?: 'div' | 'span';
className?: string;
}
const RawLogText = memo(
({
content,
channel = 'stdout',
as: Component = 'div',
className,
}: RawLogTextProps) => {
// Only apply stderr fallback color when no ANSI codes are present
const hasAnsiCodes = hasAnsi(content);
const shouldApplyStderrFallback = channel === 'stderr' && !hasAnsiCodes;
return (
<Component
className={clsx(
'font-mono text-xs break-all whitespace-pre-wrap',
shouldApplyStderrFallback && 'text-red-600',
className
)}
>
<AnsiHtml text={content} />
</Component>
);
}
);
RawLogText.displayName = 'RawLogText';
export default RawLogText;

View File

@@ -1,11 +1,13 @@
import RawLogText from '@/components/common/RawLogText';
interface StderrEntryProps { interface StderrEntryProps {
content: string; content: string;
} }
function StderrEntry({ content }: StderrEntryProps) { function StderrEntry({ content }: StderrEntryProps) {
return ( return (
<div className="flex gap-2 text-xs font-mono px-4"> <div className="flex gap-2 px-4">
<span className="text-red-600 break-all">{content}</span> <RawLogText content={content} channel="stderr" as="span" />
</div> </div>
); );
} }

View File

@@ -1,11 +1,13 @@
import RawLogText from '@/components/common/RawLogText';
interface StdoutEntryProps { interface StdoutEntryProps {
content: string; content: string;
} }
function StdoutEntry({ content }: StdoutEntryProps) { function StdoutEntry({ content }: StdoutEntryProps) {
return ( return (
<div className="flex gap-2 text-xs font-mono px-4"> <div className="flex gap-2 px-4">
<span className="break-all">{content}</span> <RawLogText content={content} channel="stdout" as="span" />
</div> </div>
); );
} }

View File

@@ -12,6 +12,7 @@ import type { ExecutionProcessStatus, ExecutionProcess } from 'shared/types';
import { useLogStream } from '@/hooks/useLogStream'; import { useLogStream } from '@/hooks/useLogStream';
import { useProcessConversation } from '@/hooks/useProcessConversation'; import { useProcessConversation } from '@/hooks/useProcessConversation';
import DisplayConversationEntry from '@/components/NormalizedConversation/DisplayConversationEntry'; import DisplayConversationEntry from '@/components/NormalizedConversation/DisplayConversationEntry';
import RawLogText from '@/components/common/RawLogText';
interface ProcessCardProps { interface ProcessCardProps {
process: ExecutionProcess; process: ExecutionProcess;
@@ -173,17 +174,12 @@ function ProcessCard({ process }: ProcessCardProps) {
<div className="text-gray-400">No logs available...</div> <div className="text-gray-400">No logs available...</div>
) : ( ) : (
logs.map((logEntry, index) => ( logs.map((logEntry, index) => (
<div key={index} className="break-all"> <RawLogText
{logEntry.type === 'STDERR' ? ( key={index}
<span className="text-destructive"> content={logEntry.content}
{logEntry.content} channel={logEntry.type === 'STDERR' ? 'stderr' : 'stdout'}
</span> as="div"
) : ( />
<span className="text-foreground">
{logEntry.content}
</span>
)}
</div>
)) ))
)} )}
<div ref={logEndRef} /> <div ref={logEndRef} />

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { useLogStream } from '@/hooks/useLogStream'; import { useLogStream } from '@/hooks/useLogStream';
import RawLogText from '@/components/common/RawLogText';
import type { PatchType } from 'shared/types'; import type { PatchType } from 'shared/types';
type LogEntry = Extract<PatchType, { type: 'STDOUT' } | { type: 'STDERR' }>; type LogEntry = Extract<PatchType, { type: 'STDOUT' } | { type: 'STDERR' }>;
@@ -53,14 +54,13 @@ export default function ProcessLogsViewer({
}, [logs.length, atBottom, logs]); }, [logs.length, atBottom, logs]);
const formatLogLine = (entry: LogEntry, index: number) => { const formatLogLine = (entry: LogEntry, index: number) => {
let className = 'text-sm font-mono px-4 py-1 whitespace-pre-wrap';
className +=
entry.type === 'STDERR' ? ' text-destructive' : ' text-foreground';
return ( return (
<div key={index} className={className}> <RawLogText
{entry.content} key={index}
</div> content={entry.content}
channel={entry.type === 'STDERR' ? 'stderr' : 'stdout'}
className="text-sm px-4 py-1"
/>
); );
}; };

View File

@@ -291,3 +291,64 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* ANSI color classes for fancy-ansi */
@layer components {
.ansi-red {
@apply text-red-500;
}
.ansi-green {
@apply text-green-500;
}
.ansi-yellow {
@apply text-yellow-500;
}
.ansi-blue {
@apply text-blue-500;
}
.ansi-magenta {
@apply text-purple-500;
}
.ansi-cyan {
@apply text-cyan-500;
}
.ansi-white {
@apply text-white;
}
.ansi-black {
@apply text-black;
}
.ansi-bright-red {
@apply text-red-400;
}
.ansi-bright-green {
@apply text-green-400;
}
.ansi-bright-yellow {
@apply text-yellow-400;
}
.ansi-bright-blue {
@apply text-blue-400;
}
.ansi-bright-magenta {
@apply text-purple-400;
}
.ansi-bright-cyan {
@apply text-cyan-400;
}
.ansi-bright-white {
@apply text-gray-200;
}
.ansi-bright-black {
@apply text-gray-700;
}
.ansi-bold {
@apply font-bold;
}
.ansi-italic {
@apply italic;
}
.ansi-underline {
@apply underline;
}
}

15
pnpm-lock.yaml generated
View File

@@ -95,6 +95,9 @@ importers:
diff: diff:
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
fancy-ansi:
specifier: ^0.1.3
version: 0.1.3
lucide-react: lucide-react:
specifier: ^0.539.0 specifier: ^0.539.0
version: 0.539.0(react@18.3.1) version: 0.539.0(react@18.3.1)
@@ -1772,6 +1775,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@4.0.0: escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1856,6 +1862,9 @@ packages:
extend@3.0.2: extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fancy-ansi@0.1.3:
resolution: {integrity: sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw==}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -4614,6 +4623,8 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
escape-html@1.0.3: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.5(eslint@8.57.1): eslint-config-prettier@10.1.5(eslint@8.57.1):
@@ -4715,6 +4726,10 @@ snapshots:
extend@3.0.2: {} extend@3.0.2: {}
fancy-ansi@0.1.3:
dependencies:
escape-html: 1.0.3
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {} fast-diff@1.3.0: {}