feat: add terminal-capture for CLI screenshot automation

- Add terminal-capture engine using node-pty + xterm.js + Playwright
- Add scenario runner with TypeScript configuration
- Add pre-built scenarios (/about, /context, /export, /auth)
- Add Cursor skills for terminal-capture and pr-review workflow
- Add motivation documentation

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
pomelo-nwu 2026-02-14 21:34:42 +08:00
parent 03c3889991
commit 997fcbfaed
10 changed files with 1758 additions and 1 deletions

4
.gitignore vendored
View file

@ -51,7 +51,7 @@ packages/core/src/generated/
packages/vscode-ide-companion/*.vsix
# Qwen Code Configs
.qwen/
logs/
# GHA credentials
gha-creds-*.json
@ -70,6 +70,8 @@ __pycache__/
integration-tests/concurrent-runner/output/
integration-tests/concurrent-runner/task-*
integration-tests/terminal-capture/scenarios/screenshots/
# storybook
*storybook.log
storybook-static

View file

@ -0,0 +1,104 @@
---
name: pr-review
description: Reviews pull requests with code analysis and terminal smoke testing. Applies when examining code changes, running CLI tests, or when 'PR review', 'code review', 'terminal screenshot', 'visual test' is mentioned.
---
# PR Review — Code Review + Terminal Smoke Testing
## Workflow
### 1. Fetch PR Information
```bash
# List open PRs
gh pr list
# View PR details
gh pr view <number>
# Get diff
gh pr diff <number>
```
### 2. Code Review
Analyze changes across the following dimensions:
- **Correctness** — Is the logic correct? Are edge cases handled?
- **Code Style** — Does it follow existing code style and conventions?
- **Performance** — Are there any performance concerns?
- **Test Coverage** — Are there corresponding tests for the changes?
- **Security** — Does it introduce any security risks?
Output format:
- 🔴 **Critical** — Must fix
- 🟡 **Suggestion** — Suggested improvement
- 🟢 **Nice to have** — Optional optimization
### 3. Terminal Smoke Testing (Run for Every PR)
**Run terminal-capture for every PR review**, not just UI changes. Reasons:
- **Smoke Test** — Verify the CLI starts correctly and responds to user input, ensuring the PR didn't break anything
- **Visual Verification** — If there are UI changes, screenshots provide the most intuitive review evidence
- **Documentation** — Attach screenshots to the PR comments so reviewers can see the results without building locally
```bash
# Checkout branch & build
gh pr checkout <number>
npm run build
```
#### Scenario Selection Strategy
Choose appropriate scenarios based on the PR's scope of changes:
| PR Type | Recommended Scenarios | Description |
| ------------------------------------- | ------------------------------------------------------------ | --------------------------------- |
| **Any PR** (default) | smoke test: send `hi`, verify startup & response | Minimal-cost smoke validation |
| Slash command changes | Corresponding command scenarios (`/about`, `/context`, etc.) | Verify command output correctness |
| Ink component / layout changes | Multiple scenarios + full-flow long screenshot | Verify visual effects |
| Large refactors / dependency upgrades | Run `scenarios/all.ts` fully | Full regression |
#### Running Screenshots
```bash
# Write scenario config to integration-tests/terminal-capture/scenarios/
# See terminal-capture skill for FlowStep API reference
# Single scenario
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/<scenario>.ts
# Check output in screenshots/ directory
```
#### Minimal Smoke Test Example
No need to write a new scenario file — just use the existing `about.ts`. It sends "hi" then runs `/about`, covering startup + input + command response:
```bash
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
```
### 4. Upload Screenshots to PR
Use Playwright MCP browser to upload screenshots to the PR comments (images hosted at `github.com/user-attachments/assets/`, zero side effects):
1. Open the PR page with Playwright: `https://github.com/<repo>/pull/<number>`
2. Click the comment text box and enter a comment title (e.g., `## 📷 Terminal Smoke Test Screenshots`)
3. Click the "Paste, drop, or click to add files" button to trigger the file picker
4. Upload screenshot PNG files via `browser_file_upload` (can upload multiple one by one)
5. Wait for GitHub to process (about 2-3 seconds) — image links auto-insert into the comment box
6. Click the "Comment" button to submit
> **Prerequisite**: Playwright MCP needs `--user-data-dir` configured to persist GitHub login session. First time use requires manually logging into GitHub in the Playwright browser.
### 5. Submit Review
Submit code review comments via `gh pr review`:
```bash
gh pr review <number> --comment --body "review content"
```

View file

@ -0,0 +1,197 @@
---
name: terminal-capture
description: Automates terminal UI screenshot testing for CLI commands. Applies when reviewing PRs that affect CLI output, testing slash commands (/about, /context, /auth, /export), generating visual documentation, or when 'terminal screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned.
---
# Terminal Capture — CLI Terminal Screenshot Automation
Drive terminal interactions and screenshots via TypeScript configuration, used for visual verification during PR reviews.
## Prerequisites
Ensure the following dependencies are installed before running:
```bash
npm install # Install project dependencies (including node-pty, xterm, playwright, etc.)
npx playwright install chromium # Install Playwright browser
```
## Architecture
```
node-pty (pseudo-terminal) → ANSI byte stream → xterm.js (Playwright headless) → Screenshot
```
Core files:
| File | Purpose |
| -------------------------------------------------------- | ------------------------------------------------------------------------ |
| `integration-tests/terminal-capture/terminal-capture.ts` | Low-level engine (PTY + xterm.js + Playwright) |
| `integration-tests/terminal-capture/scenario-runner.ts` | Scenario executor (parses config, drives interactions, auto-screenshots) |
| `integration-tests/terminal-capture/run.ts` | CLI entry point (batch run scenarios) |
| `integration-tests/terminal-capture/scenarios/*.ts` | Scenario configuration files |
## Quick Start
### 1. Write Scenario Configuration
Create a `.ts` file under `integration-tests/terminal-capture/scenarios/`:
```typescript
import type { ScenarioConfig } from '../scenario-runner.js';
export default {
name: '/about',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' }, // Relative to this config file's location
flow: [
{ type: 'Hi, can you help me understand this codebase?' },
{ type: '/about' },
],
} satisfies ScenarioConfig;
```
### 2. Run
```bash
# Single scenario
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
# Batch (entire directory)
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
```
### 3. Output
Screenshots are saved to `integration-tests/terminal-capture/scenarios/screenshots/{name}/`:
| File | Description |
| --------------- | ---------------------------------- |
| `01-01.png` | Step 1 input state |
| `01-02.png` | Step 1 execution result |
| `02-01.png` | Step 2 input state |
| `02-02.png` | Step 2 execution result |
| `full-flow.png` | Final state full-length screenshot |
## FlowStep API
Each flow step can contain the following fields:
### `type: string` — Input Text
Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for output to stabilize → Screenshot (02).
```typescript
{
type: 'Hello';
} // Plain text
{
type: '/about';
} // Slash command (auto-completion handled automatically)
```
**Special rule**: If the next step is `key`, do not auto-press Enter (hand over control to the key sequence).
### `key: string | string[]` — Send Key Press
Used for menu selection, Tab completion, and other interactions. Does not auto-press Enter or auto-screenshot.
Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, `Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`, `Delete`
```typescript
{
key: 'ArrowDown';
} // Single key
{
key: ['ArrowDown', 'ArrowDown', 'Enter'];
} // Multiple keys
```
Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`).
### `capture` / `captureFull` — Explicit Screenshot
Use as a standalone step, or override automatic naming:
```typescript
{
capture: 'initial.png';
} // Screenshot current viewport only
{
captureFull: 'all-output.png';
} // Screenshot full scrollback buffer
```
## Scenario Examples
### Basic: Input + Command
```typescript
flow: [{ type: 'explain this project' }, { type: '/about' }];
```
### Secondary Menu Selection (/auth)
```typescript
flow: [
{ type: '/auth' },
{ key: 'ArrowDown' }, // Select API Key option
{ key: 'Enter' }, // Confirm
{ type: 'sk-xxx' }, // Input API key
];
```
### Tab Completion Selection (/export)
```typescript
flow: [
{ type: 'Tell me about yourself' },
{ type: '/export' }, // No auto-Enter (next step is key)
{ key: 'Tab' }, // Pop format selection
{ key: 'ArrowDown' }, // Select format
{ key: 'Enter' }, // Confirm → auto-screenshot
];
```
### Array Batch (Multiple Scenarios in One File)
```typescript
export default [
{ name: '/about', spawn: [...], flow: [...] },
{ name: '/context', spawn: [...], flow: [...] },
] satisfies ScenarioConfig[];
```
## Integration with PR Review
This tool is commonly used for visual verification during PR reviews. For the complete code review + screenshot workflow, see the [pr-review](../pr-review/SKILL.md) skill.
## Troubleshooting
| Issue | Cause | Solution |
| ------------------------------------ | ------------------------------------- | ---------------------------------------------------- |
| Playwright error `browser not found` | Browser not installed | `npx playwright install chromium` |
| Blank screenshot | Process starts slowly or build failed | Ensure `npm run build` succeeds, check spawn command |
| PTY-related errors | node-pty native module not compiled | `npm rebuild node-pty` |
| Unstable screenshot output | Terminal output not fully rendered | Check if the scenario needs additional wait time |
## Full ScenarioConfig Type
```typescript
interface ScenarioConfig {
name: string; // Scenario name (also used as screenshot subdirectory name)
spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"]
flow: FlowStep[]; // Interaction steps
terminal?: {
// Terminal configuration (all optional)
cols?: number; // Number of columns, default 100
rows?: number; // Number of rows, default 28
theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl
chrome?: boolean; // macOS window decorations, default true
title?: string; // Window title, default "Terminal"
fontSize?: number; // Font size
cwd?: string; // Working directory (relative to config file)
};
outputDir?: string; // Screenshot output directory (relative to config file)
}
```

View file

@ -0,0 +1,117 @@
# terminal-capture — Motivation and Positioning
## 1. Overview of Existing Testing System
| Layer | Tools | Coverage | Status |
| ---------------------- | ----------------------------------------- | --------------------------------------- | --------------------------------------------------------- |
| Unit Tests | Vitest + ink-testing-library | Ink components, Core logic, utilities | Mature, extensive `.test.ts` / `.test.tsx` |
| Integration Tests | Vitest + TestRig / SDKTestHelper | CLI E2E, SDK multi-turn, MCP, auth | Mature, supports none/docker/podman sandboxes |
| Terminal UI Snapshots | `toMatchSnapshot()` + ink-testing-library | Ink component render output (ANSI) | Exists, covers Footer, InputPrompt, MarkdownDisplay, etc. |
| Web UI Regression | Chromatic + Storybook | `packages/webui` components | Exists, but only covers Web UI |
| **Terminal UI Visual** | **terminal-capture** | CLI terminal real rendering screenshots | ✅ Implemented |
## 2. Problems Solved by terminal-capture
### Limitations of Existing Ink Text Snapshots
The project uses `toMatchSnapshot()` to compare Ink component ANSI text output, which validates **text content**, but cannot verify:
- Whether colors are correct (red separators? green highlights? Logo gradients?)
- Whether layout is aligned (table borders? multi-column layout?)
- Overall visual feel (component spacing? blank areas? overflow?)
These can only be seen by **actually rendering to a terminal emulator**.
### Core Architecture
```
node-pty (pseudo-terminal)
↓ raw ANSI byte stream
xterm.js (running inside Playwright headless Chromium)
↓ perfect rendering: colors, bold, cursor, scrolling
Playwright element screenshot
↓ pixel-perfect screenshots (optional macOS window decorations)
```
### Core Features
| Feature | Description |
| -------------------- | ----------------------------------------------------------------------------------- |
| WYSIWYG | xterm.js fully renders ANSI, no manual output cleaning needed |
| Theme Support | Built-in 5 themes (Dracula, One Dark, GitHub Dark, Monokai, Night Owl) |
| Full-length | `captureFull()` supports capturing scrollback buffer content |
| Deterministic Naming | Screenshot filenames auto-generated by step sequence for easy regression comparison |
| Batch Execution | `run.ts` executes all scenarios in one command |
## 3. Usage
### TypeScript Configuration-Driven
Scenario config files (`scenarios/*.ts`) only need to declare `type` (input) and `key` (keypress), Runner handles automatically:
- Wait for CLI readiness
- Auto-complete interference handling (/ commands auto-send Escape)
- Auto-screenshot before/after input (01 = input state, 02 = result)
- Auto-capture full-length image at last step (full-flow.png)
- Special key interactions (Arrow keys / Tab / Enter, etc.)
```typescript
// integration-tests/terminal-capture/scenarios/about.ts
import type { ScenarioConfig } from '../scenario-runner.js';
export default {
name: '/about',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [
{ type: 'Hi, can you help me understand this codebase?' },
{ type: '/about' },
],
} satisfies ScenarioConfig;
```
### Running
```bash
# From project root
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
# Or inside terminal-capture directory
npm run capture
```
### Screenshot Output
```
scenarios/screenshots/
about/
01-01.png # Step 1 input state
01-02.png # Step 1 result
02-01.png # Step 2 input state
02-02.png # Step 2 result
full-flow.png # Final state full-length image
context/
...
```
## 4. Position in Testing System
```
┌─────────────────────────────────────┐
│ Existing Testing System │
├─────────────────────────────────────┤
│ Unit Tests (Vitest) │ ← Function/Component level
│ Text Snapshots (ink-testing-lib) │ ← ANSI string comparison
│ Integration Tests (TestRig/SDK) │ ← E2E functionality
│ Web UI Regression (Chromatic) │ ← Only covers webui
├─────────────────────────────────────┤
│ terminal-capture │ ← Terminal UI visual layer
│ (xterm.js + Playwright) │ Fills the gap
└─────────────────────────────────────┘
```
## 5. Future Directions
1. **Visual Regression** — Integrate Playwright `toHaveScreenshot()` for pixel-level baseline comparison, CI auto-detects terminal UI changes
2. **PR Workflow Integration** — Drive Agent via Cursor Skill to auto-checkout branch → build → screenshot → attach to review comment
3. **Complement to Chromatic** — Chromatic covers Web UI, terminal-capture covers CLI terminal UI

View file

@ -0,0 +1,18 @@
{
"name": "@qwen-code/terminal-capture",
"version": "0.1.0",
"private": true,
"description": "Terminal UI screenshot automation for CLI visual testing",
"type": "module",
"scripts": {
"capture": "npx tsx run.ts scenarios/",
"capture:about": "npx tsx run.ts scenarios/about.ts",
"capture:all": "npx tsx run.ts scenarios/all.ts"
},
"dependencies": {
"@lydell/node-pty": "1.1.0",
"@xterm/xterm": "^5.5.0",
"playwright": "^1.50.0",
"strip-ansi": "^7.1.2"
}
}

View file

@ -0,0 +1,105 @@
#!/usr/bin/env npx tsx
/**
* Batch run terminal screenshot scenarios
*
* Usage:
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ # batch
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/*.ts # glob
*/
import {
loadScenarios,
runScenario,
type RunResult,
} from './scenario-runner.js';
import { readdirSync, statSync } from 'node:fs';
import { resolve, extname, join } from 'node:path';
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log(
`
Usage: npx tsx integration-tests/terminal-capture/run.ts <scenario.ts | directory>...
Examples:
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
`.trim(),
);
process.exit(1);
}
// Collect all .ts scenario files from arguments
const scenarioFiles: string[] = [];
for (const arg of args) {
const abs = resolve(arg);
try {
const stat = statSync(abs);
if (stat.isDirectory()) {
const files = readdirSync(abs)
.filter((f) => extname(f) === '.ts')
.sort()
.map((f) => join(abs, f));
scenarioFiles.push(...files);
} else {
scenarioFiles.push(abs);
}
} catch {
console.error(`❌ Not found: ${arg}`);
process.exit(1);
}
}
if (scenarioFiles.length === 0) {
console.error('❌ No .ts scenario files found');
process.exit(1);
}
console.log(`🎬 Running ${scenarioFiles.length} scenario(s)...\n`);
// Run scenarios sequentially (single file can export an array)
const results: RunResult[] = [];
for (const file of scenarioFiles) {
const { configs, basedir } = await loadScenarios(file);
for (const config of configs) {
const result = await runScenario(config, basedir);
results.push(result);
}
}
// Summary
console.log(`\n${'═'.repeat(60)}`);
console.log('📊 Summary');
console.log('═'.repeat(60));
const passed = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
const totalScreenshots = results.reduce(
(sum, r) => sum + r.screenshots.length,
0,
);
const totalTime = results.reduce((sum, r) => sum + r.durationMs, 0);
for (const r of results) {
const icon = r.success ? '✅' : '❌';
const time = (r.durationMs / 1000).toFixed(1);
console.log(
` ${icon} ${r.name}${r.screenshots.length} screenshots, ${time}s`,
);
if (r.error) console.log(` ${r.error}`);
}
console.log(
`\n Total: ${passed.length} passed, ${failed.length} failed, ${totalScreenshots} screenshots, ${(totalTime / 1000).toFixed(1)}s`,
);
if (failed.length > 0) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,304 @@
/**
* Scenario Runner v3 TypeScript Configuration-Driven Terminal Screenshots
*
* Configuration has only two core concepts: type (input) and capture (screenshot).
* All intelligent waiting is handled automatically by the Runner.
*
* Usage:
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts
* npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/
*/
import { TerminalCapture, THEMES } from './terminal-capture.js';
import { dirname, resolve, isAbsolute } from 'node:path';
// ─────────────────────────────────────────────
// Schema — Minimal
// ─────────────────────────────────────────────
export interface FlowStep {
/** Input text (auto-press Enter, auto-wait for output to stabilize, auto-screenshot before/after) */
type?: string;
/**
* Send special key presses (no auto-Enter, no auto-screenshot)
* Supported: ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Enter, Tab, Escape, Backspace, Space
* Can also pass ANSI escape sequence strings
*/
key?: string | string[];
/** Explicit screenshot: current viewport (standalone capture when no type) */
capture?: string;
/** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */
captureFull?: string;
}
export interface ScenarioConfig {
/** Scenario name */
name: string;
/** Launch command, e.g., ["node", "dist/cli.js", "--yolo"] */
spawn: string[];
/** Execution flow: array, each item can contain type / capture / captureFull */
flow: FlowStep[];
/** Terminal configuration (all optional) */
terminal?: {
cols?: number;
rows?: number;
theme?: string;
chrome?: boolean;
title?: string;
fontSize?: number;
cwd?: string;
};
/** Screenshot output directory (relative to config file) */
outputDir?: string;
}
// ─────────────────────────────────────────────
// Runner
// ─────────────────────────────────────────────
export interface RunResult {
name: string;
screenshots: string[];
success: boolean;
error?: string;
durationMs: number;
}
/** Dynamically load configuration from .ts file (supports single object or array) */
export async function loadScenarios(
tsPath: string,
): Promise<{ configs: ScenarioConfig[]; basedir: string }> {
const absPath = isAbsolute(tsPath) ? tsPath : resolve(tsPath);
const mod = (await import(absPath)) as {
default: ScenarioConfig | ScenarioConfig[];
};
const raw = mod.default;
const configs = Array.isArray(raw) ? raw : [raw];
for (const config of configs) {
if (!config?.name) throw new Error(`Missing 'name': ${absPath}`);
if (!config.spawn?.length) throw new Error(`Missing 'spawn': ${absPath}`);
if (!config.flow?.length) throw new Error(`Missing 'flow': ${absPath}`);
}
return { configs, basedir: dirname(absPath) };
}
/** Execute a single scenario */
export async function runScenario(
config: ScenarioConfig,
basedir: string,
): Promise<RunResult> {
const startTime = Date.now();
const screenshots: string[] = [];
const t = config.terminal ?? {};
const cwd = t.cwd ? resolve(basedir, t.cwd) : resolve(basedir, '..');
// Use scenario name as subdirectory to isolate screenshot outputs from different scenarios
const scenarioDir =
config.name
.replace(/^\//, '')
.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'unnamed';
const outputDir = config.outputDir
? resolve(basedir, config.outputDir, scenarioDir)
: resolve(basedir, 'screenshots', scenarioDir);
console.log(`\n${'═'.repeat(60)}`);
console.log(`${config.name}`);
console.log('═'.repeat(60));
const terminal = await TerminalCapture.create({
cols: t.cols ?? 100,
rows: t.rows ?? 28,
theme: (t.theme ?? 'dracula') as keyof typeof THEMES,
chrome: t.chrome ?? true,
title: t.title ?? 'Terminal',
fontSize: t.fontSize,
cwd,
outputDir,
});
try {
// ── Spawn ──
const [command, ...args] = config.spawn;
console.log(` spawn: ${config.spawn.join(' ')}`);
await terminal.spawn(command, args);
// ── Auto-wait for CLI readiness ──
console.log(' ⏳ waiting for ready...');
await terminal.idle(1500, 30000);
console.log(' ✅ ready');
// ── Execute flow ──
let seq = 0; // Global screenshot sequence number
for (let i = 0; i < config.flow.length; i++) {
const step = config.flow[i];
const label = `[${i + 1}/${config.flow.length}]`;
if (step.type) {
const display =
step.type.length > 60 ? step.type.slice(0, 60) + '...' : step.type;
// If next step is key, there's more interaction to do, so don't auto-press Enter
const nextStep = config.flow[i + 1];
const autoEnter = !nextStep?.key;
console.log(
` ${label} type: "${display}"${autoEnter ? '' : ' (no auto-enter)'}`,
);
const text = step.type.replace(/\n$/, '');
await terminal.type(text);
await sleep(300);
// Only send Escape for / commands to close auto-complete, not for regular text
if (text.startsWith('/') && autoEnter) {
await terminal.type('\x1b');
await sleep(100);
}
// ── 01: Text input complete ──
seq++;
const inputName = step.capture
? step.capture.replace(/\.png$/, '-01.png')
: `${pad(seq)}-01.png`;
console.log(` ${label} 📸 input: ${inputName}`);
screenshots.push(await terminal.capture(inputName));
if (autoEnter) {
// ── Auto-press Enter → Wait for stabilization → 02 screenshot ──
await terminal.type('\n');
console.log(` ⏳ waiting for output to settle...`);
await terminal.idle(2000, 60000);
console.log(` ✅ settled`);
const resultName = step.capture ?? `${pad(seq)}-02.png`;
console.log(` ${label} 📸 result: ${resultName}`);
screenshots.push(await terminal.capture(resultName));
// full-flow: Only the last type step auto-captures full-length image
const isLastType = !config.flow.slice(i + 1).some((s) => s.type);
if (isLastType || step.captureFull) {
const fullName = step.captureFull ?? 'full-flow.png';
console.log(` ${label} 📸 full: ${fullName}`);
screenshots.push(await terminal.captureFull(fullName));
}
}
// When not autoEnter, only captured before state, subsequent key steps take over interaction
} else if (step.key) {
// ── key: Send special key presses (arrow keys, Tab, Enter, etc.) ──
const keys = Array.isArray(step.key) ? step.key : [step.key];
console.log(` ${label} key: ${keys.join(', ')}`);
for (const k of keys) {
await terminal.type(resolveKey(k));
await sleep(150);
}
// Wait for UI response to key press
await terminal.idle(500, 5000);
// If key step has explicit capture/captureFull
if (step.capture || step.captureFull) {
seq++;
if (step.capture) {
console.log(` ${label} 📸 capture: ${step.capture}`);
screenshots.push(await terminal.capture(step.capture));
}
if (step.captureFull) {
console.log(` ${label} 📸 captureFull: ${step.captureFull}`);
screenshots.push(await terminal.captureFull(step.captureFull));
}
}
// After key sequence ends (next step is not key), auto-add result + full screenshots
const nextStep = config.flow[i + 1];
if (!nextStep?.key) {
console.log(` ⏳ waiting for output to settle...`);
await terminal.idle(2000, 60000);
console.log(` ✅ settled`);
const resultName = `${pad(seq)}-02.png`;
console.log(` ${label} 📸 result: ${resultName}`);
screenshots.push(await terminal.capture(resultName));
// If this is the last interaction step, add full-length image
const isLastType = !config.flow.slice(i + 1).some((s) => s.type);
if (isLastType) {
console.log(` ${label} 📸 full: full-flow.png`);
screenshots.push(await terminal.captureFull('full-flow.png'));
}
}
} else {
// ── Standalone screenshot step (no type/key) ──
seq++;
if (step.capture) {
console.log(` ${label} 📸 capture: ${step.capture}`);
screenshots.push(await terminal.capture(step.capture));
}
if (step.captureFull) {
console.log(` ${label} 📸 captureFull: ${step.captureFull}`);
screenshots.push(await terminal.captureFull(step.captureFull));
}
}
}
const duration = Date.now() - startTime;
console.log(
`\n ✅ ${config.name}${screenshots.length} screenshots, ${(duration / 1000).toFixed(1)}s`,
);
return {
name: config.name,
screenshots,
success: true,
durationMs: duration,
};
} catch (err) {
const duration = Date.now() - startTime;
const msg = err instanceof Error ? err.message : String(err);
console.error(`\n ❌ ${config.name}${msg}`);
return {
name: config.name,
screenshots,
success: false,
error: msg,
durationMs: duration,
};
} finally {
await terminal.close();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Pad sequence number with zero: 1 → "01" */
function pad(n: number): string {
return String(n).padStart(2, '0');
}
/** Key name → PTY escape sequence */
const KEY_MAP: Record<string, string> = {
ArrowUp: '\x1b[A',
ArrowDown: '\x1b[B',
ArrowRight: '\x1b[C',
ArrowLeft: '\x1b[D',
Enter: '\r',
Tab: '\t',
Escape: '\x1b',
Backspace: '\x7f',
Space: ' ',
Home: '\x1b[H',
End: '\x1b[F',
PageUp: '\x1b[5~',
PageDown: '\x1b[6~',
Delete: '\x1b[3~',
};
/** Parse key name to PTY-recognizable character sequence */
function resolveKey(key: string): string {
return KEY_MAP[key] ?? key;
}

View file

@ -0,0 +1,8 @@
import type { ScenarioConfig } from '../scenario-runner.js';
export default {
name: '/about command',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [{ type: 'hi' }, { type: '/about' }],
} satisfies ScenarioConfig;

View file

@ -0,0 +1,46 @@
import type { ScenarioConfig } from '../scenario-runner.js';
export default [
{
name: '/about',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [
{ type: 'Hi, can you help me understand this codebase?' },
{ type: '/about' },
],
},
{
name: '/context',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [
{ type: 'How do you understand this project?' },
{ type: '/context' },
],
},
{
name: '/export (tab select)',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [
{ type: 'Please give me a brief introduction about yourself.' },
{ type: '/export' },
{ key: 'Tab' }, // Tab to open format selection
{ key: 'ArrowDown' }, // Down arrow to switch options
{ key: 'Enter' }, // Confirm selection
],
},
{
name: '/auth',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [
{ type: '/auth' },
{ key: 'ArrowDown' }, // Select API Key
{ key: 'Enter' }, // Confirm
{ type: 'sk-test-key-123' },
],
},
] satisfies ScenarioConfig[];

View file

@ -0,0 +1,856 @@
/**
* TerminalCapture - Terminal Screenshot Tool
*
* Terminal screenshot solution based on xterm.js + Playwright + node-pty.
* Core philosophy: WYSIWYG let xterm.js complete terminal simulation and rendering
* inside the browser. Screenshots always capture the terminal's current real state,
* no manual output cleaning needed.
*
* Architecture:
* node-pty (pseudo-terminal)
* raw ANSI byte stream
* xterm.js (running inside Playwright headless Chromium)
* perfect rendering: colors, bold, cursor, scrolling
* Playwright element screenshot
* pixel-perfect screenshots (optional macOS window decorations)
*/
import { chromium, type Browser, type Page } from 'playwright';
import * as pty from '@lydell/node-pty';
import stripAnsi from 'strip-ansi';
import { mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { createRequire } from 'node:module';
const _require = createRequire(import.meta.url);
// ─────────────────────────────────────────────
// Theme definitions
// ─────────────────────────────────────────────
export interface XtermTheme {
background: string;
foreground: string;
cursor: string;
cursorAccent?: string;
selectionBackground?: string;
selectionForeground?: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}
export const THEMES: Record<string, XtermTheme> = {
dracula: {
background: '#282a36',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
selectionBackground: '#44475a',
black: '#21222c',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#bd93f9',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#f8f8f2',
brightBlack: '#6272a4',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#d6acff',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#ffffff',
},
'one-dark': {
background: '#282c34',
foreground: '#abb2bf',
cursor: '#528bff',
selectionBackground: '#3e4451',
black: '#545862',
red: '#e06c75',
green: '#98c379',
yellow: '#e5c07b',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#abb2bf',
brightBlack: '#545862',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#c8ccd4',
},
'github-dark': {
background: '#0d1117',
foreground: '#c9d1d9',
cursor: '#c9d1d9',
selectionBackground: '#264f78',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc',
},
monokai: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
selectionBackground: '#49483e',
black: '#272822',
red: '#f92672',
green: '#a6e22e',
yellow: '#f4bf75',
blue: '#66d9ef',
magenta: '#ae81ff',
cyan: '#a1efe4',
white: '#f8f8f2',
brightBlack: '#75715e',
brightRed: '#f92672',
brightGreen: '#a6e22e',
brightYellow: '#f4bf75',
brightBlue: '#66d9ef',
brightMagenta: '#ae81ff',
brightCyan: '#a1efe4',
brightWhite: '#f9f8f5',
},
'night-owl': {
background: '#011627',
foreground: '#d6deeb',
cursor: '#80a4c2',
selectionBackground: '#1d3b53',
black: '#011627',
red: '#ef5350',
green: '#22da6e',
yellow: '#addb67',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#21c7a8',
white: '#d6deeb',
brightBlack: '#575656',
brightRed: '#ef5350',
brightGreen: '#22da6e',
brightYellow: '#ffeb95',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#7fdbca',
brightWhite: '#ffffff',
},
};
// ─────────────────────────────────────────────
// Options
// ─────────────────────────────────────────────
export interface TerminalCaptureOptions {
/** Number of terminal columns, default 120 */
cols?: number;
/** Number of terminal rows, default 40 */
rows?: number;
/** Working directory */
cwd?: string;
/** Environment variables */
env?: NodeJS.ProcessEnv;
/** Theme name or custom theme object, default 'dracula' */
theme?: keyof typeof THEMES | XtermTheme;
/** Whether to show macOS window decorations (traffic lights + title bar), default true */
chrome?: boolean;
/** Window title (only effective when chrome=true), default 'Terminal' */
title?: string;
/** Font size, default 14 */
fontSize?: number;
/** Font family, default system monospace font */
fontFamily?: string;
/** Default screenshot output directory */
outputDir?: string;
}
// ─────────────────────────────────────────────
// Main class
// ─────────────────────────────────────────────
export class TerminalCapture {
private browser: Browser | null = null;
private page: Page | null = null;
private ptyProcess: pty.IPty | null = null;
private rawOutput = '';
private lastFlushedLength = 0;
private readonly cols: number;
private readonly rows: number;
private readonly cwd: string;
private readonly env: NodeJS.ProcessEnv;
private readonly theme: XtermTheme;
private readonly showChrome: boolean;
private readonly windowTitle: string;
private readonly fontSize: number;
private readonly fontFamily: string;
private readonly outputDir: string;
// ── Factory ──────────────────────────────
/**
* Create and initialize a TerminalCapture instance
*
* @example
* ```ts
* const t = await TerminalCapture.create({
* theme: 'dracula',
* chrome: true,
* title: 'qwen-code',
* });
* ```
*/
static async create(
options?: TerminalCaptureOptions,
): Promise<TerminalCapture> {
const instance = new TerminalCapture(options);
await instance.init();
return instance;
}
private constructor(options?: TerminalCaptureOptions) {
this.cols = options?.cols ?? 120;
this.rows = options?.rows ?? 40;
this.cwd = options?.cwd ?? process.cwd();
// Build a clean env for optimal terminal rendering:
// - Remove NO_COLOR (conflicts with FORCE_COLOR, can crash gradient components)
// - Suppress Node.js warnings (noisy in screenshots)
// - Force color output and 256-color terminal
const baseEnv = { ...process.env };
delete baseEnv['NO_COLOR'];
this.env = options?.env ?? {
...baseEnv,
FORCE_COLOR: '1',
TERM: 'xterm-256color',
NODE_NO_WARNINGS: '1',
};
this.showChrome = options?.chrome ?? true;
this.windowTitle = options?.title ?? 'Terminal';
this.fontSize = options?.fontSize ?? 14;
this.fontFamily =
options?.fontFamily ??
"'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace";
this.outputDir = options?.outputDir ?? join(process.cwd(), 'screenshots');
// Resolve theme
if (typeof options?.theme === 'string') {
this.theme = THEMES[options.theme] ?? THEMES['dracula'];
} else if (options?.theme && typeof options.theme === 'object') {
this.theme = options.theme;
} else {
this.theme = THEMES['dracula'];
}
}
// ── Lifecycle ────────────────────────────
private async init(): Promise<void> {
// 1. Launch browser
this.browser = await chromium.launch({ headless: true });
this.page = await this.browser.newPage({
viewport: { width: 1600, height: 1000 },
});
// 2. Set base HTML (with chrome decoration, container, etc.)
await this.page.setContent(this.buildHTML());
// 3. Load xterm.js from node_modules
const xtermDir = this.resolveXtermDir();
await this.page.addStyleTag({ path: join(xtermDir, 'css', 'xterm.css') });
await this.page.addScriptTag({ path: join(xtermDir, 'lib', 'xterm.js') });
// 4. Create xterm Terminal instance inside the page
await this.page.evaluate(
({ cols, rows, theme, fontSize, fontFamily }) => {
const W = window as unknown as Record<string, unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Terminal = W['Terminal'] as new (opts: unknown) => any;
const term = new Terminal({
cols,
rows,
theme,
fontFamily,
fontSize,
lineHeight: 1.2,
cursorBlink: false,
allowProposedApi: true,
scrollback: 1000,
});
const container = document.getElementById('xterm-container')!;
term.open(container);
// Expose to outer scope
W['term'] = term;
W['termReady'] = true;
},
{
cols: this.cols,
rows: this.rows,
theme: this.theme as unknown as Record<string, string>,
fontSize: this.fontSize,
fontFamily: this.fontFamily,
},
);
// 5. Wait until terminal is ready
await this.page.waitForFunction(
() =>
(window as unknown as Record<string, unknown>)['termReady'] === true,
);
}
/**
* Spawn a command (via pseudo-terminal)
*
* @example
* ```ts
* await terminal.spawn('node', ['dist/cli.js', '--yolo']);
* ```
*/
async spawn(command: string, args: string[] = []): Promise<void> {
if (!this.page) {
throw new Error(
'Not initialized. Use TerminalCapture.create() factory method.',
);
}
this.ptyProcess = pty.spawn(command, args, {
name: 'xterm-256color',
cols: this.cols,
rows: this.rows,
cwd: this.cwd,
env: this.env,
});
this.ptyProcess.onData((data) => {
this.rawOutput += data;
});
}
// ── Input ────────────────────────────────
/**
* Input text. Supports `\n` as Enter.
*
* @param text Text to input
* @param options.delay Delay after input (ms), default 10
* @param options.slow Type character by character (simulate real typing), default false
*
* @example
* ```ts
* await terminal.type('Hello world\n'); // Input + Enter
* await terminal.type('ls -la\n', { slow: true, delay: 80 });
* ```
*/
async type(
text: string,
options?: { delay?: number; slow?: boolean },
): Promise<void> {
if (!this.ptyProcess) {
throw new Error('No process running. Call spawn() first.');
}
// Convert \n to \r for PTY
const translated = text.replace(/\n/g, '\r');
if (options?.slow) {
for (const char of translated) {
this.ptyProcess.write(char);
await this.sleep(options.delay ?? 50);
}
} else {
this.ptyProcess.write(translated);
await this.sleep(options?.delay ?? 10);
}
}
// ── Wait ─────────────────────────────────
/**
* Wait for specific text to appear in terminal output
*
* @throws Error on timeout
*
* @example
* ```ts
* await terminal.waitFor('Type your message');
* await terminal.waitFor('tokens', { timeout: 30000 });
* ```
*/
async waitFor(text: string, options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? 15000;
const start = Date.now();
while (Date.now() - start < timeout) {
if (
stripAnsi(this.rawOutput).toLowerCase().includes(text.toLowerCase())
) {
return;
}
await this.sleep(200);
}
throw new Error(
`Timeout (${timeout}ms) waiting for text: "${text}"\n` +
`Last 500 chars of output: ${stripAnsi(this.rawOutput).slice(-500)}`,
);
}
/**
* Wait for output to stabilize (no new output within specified time)
*
* @param stableMs Stability detection duration (ms), default 500
* @param timeout Maximum wait time (ms), default 30000
*
* @example
* ```ts
* await terminal.idle(); // Default: 500ms with no new output considered stable
* await terminal.idle(2000); // 2s with no new output
* ```
*/
async idle(stableMs: number = 500, timeout: number = 30000): Promise<void> {
const start = Date.now();
let lastLength = this.rawOutput.length;
let lastChangeTime = Date.now();
while (Date.now() - start < timeout) {
await this.sleep(100);
if (this.rawOutput.length !== lastLength) {
lastLength = this.rawOutput.length;
lastChangeTime = Date.now();
} else if (Date.now() - lastChangeTime >= stableMs) {
return;
}
}
// Timeout for idle() is not an error — just means output kept coming
}
/**
* Wait for text to appear, then wait for output to stabilize (common combination)
*/
async waitForAndIdle(
text: string,
options?: { timeout?: number; stableMs?: number },
): Promise<void> {
await this.waitFor(text, { timeout: options?.timeout });
await this.idle(options?.stableMs ?? 300, 5000);
}
// ── Capture ──────────────────────────────
/**
* Capture and save a screenshot. Filenames are deterministic (no timestamps) for easy regression comparison.
*
* @param filename Filename, e.g., 'initial.png'
* @param outputDir Output directory, defaults to the outputDir from construction
* @returns Full path to the screenshot file
*
* @example
* ```ts
* await terminal.capture('01-initial.png');
* await terminal.capture('02-output.png', '/tmp/screenshots');
* ```
*/
async capture(filename: string, outputDir?: string): Promise<string> {
if (!this.page) {
throw new Error('Not initialized');
}
// 1. Flush all accumulated PTY data to xterm.js
await this.flush();
// 2. Wait for xterm.js rendering to complete
await this.sleep(150);
// 3. Prepare output directory
const dir = outputDir ?? this.outputDir;
mkdirSync(dir, { recursive: true });
const filepath = join(dir, filename);
// 4. Screenshot the capture root (terminal + optional chrome)
const element = await this.page.$('#capture-root');
if (element) {
await element.screenshot({ path: filepath });
} else {
await this.page.screenshot({ path: filepath });
}
console.log(`📸 Captured: ${filepath}`);
return filepath;
}
/**
* Capture full terminal output (including scrollback buffer) as a long image.
* Suitable for scenarios where output exceeds the visible area, e.g., detailed token lists from /context.
*
* Principle: Temporarily expand xterm.js rows to show complete scrollback, then restore original dimensions after screenshot.
* Note: Only resizes xterm.js inside the browser, not the PTY dimensions, so it won't trigger CLI re-render.
*
* @param filename Filename
* @param outputDir Output directory
* @returns Full path to the screenshot file
*
* @example
* ```ts
* // Regular screenshot (only current viewport)
* await terminal.capture('output.png');
* // Full-length image (including scrollback buffer)
* await terminal.captureFull('output-full.png');
* ```
*/
async captureFull(filename: string, outputDir?: string): Promise<string> {
if (!this.page) {
throw new Error('Not initialized');
}
// 1. Flush all accumulated PTY data to xterm.js
await this.flush();
await this.sleep(150);
// 2. Query xterm.js for the actual content height (skip trailing empty lines)
const contentLines = await this.page.evaluate(() => {
const W = window as unknown as Record<string, unknown>;
const term = W['term'] as {
buffer: {
active: {
length: number;
getLine: (i: number) =>
| {
translateToString: (trimRight?: boolean) => string;
}
| undefined;
};
};
};
const buf = term.buffer.active;
let lastNonEmpty = 0;
for (let i = buf.length - 1; i >= 0; i--) {
const line = buf.getLine(i);
if (line && line.translateToString(true).trim().length > 0) {
lastNonEmpty = i;
break;
}
}
return lastNonEmpty + 1;
});
const expandedRows = Math.max(contentLines + 2, this.rows);
// 3. Temporarily resize xterm.js only (NOT the PTY) to show all content
// This avoids sending SIGWINCH to the child process, so the CLI won't re-render
await this.page.evaluate(
({ cols, rows }: { cols: number; rows: number }) => {
const W = window as unknown as Record<string, unknown>;
const term = W['term'] as {
resize: (c: number, r: number) => void;
scrollToTop: () => void;
};
term.resize(cols, rows);
// Scroll to top to ensure rendering starts from scrollback beginning position
term.scrollToTop();
},
{ cols: this.cols, rows: expandedRows },
);
// 4. Expand viewport to accommodate the taller terminal
await this.page.setViewportSize({
width: 1600,
height: Math.max(expandedRows * 22, 1000), // ~22px per row (fontSize 14 * lineHeight 1.2 + padding)
});
await this.sleep(300);
// 5. Screenshot the full content
const dir = outputDir ?? this.outputDir;
mkdirSync(dir, { recursive: true });
const filepath = join(dir, filename);
const element = await this.page.$('#capture-root');
if (element) {
await element.screenshot({ path: filepath });
} else {
await this.page.screenshot({ path: filepath, fullPage: true });
}
// 6. Restore original xterm.js dimensions and viewport
await this.page.evaluate(
({ cols, rows }: { cols: number; rows: number }) => {
const W = window as unknown as Record<string, unknown>;
const term = W['term'] as { resize: (c: number, r: number) => void };
term.resize(cols, rows);
},
{ cols: this.cols, rows: this.rows },
);
await this.page.setViewportSize({ width: 1600, height: 1000 });
console.log(`📸 Captured (full): ${filepath}`);
return filepath;
}
// ── Output access ────────────────────────
/**
* Get cleaned terminal output (without ANSI escape sequences)
*/
getOutput(): string {
return stripAnsi(this.rawOutput);
}
/**
* Get raw terminal output (with ANSI escape sequences)
*/
getRawOutput(): string {
return this.rawOutput;
}
// ── Cleanup ──────────────────────────────
/**
* Release all resources (PTY process, browser)
*/
async close(): Promise<void> {
if (this.ptyProcess) {
try {
this.ptyProcess.kill();
} catch {
// Process may have already exited
}
this.ptyProcess = null;
}
if (this.browser) {
await this.browser.close();
this.browser = null;
this.page = null;
}
}
// ── Internal: flush PTY → xterm.js ──────
/**
* Flush accumulated PTY raw output to xterm.js inside the browser.
* Uses xterm.js's write callback to ensure data is fully parsed,
* then waits one requestAnimationFrame to ensure rendering is complete.
*/
private async flush(): Promise<void> {
if (!this.page || this.rawOutput.length <= this.lastFlushedLength) {
return;
}
const newData = this.rawOutput.slice(this.lastFlushedLength);
this.lastFlushedLength = this.rawOutput.length;
// Send data in chunks to avoid hitting string size limits
const CHUNK_SIZE = 64 * 1024;
for (let i = 0; i < newData.length; i += CHUNK_SIZE) {
const chunk = newData.slice(i, i + CHUNK_SIZE);
await this.page.evaluate((data: string) => {
return new Promise<void>((resolve) => {
const W = window as unknown as Record<string, unknown>;
const term = W['term'] as {
write: (d: string, cb: () => void) => void;
};
term.write(data, () => {
// Data parsed → wait one frame for rendering
requestAnimationFrame(() => resolve());
});
});
}, chunk);
}
}
// ── Internal: resolve xterm.js path ─────
private resolveXtermDir(): string {
try {
const pkgJsonPath = _require.resolve('@xterm/xterm/package.json');
return dirname(pkgJsonPath);
} catch {
throw new Error(
'@xterm/xterm is not installed.\n' +
'Run: npm install --save-dev @xterm/xterm',
);
}
}
// ── Internal: build HTML ────────────────
private buildHTML(): string {
const bg = this.theme.background;
// Title bar color: slightly lighter than background
// Use a manual approximation instead of color-mix for compatibility
const titleBarBg = this.lighten(bg, 0.08);
const chromeHTML = this.showChrome
? `
<div class="title-bar" style="background: ${titleBarBg};">
<div class="traffic-lights">
<span class="tl tl-close"></span>
<span class="tl tl-minimize"></span>
<span class="tl tl-maximize"></span>
</div>
<span class="title-text">${this.escapeHtml(this.windowTitle)}</span>
<div class="traffic-lights-spacer"></div>
</div>`
: '';
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0e0e1a;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 40px;
min-height: 100vh;
}
#capture-root {
display: inline-block;
border-radius: ${this.showChrome ? '10px' : '6px'};
overflow: hidden;
background: ${bg};
box-shadow:
0 25px 70px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.08);
}
/* ── Title bar (macOS chrome) ── */
.title-bar {
height: 40px;
display: flex;
align-items: center;
padding: 0 16px;
user-select: none;
}
.traffic-lights {
display: flex;
gap: 8px;
width: 56px;
}
.traffic-lights-spacer {
width: 56px;
}
.tl {
width: 12px;
height: 12px;
border-radius: 50%;
display: block;
}
.tl-close { background: #ff5f57; }
.tl-minimize { background: #ffbd2e; }
.tl-maximize { background: #28c840; }
.title-text {
flex: 1;
text-align: center;
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-weight: 500;
}
/* ── Terminal container ── */
#xterm-container {
padding: 4px 8px 8px 8px;
}
/* Hide scrollbar in xterm */
.xterm-viewport::-webkit-scrollbar { display: none; }
.xterm-viewport { scrollbar-width: none; }
/* Ensure xterm canvas renders sharply */
.xterm canvas { image-rendering: pixelated; }
</style>
</head>
<body>
<div id="capture-root">
${chromeHTML}
<div id="xterm-container"></div>
</div>
</body>
</html>`;
}
// ── Internal: utils ─────────────────────
private escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Lighten a hex color by a factor (0-1)
*/
private lighten(hex: string, factor: number): string {
const h = hex.replace('#', '');
const r = Math.min(
255,
parseInt(h.slice(0, 2), 16) + Math.round(255 * factor),
);
const g = Math.min(
255,
parseInt(h.slice(2, 4), 16) + Math.round(255 * factor),
);
const b = Math.min(
255,
parseInt(h.slice(4, 6), 16) + Math.round(255 * factor),
);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}