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
4 changes: 2 additions & 2 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
106 changes: 105 additions & 1 deletion docs/tui-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
});

Expand Down
90 changes: 90 additions & 0 deletions scripts/start-tui-harness.sh
Original file line number Diff line number Diff line change
@@ -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"
111 changes: 111 additions & 0 deletions src/tui-harness/__tests__/svg-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Terminal>;

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('<svg')).toBe(true);
expect(svg.endsWith('</svg>')).toBe(true);
expect(svg).toContain('Hello World');
expect(svg).toContain('<rect');
});

it('includes window chrome when showWindowChrome 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, { 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('<div>&"test"</div>');
await new Promise(resolve => setTimeout(resolve, 50));

const svg = renderTerminalToSvg(terminal);

expect(svg).toContain('&lt;div&gt;');
expect(svg).toContain('&amp;');
// Must not contain raw unescaped characters in the text content
expect(svg).not.toMatch(/<div>[^<]*&"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');
});
});
4 changes: 4 additions & 0 deletions src/tui-harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading