diff --git a/.changeset/quiet-flies-report.md b/.changeset/quiet-flies-report.md
new file mode 100644
index 000000000..0b877c99e
--- /dev/null
+++ b/.changeset/quiet-flies-report.md
@@ -0,0 +1,5 @@
+---
+'@halfdomelabs/project-builder-web': patch
+---
+
+Replace @cocalc/ansi-to-react with the anser library directly
diff --git a/packages/project-builder-web/package.json b/packages/project-builder-web/package.json
index f68ef59bb..22b016862 100644
--- a/packages/project-builder-web/package.json
+++ b/packages/project-builder-web/package.json
@@ -34,7 +34,6 @@
]
},
"dependencies": {
- "@cocalc/ansi-to-react": "^7.0.0",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -51,6 +50,7 @@
"@trpc/server": "^10.44.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
+ "anser": "2.3.2",
"axios": "^1.8.3",
"clsx": "2.1.1",
"culori": "^4.0.1",
diff --git a/packages/project-builder-web/src/app/AppLayout/ProjectSyncModal.tsx b/packages/project-builder-web/src/app/AppLayout/ProjectSyncModal.tsx
index c50753148..fda7b3343 100644
--- a/packages/project-builder-web/src/app/AppLayout/ProjectSyncModal.tsx
+++ b/packages/project-builder-web/src/app/AppLayout/ProjectSyncModal.tsx
@@ -1,5 +1,4 @@
import type React from 'react';
-import type { ConsoleRef } from 'src/components/Console';
import {
useBlockBeforeContinue,
@@ -9,10 +8,12 @@ import { Button, Dialog, toast } from '@halfdomelabs/ui-components';
import clsx from 'clsx';
import { useRef, useState } from 'react';
import { MdSync } from 'react-icons/md';
-import Console from 'src/components/Console';
import { startSync } from 'src/services/api';
import { formatError } from 'src/services/error-formatter';
+import type { ConsoleRef } from '@src/components';
+
+import { Console } from '@src/components';
import { useProjects } from '@src/hooks/useProjects';
interface Props {
diff --git a/packages/project-builder-web/src/components/AnsiText/AnsiText.tsx b/packages/project-builder-web/src/components/AnsiText/AnsiText.tsx
new file mode 100644
index 000000000..8ca9b7fd7
--- /dev/null
+++ b/packages/project-builder-web/src/components/AnsiText/AnsiText.tsx
@@ -0,0 +1,83 @@
+import type React from 'react';
+
+import anser from 'anser';
+
+interface AnsiTextProps {
+ text: string;
+}
+
+/**
+ * Component to render ANSI colored text using anser
+ * Uses inline styles only, no CSS classes
+ *
+ * @param {AnsiTextProps} props - Component props
+ * @param {string} props.text - Text with ANSI escape sequences
+ * @returns {JSX.Element} Rendered ANSI text with proper styling
+ */
+export function AnsiText({ text }: AnsiTextProps): React.JSX.Element {
+ // Parse ANSI escape sequences
+ const parsedLines = text.split('\n').map((line, i) => {
+ // Use ansiToJson with use_classes: false to get RGB values
+ const parsed = anser.ansiToJson(line, { use_classes: false });
+
+ return (
+
+ {parsed.map((part, j) => {
+ // Extract all decorations from the part
+ const { decorations } = part;
+
+ // Create the style object
+ const style: React.CSSProperties = {};
+
+ // Add foreground color
+ if (part.fg) {
+ style.color = `rgb(${part.fg})`;
+ }
+
+ // Add background color
+ if (part.bg) {
+ style.backgroundColor = `rgb(${part.bg})`;
+ }
+
+ // Add text decorations
+ if (decorations.includes('bold')) {
+ style.fontWeight = 'bold';
+ }
+
+ if (decorations.includes('italic')) {
+ style.fontStyle = 'italic';
+ }
+
+ if (decorations.includes('dim')) {
+ style.opacity = 0.5;
+ }
+
+ if (decorations.includes('hidden')) {
+ style.visibility = 'hidden';
+ }
+
+ // Combine underline and strikethrough if both are present
+ const textDecorations = [];
+ if (decorations.includes('underline'))
+ textDecorations.push('underline');
+ if (decorations.includes('strikethrough'))
+ textDecorations.push('line-through');
+ if (decorations.includes('blink')) textDecorations.push('blink');
+
+ if (textDecorations.length > 0) {
+ style.textDecoration = textDecorations.join(' ');
+ }
+
+ // Return the styled span
+ return (
+
+ {part.content}
+
+ );
+ })}
+
+ );
+ });
+
+ return <>{parsedLines}>;
+}
diff --git a/packages/project-builder-web/src/components/AnsiText/AnsiText.unit.test.tsx b/packages/project-builder-web/src/components/AnsiText/AnsiText.unit.test.tsx
new file mode 100644
index 000000000..e01572e23
--- /dev/null
+++ b/packages/project-builder-web/src/components/AnsiText/AnsiText.unit.test.tsx
@@ -0,0 +1,156 @@
+import type { AnserJsonEntry } from 'anser';
+
+import { render, screen } from '@testing-library/react';
+import anser from 'anser';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { AnsiText } from './AnsiText';
+
+// Mock the anser library
+vi.mock('anser', () => ({
+ default: {
+ ansiToJson: vi.fn(),
+ },
+}));
+
+// Helper function to create properly typed mock entries
+function createMockAnserEntry(
+ options: Partial = {},
+): AnserJsonEntry {
+ return {
+ content: options.content ?? '',
+ fg: options.fg ?? '',
+ bg: options.bg ?? '',
+ fg_truecolor: options.fg_truecolor ?? '',
+ bg_truecolor: options.bg_truecolor ?? '',
+ decorations: options.decorations ?? [],
+ was_processed: options.was_processed ?? false,
+ clearLine: options.clearLine ?? false,
+ isEmpty: options.isEmpty ?? (() => false),
+ decoration: options.decoration ?? null,
+ };
+}
+
+// Properly type the mocked function
+const mockedAnser = vi.mocked(anser, true);
+
+describe('AnsiText', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders plain text without styles', () => {
+ const text = 'Hello, world!';
+
+ // Mock ansiToJson to return plain text
+ mockedAnser.ansiToJson.mockReturnValueOnce([
+ createMockAnserEntry({ content: text }),
+ ]);
+
+ render();
+
+ expect(screen.getByText(text)).toBeInTheDocument();
+ expect(mockedAnser.ansiToJson).toHaveBeenCalledWith(text, {
+ use_classes: false,
+ });
+ });
+
+ it('applies colors to text', () => {
+ const text = 'Colored text';
+
+ // Mock ansiToJson to return text with foreground and background colors
+ mockedAnser.ansiToJson.mockReturnValueOnce([
+ createMockAnserEntry({
+ content: text,
+ fg: '255, 0, 0',
+ bg: '0, 0, 255',
+ }),
+ ]);
+
+ render();
+
+ const element = screen.getByText(text);
+ expect(element).toBeInTheDocument();
+ expect(element).toHaveStyle({
+ color: 'rgb(255, 0, 0)',
+ 'background-color': 'rgb(0, 0, 255)',
+ });
+ });
+
+ it('applies text decorations correctly', () => {
+ const text = 'Decorated text';
+
+ // Mock ansiToJson to return text with multiple decorations
+ mockedAnser.ansiToJson.mockReturnValueOnce([
+ createMockAnserEntry({
+ content: text,
+ decorations: ['bold', 'italic', 'underline'],
+ }),
+ ]);
+
+ render();
+
+ const element = screen.getByText(text);
+ expect(element).toBeInTheDocument();
+ expect(element).toHaveStyle({
+ 'font-weight': 'bold',
+ 'font-style': 'italic',
+ 'text-decoration': 'underline',
+ });
+ });
+
+ it('handles multiple segments in a line', () => {
+ // Mock ansiToJson to return multiple segments with different styles
+ mockedAnser.ansiToJson.mockReturnValueOnce([
+ createMockAnserEntry({
+ content: 'Red',
+ fg: '255, 0, 0',
+ }),
+ createMockAnserEntry({
+ content: 'Bold',
+ decorations: ['bold'],
+ fg: '0, 0, 255',
+ }),
+ ]);
+
+ render();
+
+ const redSegment = screen.getByText('Red');
+ const boldSegment = screen.getByText('Bold');
+
+ expect(redSegment).toHaveStyle('color: rgb(255, 0, 0)');
+ expect(boldSegment).toHaveStyle({
+ color: 'rgb(0, 0, 255)',
+ 'font-weight': 'bold',
+ });
+ });
+
+ it('renders multiple lines correctly', () => {
+ // Set up a mock for multiline text
+ const multilineText = 'Line 1\nLine 2';
+
+ // First call for Line 1
+ mockedAnser.ansiToJson
+ .mockReturnValueOnce([
+ createMockAnserEntry({
+ content: 'Line 1',
+ decorations: ['bold'],
+ }),
+ ])
+ // Second call for Line 2
+ .mockReturnValueOnce([
+ createMockAnserEntry({
+ content: 'Line 2',
+ decorations: ['italic'],
+ }),
+ ]);
+
+ render();
+
+ expect(screen.getByText('Line 1')).toHaveStyle('font-weight: bold');
+ expect(screen.getByText('Line 2')).toHaveStyle('font-style: italic');
+
+ // Verify ansiToJson was called once per line
+ expect(mockedAnser.ansiToJson).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/project-builder-web/src/components/Console/index.tsx b/packages/project-builder-web/src/components/Console/Console.tsx
similarity index 93%
rename from packages/project-builder-web/src/components/Console/index.tsx
rename to packages/project-builder-web/src/components/Console/Console.tsx
index 9a86cc5c4..c2113b121 100644
--- a/packages/project-builder-web/src/components/Console/index.tsx
+++ b/packages/project-builder-web/src/components/Console/Console.tsx
@@ -1,6 +1,5 @@
import type { UIEventHandler } from 'react';
-import Ansi from '@cocalc/ansi-to-react';
import clsx from 'clsx';
import {
forwardRef,
@@ -13,6 +12,8 @@ import {
import { useProjects } from '@src/hooks/useProjects';
import { trpc } from '@src/services/trpc';
+import { AnsiText } from '../AnsiText/AnsiText';
+
interface Props {
className?: string;
}
@@ -21,7 +22,7 @@ export interface ConsoleRef {
clearConsole: () => void;
}
-const Console = forwardRef(({ className }, ref) => {
+export const Console = forwardRef(({ className }, ref) => {
const [consoleText, setConsoleText] = useState('');
useImperativeHandle(ref, () => ({
@@ -90,12 +91,10 @@ const Console = forwardRef(({ className }, ref) => {
ref={codeRef}
onScroll={handleScroll}
>
- {consoleText}
+
);
});
Console.displayName = 'Console';
-
-export default Console;
diff --git a/packages/project-builder-web/src/components/index.ts b/packages/project-builder-web/src/components/index.ts
index 5bc2fadfe..6e744d192 100644
--- a/packages/project-builder-web/src/components/index.ts
+++ b/packages/project-builder-web/src/components/index.ts
@@ -1,7 +1,9 @@
export { default as Alert } from './Alert';
export { default as AlertIcon } from './AlertIcon';
+export * from './AnsiText/AnsiText';
export * from './BlockerDialog/BlockerDialog';
export { default as Button } from './Button';
+export * from './Console/Console';
export { default as FormActionBar } from './FormActionBar';
export { default as FormError } from './FormError';
export { default as FormLabel } from './FormLabel';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1ed973e9f..91bf254b2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -660,9 +660,6 @@ importers:
packages/project-builder-web:
dependencies:
- '@cocalc/ansi-to-react':
- specifier: ^7.0.0
- version: 7.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@dnd-kit/core':
specifier: ^6.0.8
version: 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -711,6 +708,9 @@ importers:
'@types/react-dom':
specifier: 'catalog:'
version: 18.3.0
+ anser:
+ specifier: 2.3.2
+ version: 2.3.2
axios:
specifier: ^1.8.3
version: 1.8.3
@@ -1533,12 +1533,6 @@ packages:
'@changesets/write@0.4.0':
resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
- '@cocalc/ansi-to-react@7.0.0':
- resolution: {integrity: sha512-FOuHtOnuBtqTZSPR78Zg5w86/n+WJ/AOd0Y0PTh7Sx2TttyN3KjXRD8gSD8zEp1Ewf3Qv30tP3m8kNoPQa3lTw==}
- peerDependencies:
- react: ^16.3.2 || ^17.0.0 || ^18.0.0
- react-dom: ^16.3.2 || ^17.0.0 || ^18.0.0
-
'@csstools/color-helpers@5.0.1':
resolution: {integrity: sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==}
engines: {node: '>=18'}
@@ -3366,8 +3360,8 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
- anser@2.1.1:
- resolution: {integrity: sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ==}
+ anser@2.3.2:
+ resolution: {integrity: sha512-PMqBCBvrOVDRqLGooQb+z+t1Q0PiPyurUQeZRR5uHBOVZcW8B04KMmnT12USnhpNX2wCPagWzLVppQMUG3u0Dw==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
@@ -4120,9 +4114,6 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
- escape-carriage@1.3.1:
- resolution: {integrity: sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==}
-
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
@@ -7176,13 +7167,6 @@ snapshots:
human-id: 4.1.1
prettier: 2.8.8
- '@cocalc/ansi-to-react@7.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- anser: 2.1.1
- escape-carriage: 1.3.1
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
-
'@csstools/color-helpers@5.0.1': {}
'@csstools/css-calc@2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
@@ -9112,7 +9096,7 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
- anser@2.1.1: {}
+ anser@2.3.2: {}
ansi-colors@4.1.3: {}
@@ -9975,8 +9959,6 @@ snapshots:
escalade@3.2.0: {}
- escape-carriage@1.3.1: {}
-
escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {}