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:
committed by
GitHub
parent
85cc1d6211
commit
b868d1917e
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
40
frontend/src/components/common/RawLogText.tsx
Normal file
40
frontend/src/components/common/RawLogText.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
15
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user