diff --git a/.mcp.json b/.mcp.json index 56668394..64fcbeca 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,8 @@ { "mcpServers": { "tui-harness": { - "command": "node", - "args": ["dist/mcp-harness/index.mjs"] + "type": "http", + "url": "http://127.0.0.1:24100/mcp" } } } diff --git a/docs/tui-harness.md b/docs/tui-harness.md index 0f3b7e10..4b925a0c 100644 --- a/docs/tui-harness.md +++ b/docs/tui-harness.md @@ -3,6 +3,77 @@ The TUI harness provides MCP tools for programmatically driving the AgentCore CLI terminal UI. The MCP server lives at `src/mcp-harness/` and the underlying library is at `src/test-utils/tui-harness/`. +## Running in HTTP Mode (Recommended) + +The TUI harness supports two transport modes: **HTTP** and **stdio**. HTTP mode is the recommended way to run the server +when using it from Claude Code. + +**Why HTTP mode?** In stdio mode, the MCP server runs inside Claude Code's sandbox, which blocks `posix_spawnp` -- the +system call that `node-pty` needs to spawn PTY processes. This means `tui_launch` will fail when the server is connected +via stdio. HTTP mode runs the server as an independent process outside the sandbox, so PTY spawning works normally. + +### Quick Start + +```bash +# Build the harness +npm run build:harness + +# Start the MCP server in HTTP mode (runs on port 24100) +node dist/mcp-harness/index.mjs --http + +# Or use the convenience script +./scripts/start-tui-harness.sh +``` + +### Configure Claude Code + +Add the HTTP server as an MCP source in Claude Code: + +```bash +# Add via CLI +claude mcp add --transport http -s project tui-harness http://127.0.0.1:24100/mcp +``` + +Or add directly to `.mcp.json`: + +```json +{ + "mcpServers": { + "tui-harness": { + "type": "http", + "url": "http://127.0.0.1:24100/mcp" + } + } +} +``` + +### Custom Port + +The default port is `24100`. Override it with the `--port` flag or the `MCP_HARNESS_PORT` environment variable: + +```bash +node dist/mcp-harness/index.mjs --http --port 3456 + +# Or via environment variable +MCP_HARNESS_PORT=3456 node dist/mcp-harness/index.mjs --http +``` + +### Verifying the Connection + +After starting the server and configuring Claude Code, verify the connection: + +```bash +claude mcp list +``` + +The output should show `tui-harness` with a connected status. + +### Note on Stdio Mode + +Stdio transport is still supported for backward compatibility. However, PTY process spawning will not work inside Claude +Code's sandbox -- `tui_launch` calls will fail with a spawn error. Stdio mode is useful for automated test pipelines +where the test runner directly communicates with the server and no sandbox restrictions apply. + ## Getting Started 1. Run `npm run build:harness` to compile both the CLI and the MCP harness binary. The harness is dev-only tooling and @@ -23,7 +94,8 @@ The TUI harness provides MCP tools for programmatically driving the AgentCore CL specific UI elements), `includeScrollback: true` includes lines scrolled above the viewport. - `tui_wait_for` -- Wait for text or a regex pattern to appear on screen. Returns `{found: false}` on timeout, NOT an error. -- `tui_screenshot` -- Capture a bordered screenshot with line numbers. +- `tui_screenshot` -- Capture a screenshot. Supports `format: 'text'` (bordered text with line numbers, the default) or + `format: 'svg'` (visual SVG render). Optional `savePath` to write to disk, `theme` to select color scheme. - `tui_close` -- Close a session and terminate the underlying process. - `tui_list_sessions` -- List all active sessions. @@ -52,6 +124,38 @@ The TUI harness provides MCP tools for programmatically driving the AgentCore CL The response also includes metadata: cursor position, terminal dimensions, buffer type, and timestamp. Use line numbers when referencing specific UI elements in your reasoning. +## SVG Screenshots + +`tui_screenshot` can render the terminal buffer as a visual SVG image. SVGs are self-contained (all fonts and styles are +inlined) with no external dependencies, making them safe to embed anywhere. + +### MCP Tool Usage + +``` +tui_screenshot({ sessionId: "abc", format: "svg", savePath: "./screenshot.svg" }) +tui_screenshot({ sessionId: "abc", format: "svg", theme: "light" }) +``` + +### Library Usage + +```typescript +const svg = session.screenshot(); +fs.writeFileSync('docs/screenshots/home-screen.svg', svg); +``` + +### Themes + +- **dark** (default) -- VS Code Dark+ colors. Best for terminal-style presentation. +- **light** -- GitHub-friendly palette. Best for docs and PR descriptions where the page background is white. + +### Embedding in Markdown + +SVG files render natively in GitHub markdown: + +```markdown +![Home Screen](docs/screenshots/home-screen.svg) +``` + ## Screen Identification Markers Use these stable text patterns with `tui_wait_for` to identify which screen is currently displayed. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index d47f25bb..35b7bcce 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -89,7 +89,9 @@ if (process.env.BUILD_HARNESS === '1' && fs.existsSync(mcpEntryPoint)) { // @xterm/headless is CJS-only (no ESM exports map) — esbuild's CJS-to-ESM // conversion mangles its default export at runtime, so let Node handle it. // fsevents is macOS-only optional native module. - external: ['fsevents', 'node-pty', '@xterm/headless'], + // express is CJS-heavy (deeply nested require chains) — let Node.js resolve + // it at runtime from node_modules instead of bundling. + external: ['fsevents', 'node-pty', '@xterm/headless', 'express'], plugins: [textLoaderPlugin], }); diff --git a/scripts/start-tui-harness.sh b/scripts/start-tui-harness.sh new file mode 100755 index 00000000..a32bd72a --- /dev/null +++ b/scripts/start-tui-harness.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# start-tui-harness.sh +# +# Convenience wrapper to build (if needed) and start the TUI harness MCP +# server over HTTP transport. +# +# Usage: +# ./scripts/start-tui-harness.sh # default port 24100 +# ./scripts/start-tui-harness.sh --port 8080 # custom port +# +# The server runs in the foreground so it can be stopped with Ctrl-C. +# --------------------------------------------------------------------------- + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Resolve project root (parent directory of this script's directory) +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# --------------------------------------------------------------------------- +# Parse optional --port argument (default: 24100) +# --------------------------------------------------------------------------- +PORT=24100 + +while [[ $# -gt 0 ]]; do + case "$1" in + --port) + if [[ -z "${2:-}" ]]; then + echo "Error: --port requires a value" >&2 + exit 1 + fi + PORT="$2" + shift 2 + ;; + --port=*) + PORT="${1#*=}" + shift + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: $0 [--port PORT]" >&2 + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Build the harness bundle if it doesn't exist yet +# --------------------------------------------------------------------------- +HARNESS_BUNDLE="$PROJECT_DIR/dist/mcp-harness/index.mjs" + +if [[ ! -f "$HARNESS_BUNDLE" ]]; then + echo "Harness bundle not found at $HARNESS_BUNDLE" + echo "Building with: npm run build:harness ..." + (cd "$PROJECT_DIR" && npm run build:harness) + echo "" +fi + +# --------------------------------------------------------------------------- +# Start the HTTP MCP server +# --------------------------------------------------------------------------- +echo "==========================================" +echo " TUI Harness MCP Server (HTTP transport)" +echo "==========================================" +echo "" +echo " URL: http://127.0.0.1:${PORT}/mcp" +echo "" +echo " Add this to your .mcp.json:" +echo "" +echo " {" +echo " \"mcpServers\": {" +echo " \"tui-harness\": {" +echo " \"type\": \"http\"," +echo " \"url\": \"http://127.0.0.1:${PORT}/mcp\"" +echo " }" +echo " }" +echo " }" +echo "" +echo " Press Ctrl-C to stop the server." +echo "==========================================" +echo "" + +# Export the port so the harness process can read it from the environment +export MCP_HTTP_PORT="$PORT" + +# Run the harness bundle as a foreground process +exec node "$HARNESS_BUNDLE" diff --git a/src/tui-harness/__tests__/svg-renderer.test.ts b/src/tui-harness/__tests__/svg-renderer.test.ts new file mode 100644 index 00000000..2b0717ca --- /dev/null +++ b/src/tui-harness/__tests__/svg-renderer.test.ts @@ -0,0 +1,111 @@ +/** + * Unit tests for the SVG renderer. + * + * Each test creates its own Terminal instance to avoid shared state. + * terminal.write() is async internally -- we wait 50ms after each write + * to give xterm a tick to process. + */ +import { DARK_THEME, LIGHT_THEME, renderTerminalToSvg } from '../lib/svg-renderer.js'; +import xtermHeadless from '@xterm/headless'; +import { afterEach, describe, expect, it } from 'vitest'; + +const { Terminal } = xtermHeadless; + +describe('SVG renderer', () => { + let terminal: InstanceType; + + afterEach(() => { + terminal?.dispose(); + }); + + it('renders a basic SVG from a terminal with text', async () => { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.write('Hello World'); + await new Promise(resolve => setTimeout(resolve, 50)); + + const svg = renderTerminalToSvg(terminal); + + expect(svg.startsWith('')).toBe(true); + expect(svg).toContain('Hello World'); + expect(svg).toContain(' { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.write('test'); + await new Promise(resolve => setTimeout(resolve, 50)); + + const svg = renderTerminalToSvg(terminal, { showWindowChrome: true }); + + expect(svg).toContain('#ff5f56'); + expect(svg).toContain('#ffbd2e'); + expect(svg).toContain('#27c93f'); + }); + + it('omits window chrome when showWindowChrome is false', async () => { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.write('test'); + await new Promise(resolve => setTimeout(resolve, 50)); + + const svg = renderTerminalToSvg(terminal, { showWindowChrome: false }); + + expect(svg).not.toContain('#ff5f56'); + expect(svg).not.toContain('#ffbd2e'); + expect(svg).not.toContain('#27c93f'); + }); + + it('uses light theme when specified', async () => { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.write('test'); + await new Promise(resolve => setTimeout(resolve, 50)); + + const svg = renderTerminalToSvg(terminal, { theme: LIGHT_THEME }); + + expect(svg).toContain('#ffffff'); + }); + + it('uses dark theme by default', async () => { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.write('test'); + await new Promise(resolve => setTimeout(resolve, 50)); + + const svg = renderTerminalToSvg(terminal); + + expect(svg).toContain('#1e1e1e'); + }); + + it('includes cursor when showCursor is true', async () => { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.write('test'); + await new Promise(resolve => setTimeout(resolve, 50)); + + const svg = renderTerminalToSvg(terminal, { showCursor: true }); + + expect(svg).toContain(DARK_THEME.cursor); + expect(svg).toContain('opacity="0.7"'); + }); + + it('handles special XML characters', async () => { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.write('
&"test"
'); + await new Promise(resolve => setTimeout(resolve, 50)); + + const svg = renderTerminalToSvg(terminal); + + expect(svg).toContain('<div>'); + expect(svg).toContain('&'); + // Must not contain raw unescaped characters in the text content + expect(svg).not.toMatch(/
[^<]*&"test"[^<]*<\/div>/); + }); + + it('respects custom title in window chrome', async () => { + terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + terminal.write('test'); + await new Promise(resolve => setTimeout(resolve, 50)); + + const svg = renderTerminalToSvg(terminal, { title: 'My Terminal' }); + + expect(svg).toContain('My Terminal'); + }); +}); diff --git a/src/tui-harness/index.ts b/src/tui-harness/index.ts index 1bf2a415..7f7be3a3 100644 --- a/src/tui-harness/index.ts +++ b/src/tui-harness/index.ts @@ -29,3 +29,7 @@ export { closeAll } from './lib/session-manager.js'; // --- Test helpers --- export { createMinimalProjectDir } from './helpers.js'; export type { CreateMinimalProjectDirOptions, MinimalProjectDirResult } from './helpers.js'; + +// --- SVG Screenshots --- +export { renderTerminalToSvg, DARK_THEME, LIGHT_THEME } from './lib/svg-renderer.js'; +export type { SvgRenderOptions, SvgTheme } from './lib/svg-renderer.js'; diff --git a/src/tui-harness/lib/svg-renderer.ts b/src/tui-harness/lib/svg-renderer.ts new file mode 100644 index 00000000..b30dc72f --- /dev/null +++ b/src/tui-harness/lib/svg-renderer.ts @@ -0,0 +1,467 @@ +/** + * SVG renderer for converting an @xterm/headless Terminal buffer into a visual + * SVG screenshot. + * + * This module walks the terminal buffer cell-by-cell, resolves colors and text + * attributes, groups consecutive cells with identical styles into spans, and + * emits a self-contained SVG document. The output follows the visual style + * pioneered by Rich and Textual (Python TUI projects): a monospace grid with + * colored text and backgrounds, optional macOS-style window chrome, and a + * blinking cursor overlay. + * + * The SVG is fully self-contained with no external dependencies, making it + * safe to embed in GitHub markdown via an `` tag. + */ +import xtermHeadless from '@xterm/headless'; + +const { Terminal } = xtermHeadless; +type Terminal = InstanceType; + +/** + * Derived type for an xterm buffer cell. The `IBufferCell` interface is not + * directly exported from `@xterm/headless`, so we derive it from the return + * type of `IBuffer.getNullCell()`. + */ +type BufferCell = ReturnType; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Color theme for SVG rendering. + * + * @property name - Human-readable theme name. + * @property background - Default background color (hex). + * @property foreground - Default foreground/text color (hex). + * @property cursor - Cursor overlay color (hex). + * @property palette - 16-color ANSI palette (indices 0-15). Colors 0-7 are + * the standard colors; 8-15 are their bright variants. + */ +export interface SvgTheme { + name: string; + background: string; + foreground: string; + cursor: string; + palette: string[]; +} + +/** + * Options for controlling the SVG rendering output. + * + * @property theme - Color theme. Defaults to {@link DARK_THEME}. + * @property fontSize - Font size in pixels. Defaults to 14. + * @property fontFamily - CSS font-family string. Defaults to + * `'Menlo, Monaco, Courier New, monospace'`. + * @property showCursor - Whether to draw a cursor rectangle. Defaults to true. + * @property showWindowChrome - Whether to draw a macOS-style title bar with + * colored dots. Defaults to true. + * @property title - Title text shown in the window chrome. + * @property padding - Padding around the terminal content in pixels. Defaults + * to 10. + * @property borderRadius - Border radius for the outer rectangle. Defaults to 8. + */ +export interface SvgRenderOptions { + theme?: SvgTheme; + fontSize?: number; + fontFamily?: string; + showCursor?: boolean; + showWindowChrome?: boolean; + title?: string; + padding?: number; + borderRadius?: number; +} + +// --------------------------------------------------------------------------- +// Built-in themes +// --------------------------------------------------------------------------- + +/** Dark theme inspired by VS Code Dark+. */ +export const DARK_THEME: SvgTheme = { + name: 'dark', + background: '#1e1e1e', + foreground: '#d4d4d4', + cursor: '#aeafad', + palette: [ + '#000000', + '#cd3131', + '#0dbc79', + '#e5e510', + '#2472c8', + '#bc3fbc', + '#11a8cd', + '#e5e5e5', + '#666666', + '#f14c4c', + '#23d18b', + '#f5f543', + '#3b8eea', + '#d670d6', + '#29b8db', + '#e5e5e5', + ], +}; + +/** Light theme with GitHub-friendly colors. */ +export const LIGHT_THEME: SvgTheme = { + name: 'light', + background: '#ffffff', + foreground: '#24292e', + cursor: '#044289', + palette: [ + '#24292e', + '#cf222e', + '#116329', + '#4d2d00', + '#0550ae', + '#8250df', + '#1b7c83', + '#6e7781', + '#57606a', + '#a40e26', + '#1a7f37', + '#633c01', + '#0969da', + '#8250df', + '#3192aa', + '#8c959f', + ], +}; + +// --------------------------------------------------------------------------- +// ANSI 256-color palette +// --------------------------------------------------------------------------- + +/** + * Convert an ANSI 256-color palette index (16-255) to a hex color string. + * + * - Indices 16-231: 6x6x6 color cube. The index maps to RGB components via + * `16 + 36*r + 6*g + b` where r, g, b are in [0, 5]. Each component maps + * to a brightness value in [0, 55, 95, 135, 175, 215, 255]. + * - Indices 232-255: Grayscale ramp from dark (#080808) to light (#eeeeee). + * + * @param index - A palette index in the range 16-255. + * @returns A hex color string like `#af00d7`. + */ +function ansi256ToHex(index: number): string { + if (index >= 232) { + // Grayscale ramp: 24 shades from 8 to 238 + const gray = (index - 232) * 10 + 8; + return `#${gray.toString(16).padStart(2, '0').repeat(3)}`; + } + + // 6x6x6 color cube + const cubeIndex = index - 16; + const b = cubeIndex % 6; + const g = Math.floor(cubeIndex / 6) % 6; + const r = Math.floor(cubeIndex / 36); + + const CUBE_VALUES = [0, 0x5f, 0x87, 0xaf, 0xd7, 0xff]; + const rv = CUBE_VALUES[r]!; + const gv = CUBE_VALUES[g]!; + const bv = CUBE_VALUES[b]!; + + return '#' + rv.toString(16).padStart(2, '0') + gv.toString(16).padStart(2, '0') + bv.toString(16).padStart(2, '0'); +} + +// --------------------------------------------------------------------------- +// Color resolution +// --------------------------------------------------------------------------- + +/** + * Resolve the foreground or background color of a buffer cell to a hex string. + * + * Returns `null` when the cell uses the default color mode, signaling that the + * theme's default foreground or background should be used instead. + * + * @param cell - The buffer cell to inspect. + * @param type - Whether to resolve the `'fg'` (foreground) or `'bg'` (background) color. + * @param theme - The active theme, used for palette indices 0-15. + * @returns A hex color string, or `null` for the default color. + */ +function resolveColor(cell: BufferCell, type: 'fg' | 'bg', theme: SvgTheme): string | null { + const isDefault = type === 'fg' ? cell.isFgDefault() : cell.isBgDefault(); + if (isDefault) return null; + + const isPalette = type === 'fg' ? cell.isFgPalette() : cell.isBgPalette(); + const isRGB = type === 'fg' ? cell.isFgRGB() : cell.isBgRGB(); + const colorValue = type === 'fg' ? cell.getFgColor() : cell.getBgColor(); + + if (isPalette) { + if (colorValue < 16) return theme.palette[colorValue] ?? null; + return ansi256ToHex(colorValue); + } + + if (isRGB) { + const rr = ((colorValue >> 16) & 0xff).toString(16).padStart(2, '0'); + const gg = ((colorValue >> 8) & 0xff).toString(16).padStart(2, '0'); + const bb = (colorValue & 0xff).toString(16).padStart(2, '0'); + return `#${rr}${gg}${bb}`; + } + + return null; +} + +// --------------------------------------------------------------------------- +// XML escaping +// --------------------------------------------------------------------------- + +/** + * Escape special XML characters so that text content is safe to embed in SVG. + * + * @param text - Raw text string. + * @returns The text with `&`, `<`, `>`, and `"` replaced by XML entities. + */ +function escapeXml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// --------------------------------------------------------------------------- +// Span grouping types +// --------------------------------------------------------------------------- + +/** Style properties for a contiguous run of characters on a single row. */ +interface CellStyle { + fg: string | null; + bg: string | null; + bold: boolean; + italic: boolean; + dim: boolean; + underline: boolean; +} + +/** A contiguous run of characters sharing the same style. */ +interface Span { + text: string; + style: CellStyle; + startCol: number; + colWidth: number; +} + +// --------------------------------------------------------------------------- +// Main renderer +// --------------------------------------------------------------------------- + +/** + * Render an xterm Terminal buffer as a self-contained SVG string. + * + * The renderer walks every visible cell in the terminal's active buffer, + * groups consecutive characters with identical styling into spans, and emits + * background rectangles and text elements. The resulting SVG uses only inline + * styles and embedded CSS classes, making it safe for GitHub markdown embedding. + * + * @param terminal - An xterm Terminal instance with `allowProposedApi` enabled. + * @param options - Optional rendering configuration. + * @returns A complete SVG document as a string. + */ +export function renderTerminalToSvg(terminal: Terminal, options?: SvgRenderOptions): string { + const theme = options?.theme ?? DARK_THEME; + const fontSize = options?.fontSize ?? 14; + const fontFamily = options?.fontFamily ?? 'Menlo, Monaco, Courier New, monospace'; + const showCursor = options?.showCursor ?? true; + const showWindowChrome = options?.showWindowChrome ?? true; + const title = options?.title ?? ''; + const padding = options?.padding ?? 10; + const borderRadius = options?.borderRadius ?? 8; + + // Monospace character metrics (approximate for common monospace fonts) + const charWidth = fontSize * 0.6; + const lineHeight = Math.ceil(fontSize * 1.3); + const textBaseline = Math.ceil(fontSize * 1.0); + + const buffer = terminal.buffer.active; + const cols = terminal.cols; + const rows = terminal.rows; + const startRow = buffer.baseY; + + // Chrome dimensions + const chromeHeight = showWindowChrome ? 30 : 0; + + // Total SVG dimensions + const contentWidth = cols * charWidth; + const contentHeight = rows * lineHeight; + const totalWidth = contentWidth + padding * 2; + const totalHeight = contentHeight + padding * 2 + chromeHeight; + + // Cursor position + const cursorX = buffer.cursorX; + const cursorY = buffer.cursorY; + + // ----------------------------------------------------------------------- + // Pass 1: Walk the buffer and group cells into styled spans + // ----------------------------------------------------------------------- + + const rowSpans: Span[][] = []; + const reusableCell = buffer.getNullCell(); + + for (let row = 0; row < rows; row++) { + const lineIndex = startRow + row; + const line = buffer.getLine(lineIndex); + const spans: Span[] = []; + + if (!line) { + rowSpans.push(spans); + continue; + } + + let currentSpan: Span | null = null; + + for (let col = 0; col < cols; col++) { + const cell = line.getCell(col, reusableCell); + if (!cell) continue; + + const width = cell.getWidth(); + + // Skip trailing half of wide characters (width === 0) + if (width === 0) continue; + + const chars = cell.getChars() || ' '; + const fg = resolveColor(cell, 'fg', theme); + const bg = resolveColor(cell, 'bg', theme); + const bold = cell.isBold() !== 0; + const italic = cell.isItalic() !== 0; + const dim = cell.isDim() !== 0; + const underline = cell.isUnderline() !== 0; + + const style: CellStyle = { fg, bg, bold, italic, dim, underline }; + + // Check if this cell continues the current span + if ( + currentSpan?.style.fg === style.fg && + currentSpan.style.bg === style.bg && + currentSpan.style.bold === style.bold && + currentSpan.style.italic === style.italic && + currentSpan.style.dim === style.dim && + currentSpan.style.underline === style.underline + ) { + currentSpan.text += chars; + currentSpan.colWidth += width; + } else { + if (currentSpan) { + spans.push(currentSpan); + } + currentSpan = { + text: chars, + style, + startCol: col, + colWidth: width, + }; + } + } + + if (currentSpan) { + spans.push(currentSpan); + } + + rowSpans.push(spans); + } + + // ----------------------------------------------------------------------- + // Pass 2: Emit SVG + // ----------------------------------------------------------------------- + + const svgParts: string[] = []; + + // SVG header + svgParts.push( + `` + ); + + // Embedded styles + svgParts.push(''); + + // Background rectangle + svgParts.push(``); + + // Window chrome + if (showWindowChrome) { + // Three macOS-style dots + svgParts.push(``); + svgParts.push(``); + svgParts.push(``); + + // Title text + if (title) { + svgParts.push( + `${escapeXml(title)}` + ); + } + + // Separator line between chrome and content + svgParts.push( + `` + ); + } + + // Terminal content group + svgParts.push(``); + + for (let row = 0; row < rowSpans.length; row++) { + const spans = rowSpans[row]!; + const y = row * lineHeight; + + // Emit background rectangles for spans with non-default backgrounds + for (const span of spans) { + if (span.style.bg !== null) { + const x = span.startCol * charWidth; + const w = span.colWidth * charWidth; + svgParts.push(``); + } + } + + // Emit text spans — skip rows that are entirely spaces with default style + const hasVisibleText = spans.some(s => s.text.trim().length > 0 || s.style.fg !== null); + if (!hasVisibleText) continue; + + const textY = y + textBaseline; + svgParts.push(``); + + for (const span of spans) { + const x = span.startCol * charWidth; + const attrs: string[] = [`x="${x}"`]; + + if (span.style.fg !== null) { + attrs.push(`fill="${span.style.fg}"`); + } + + // Build class list for text attributes + const classes: string[] = []; + if (span.style.bold) classes.push('b'); + if (span.style.italic) classes.push('i'); + if (span.style.dim) classes.push('d'); + if (span.style.underline) classes.push('u'); + if (classes.length > 0) { + attrs.push(`class="${classes.join(' ')}"`); + } + + svgParts.push(`${escapeXml(span.text)}`); + } + + svgParts.push(''); + } + + svgParts.push(''); + + // Cursor overlay + if (showCursor && cursorX < cols && cursorY < rows) { + const cx = padding + cursorX * charWidth; + const cy = chromeHeight + padding + cursorY * lineHeight; + svgParts.push( + `` + ); + } + + // Close SVG + svgParts.push(''); + + return svgParts.join('\n'); +} diff --git a/src/tui-harness/lib/tui-session.ts b/src/tui-harness/lib/tui-session.ts index 86517e0d..21c73496 100644 --- a/src/tui-harness/lib/tui-session.ts +++ b/src/tui-harness/lib/tui-session.ts @@ -15,6 +15,8 @@ import { resolveKey } from './key-map.js'; import { buildScreenState, getBufferType } from './screen.js'; import { register, unregister } from './session-manager.js'; import { SettlingMonitor } from './settling.js'; +import { renderTerminalToSvg } from './svg-renderer.js'; +import type { SvgRenderOptions } from './svg-renderer.js'; import type { CloseResult, LaunchOptions, ReadOptions, ScreenState, SessionInfo, SpecialKey } from './types.js'; import { LaunchError, WaitForTimeoutError } from './types.js'; import xtermHeadless from '@xterm/headless'; @@ -286,6 +288,22 @@ export class TuiSession { return buildScreenState(this.terminal, options); } + /** + * Render the current terminal screen as a self-contained SVG string. + * + * Walks the terminal buffer cell-by-cell, resolves colors and text + * attributes, and produces an SVG document suitable for embedding in + * markdown or saving to a file. + * + * @param options - Optional SVG rendering configuration (theme, font size, etc.). + * @returns A complete SVG document as a string. + * @throws {Error} If the session is no longer alive. + */ + screenshot(options?: SvgRenderOptions): string { + this.assertAlive(); + return renderTerminalToSvg(this.terminal, options); + } + // --------------------------------------------------------------------------- // Input methods // --------------------------------------------------------------------------- diff --git a/src/tui-harness/mcp/http-server.ts b/src/tui-harness/mcp/http-server.ts new file mode 100644 index 00000000..5dd0fb5e --- /dev/null +++ b/src/tui-harness/mcp/http-server.ts @@ -0,0 +1,325 @@ +/** + * HTTP transport module for the TUI harness MCP server. + * + * Exposes the MCP server over Streamable HTTP on a single `/mcp` endpoint. + * Each inbound `initialize` request creates a new stateful session with its + * own `McpServer` instance (via `createServer()`) backed by a dedicated + * `StreamableHTTPServerTransport`. Subsequent requests are routed to the + * correct transport using the `mcp-session-id` header. + * + * Supported HTTP methods on `/mcp`: + * POST — Handles `initialize` (creates a new session) and all subsequent + * JSON-RPC requests (routed by session ID). + * GET — Opens an SSE stream for server-initiated messages. + * DELETE — Terminates a session and cleans up its resources. + * + * The server binds to `127.0.0.1` only (no external access) and registers + * `SIGTERM`/`SIGINT` handlers for graceful shutdown. + */ +import { closeAllSessions, createServer } from './server.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import { randomUUID } from 'node:crypto'; +import { createServer as createHttpServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A tracked MCP session with its transport and server instances. */ +interface ManagedSession { + transport: StreamableHTTPServerTransport; + server: McpServer; +} + +// --------------------------------------------------------------------------- +// Session tracking +// --------------------------------------------------------------------------- + +/** + * Active HTTP sessions keyed by the `mcp-session-id` header value. + * + * Each session owns a unique `McpServer` + `StreamableHTTPServerTransport` + * pair. The underlying `McpServer` shares the module-scoped PTY session pool + * in `server.ts`, which is the desired behavior — all MCP server instances + * operate on the same set of TUI sessions. + */ +const sessions = new Map(); + +// --------------------------------------------------------------------------- +// Request body parsing +// --------------------------------------------------------------------------- + +/** + * Read the full request body and parse it as JSON. + * + * Returns `undefined` for empty bodies (e.g. GET/DELETE requests) or when + * the Content-Type is not `application/json`. + */ +function parseJsonBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const contentType = req.headers['content-type'] ?? ''; + if (!contentType.includes('application/json')) { + resolve(undefined); + return; + } + + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + if (raw.length === 0) { + resolve(undefined); + return; + } + try { + resolve(JSON.parse(raw)); + } catch (err) { + reject(new Error(`Failed to parse JSON body: ${err instanceof Error ? err.message : String(err)}`)); + } + }); + req.on('error', (err: Error) => { + reject(err); + }); + }); +} + +// --------------------------------------------------------------------------- +// Route handlers +// --------------------------------------------------------------------------- + +/** + * Handle POST /mcp — JSON-RPC requests. + * + * If the body is an `initialize` request, a new session is created with a + * fresh `McpServer` and `StreamableHTTPServerTransport`. Otherwise the + * request is routed to the existing session identified by `mcp-session-id`. + */ +async function handlePost(req: IncomingMessage, res: ServerResponse): Promise { + const body = await parseJsonBody(req); + + // Determine if this is an initialize request so we can create a new session. + const sessionId = req.headers['mcp-session-id']; + + if (isInitializeRequest(body)) { + // Create a new transport and server for this session. + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + + const server = createServer(); + await server.connect(transport); + + // Process the initialize request first — the transport assigns the session + // ID during handleRequest(), not at construction time. + await transport.handleRequest(req, res, body); + + // Now the session ID is available. + const transportSessionId = transport.sessionId; + if (transportSessionId !== undefined) { + const managed: ManagedSession = { transport, server }; + sessions.set(transportSessionId, managed); + + // Clean up when the transport closes (client disconnect, errors, etc.). + transport.onclose = () => { + sessions.delete(transportSessionId); + }; + + transport.onerror = (error: Error) => { + process.stderr.write(`[mcp-http] Transport error (session ${transportSessionId}): ${error.message}\n`); + }; + } + + return; + } + + // Non-initialize request — route to the existing session. + if (typeof sessionId !== 'string' || sessionId.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing mcp-session-id header.' })); + return; + } + + const managed = sessions.get(sessionId); + if (!managed) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found. It may have expired or been closed.' })); + return; + } + + await managed.transport.handleRequest(req, res, body); +} + +/** + * Handle GET /mcp — SSE stream for server-initiated messages. + * + * The client must include an `mcp-session-id` header to identify the session. + */ +async function handleGet(req: IncomingMessage, res: ServerResponse): Promise { + const sessionId = req.headers['mcp-session-id']; + + if (typeof sessionId !== 'string' || sessionId.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing mcp-session-id header.' })); + return; + } + + const managed = sessions.get(sessionId); + if (!managed) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found. It may have expired or been closed.' })); + return; + } + + await managed.transport.handleRequest(req, res); +} + +/** + * Handle DELETE /mcp — session termination. + * + * Closes the transport and removes the session from the tracking map. + */ +async function handleDelete(req: IncomingMessage, res: ServerResponse): Promise { + const sessionId = req.headers['mcp-session-id']; + + if (typeof sessionId !== 'string' || sessionId.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing mcp-session-id header.' })); + return; + } + + const managed = sessions.get(sessionId); + if (!managed) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found. It may have expired or been closed.' })); + return; + } + + // Let the transport handle the DELETE request (it sends the response). + await managed.transport.handleRequest(req, res); + + // Clean up the session after the transport has processed the request. + sessions.delete(sessionId); + await managed.server.close(); +} + +// --------------------------------------------------------------------------- +// Request dispatcher +// --------------------------------------------------------------------------- + +/** + * Dispatch an incoming HTTP request to the appropriate handler. + * + * Extracted as a non-async function to satisfy Node's `createServer` callback + * signature, which expects `(req, res) => void`. + */ +function dispatch(req: IncomingMessage, res: ServerResponse): void { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + + // Only serve the /mcp endpoint. + if (url.pathname !== '/mcp') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found. The MCP endpoint is at /mcp.' })); + return; + } + + const handleRequest = async (): Promise => { + switch (req.method) { + case 'POST': + await handlePost(req, res); + break; + case 'GET': + await handleGet(req, res); + break; + case 'DELETE': + await handleDelete(req, res); + break; + default: + res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'GET, POST, DELETE' }); + res.end(JSON.stringify({ error: `Method ${req.method} not allowed.` })); + break; + } + }; + + handleRequest().catch((err: unknown) => { + // Guard against double-sending headers if the response is already in progress. + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error.' })); + } + process.stderr.write(`[mcp-http] Request error: ${err instanceof Error ? err.message : String(err)}\n`); + }); +} + +// --------------------------------------------------------------------------- +// Server lifecycle +// --------------------------------------------------------------------------- + +/** + * Start the MCP HTTP server on the specified port. + * + * The server listens on `127.0.0.1` (localhost only) and routes all traffic + * through the `/mcp` endpoint. Registers `SIGTERM` and `SIGINT` handlers + * for graceful shutdown. + * + * @param port - The TCP port to bind to. + */ +export async function startHttpServer(port: number): Promise { + const httpServer = createHttpServer(dispatch); + + // --- Graceful shutdown --- + + const shutdown = async (): Promise => { + process.stderr.write('[mcp-http] Shutting down...\n'); + + // Close all transports so in-flight SSE streams terminate cleanly. + const closePromises = Array.from(sessions.values()).map(async managed => { + try { + await managed.transport.close(); + await managed.server.close(); + } catch { + // Best-effort cleanup — swallow errors from already-closed transports. + } + }); + await Promise.allSettled(closePromises); + sessions.clear(); + + // Close all PTY sessions managed by the server module. + await closeAllSessions(); + + // Close the HTTP server itself. + httpServer.close(); + process.exit(0); + }; + + process.on('SIGTERM', () => { + void shutdown(); + }); + process.on('SIGINT', () => { + void shutdown(); + }); + + // --- Start listening --- + + return new Promise((resolve, reject) => { + httpServer.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + reject( + new Error( + `Port ${port} is already in use. ` + + 'Try a different port with --port or MCP_HARNESS_PORT=.' + ) + ); + } else { + reject(err); + } + }); + + httpServer.listen(port, '127.0.0.1', () => { + process.stderr.write(`[mcp-http] MCP server listening at http://127.0.0.1:${port}/mcp\n`); + resolve(); + }); + }); +} diff --git a/src/tui-harness/mcp/index.ts b/src/tui-harness/mcp/index.ts index 7a3828fa..a50ad600 100644 --- a/src/tui-harness/mcp/index.ts +++ b/src/tui-harness/mcp/index.ts @@ -1,7 +1,79 @@ +import { startHttpServer } from './http-server.js'; import { closeAllSessions, createServer } from './server.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +const DEFAULT_PORT = 24100; +const MIN_PORT = 1024; +const MAX_PORT = 65535; + +interface TransportConfig { + mode: 'stdio' | 'http'; + port: number; +} + +/** + * Parses CLI arguments and environment variables to determine transport mode and port. + * + * Priority: CLI flags take precedence over environment variables. + * + * Flags: + * --http Use HTTP transport instead of stdio + * --port Port for HTTP transport (default: 24100) + * + * Environment variables: + * MCP_HARNESS_TRANSPORT Set to 'http' for HTTP mode + * MCP_HARNESS_PORT Port for HTTP transport + */ +function parseArgs(): TransportConfig { + const args = process.argv.slice(2); + + // Determine mode from CLI flag, then env var, defaulting to stdio + const httpFlagPresent = args.includes('--http'); + const envTransport = process.env.MCP_HARNESS_TRANSPORT; + const mode: 'stdio' | 'http' = httpFlagPresent || envTransport === 'http' ? 'http' : 'stdio'; + + // Determine port from CLI flag, then env var, defaulting to DEFAULT_PORT + let port = DEFAULT_PORT; + + const portFlagIndex = args.indexOf('--port'); + if (portFlagIndex !== -1) { + const portArg = args[portFlagIndex + 1]; + if (portArg === undefined) { + console.error('Error: --port flag requires a value.'); + process.exit(1); + } + port = parsePort(portArg); + } else if (process.env.MCP_HARNESS_PORT !== undefined) { + port = parsePort(process.env.MCP_HARNESS_PORT); + } + + return { mode, port }; +} + +/** + * Parses and validates a port string. Exits with an error if the value is not + * a valid integer in the range 1024-65535. + */ +function parsePort(value: string): number { + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < MIN_PORT || parsed > MAX_PORT) { + console.error(`Error: Invalid port "${value}". Must be an integer between ${MIN_PORT} and ${MAX_PORT}.`); + process.exit(1); + } + + return parsed; +} + async function main(): Promise { + const { mode, port } = parseArgs(); + + if (mode === 'http') { + await startHttpServer(port); + return; + } + + // Stdio mode (default) const server = createServer(); const transport = new StdioServerTransport(); diff --git a/src/tui-harness/mcp/server.ts b/src/tui-harness/mcp/server.ts index ff11d1df..d9c5ca4a 100644 --- a/src/tui-harness/mcp/server.ts +++ b/src/tui-harness/mcp/server.ts @@ -8,7 +8,7 @@ * tui_send_keys - Send keystrokes (text or special keys) * tui_read_screen - Read the current terminal screen * tui_wait_for - Wait for a pattern to appear on screen - * tui_screenshot - Capture a bordered, numbered screenshot + * tui_screenshot - Capture a bordered, numbered screenshot (text or SVG) * tui_close - Close a session and terminate its process * tui_list_sessions - List all active sessions * @@ -16,10 +16,11 @@ * McpServer.registerTool(). This module owns the runtime dispatch logic that * maps tool calls to TuiSession methods. */ -import { LaunchError, TuiSession, WaitForTimeoutError, closeAll } from '../index.js'; -import type { SpecialKey } from '../index.js'; +import { DARK_THEME, LIGHT_THEME, LaunchError, TuiSession, WaitForTimeoutError, closeAll } from '../index.js'; +import type { SpecialKey, SvgRenderOptions } from '../index.js'; import { LAUNCH_DEFAULTS, SPECIAL_KEY_ENUM, TOOL_NAMES } from './tools.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { writeFileSync } from 'fs'; import { z } from 'zod'; // --------------------------------------------------------------------------- @@ -248,21 +249,57 @@ async function handleWaitFor(args: { sessionId: string; pattern: string; timeout /** * Handle the `tui_screenshot` tool call. * - * Captures the current screen with line numbers and renders it inside a - * Unicode-bordered box for easy visual inspection. + * Captures the current screen in the requested format: + * - `'text'` (default): line-numbered, Unicode-bordered text for visual inspection. + * - `'svg'`: a self-contained SVG document rendered via the session's screenshot method. + * + * When `savePath` is provided, the screenshot content is also written to disk. */ -function handleScreenshot(args: { sessionId: string }) { +function handleScreenshot(args: { + sessionId: string; + format?: 'text' | 'svg'; + theme?: 'dark' | 'light'; + savePath?: string; +}) { const { sessionId } = args; const session = getSession(sessionId); if (!session) { return errorResponse(`Session not found: ${sessionId}`); } + const format = args.format ?? 'text'; + try { - const screen = session.readScreen({ numbered: true }); + const screen = session.readScreen({ numbered: format === 'text' }); const { dimensions, cursor, bufferType } = screen; + const metadata = { + cursor, + dimensions, + bufferType, + timestamp: new Date().toISOString(), + }; + + if (format === 'svg') { + // Build SVG render options from the theme parameter. + const svgOptions: SvgRenderOptions = { + theme: args.theme === 'light' ? LIGHT_THEME : DARK_THEME, + }; + + const svg = session.screenshot(svgOptions); + + if (args.savePath) { + writeFileSync(args.savePath, svg, 'utf-8'); + } - // Build the bordered screenshot. + return jsonResponse({ + format: 'svg', + svg, + ...(args.savePath ? { savePath: args.savePath } : {}), + metadata, + }); + } + + // Default: text format -- bordered screenshot with line numbers. const header = `TUI Screenshot (${dimensions.cols}x${dimensions.rows})`; const topBorder = `\u250C\u2500 ${header} ${'\u2500'.repeat(Math.max(0, dimensions.cols - header.length - 4))}\u2510`; const bottomBorder = `\u2514${'\u2500'.repeat(Math.max(0, dimensions.cols + 2))}\u2518`; @@ -271,14 +308,15 @@ function handleScreenshot(args: { sessionId: string }) { const screenshot = `${topBorder}\n${body}\n${bottomBorder}`; + if (args.savePath) { + writeFileSync(args.savePath, screenshot, 'utf-8'); + } + return jsonResponse({ + format: 'text', screenshot, - metadata: { - cursor, - dimensions, - bufferType, - timestamp: new Date().toISOString(), - }, + ...(args.savePath ? { savePath: args.savePath } : {}), + metadata, }); } catch (err) { return errorResponse( @@ -457,9 +495,27 @@ export function createServer(): McpServer { server.registerTool( TOOL_NAMES.SCREENSHOT, { - description: 'Capture a formatted screenshot of the terminal with line numbers and borders for debugging.', + description: + 'Capture a screenshot of the terminal. Supports text format (bordered, line-numbered) ' + + 'or SVG format (rendered visual screenshot). Optionally saves the output to disk.', inputSchema: { sessionId: z.string().describe('The session ID returned by tui_launch.'), + format: z + .enum(['text', 'svg']) + .optional() + .describe( + 'Output format. "text" returns a bordered text screenshot; "svg" returns a self-contained SVG document (default: "text").' + ), + theme: z + .enum(['dark', 'light']) + .optional() + .describe('Color theme for SVG rendering. Ignored when format is "text" (default: "dark").'), + savePath: z + .string() + .optional() + .describe( + 'Absolute file path to write the screenshot content to disk. The file is written in UTF-8 encoding.' + ), }, annotations: { readOnlyHint: true,