diff --git a/.gitignore b/.gitignore
index 9250bac..046a97b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ examples/*/screenshots/
output/
.mcp.json
.playwright-mcp/
+.worktrees/
diff --git a/docs/plans/2026-02-13-cursor-visibility-design.md b/docs/plans/2026-02-13-cursor-visibility-design.md
new file mode 100644
index 0000000..d722414
--- /dev/null
+++ b/docs/plans/2026-02-13-cursor-visibility-design.md
@@ -0,0 +1,43 @@
+# Cursor Visibility Design
+
+## Summary
+
+Render the terminal cursor in screenshots and recordings when xterm.js reports it as visible.
+
+## Behavior
+
+- Cursor renders automatically when visible in terminal state
+- No API changes - no new parameters to tools
+- Breaking change: existing outputs will now show cursors where terminal has them visible
+
+## Implementation
+
+Approach: Post-pass cursor rendering in `bufferToSvg`.
+
+After the main cell-rendering loop:
+
+1. Check cursor visibility (`terminal.modes.showCursor` is true, cursor within viewport)
+2. Get position from `terminal.buffer.active.cursorX/Y`
+3. Get cell at cursor position to determine current fg/bg colors
+4. Render filled rectangle at cursor position using `theme.foreground`
+5. Re-render character at cursor position with inverted colors (fg/bg swapped)
+
+SVG layering handles visual stacking.
+
+## Design Decisions
+
+- **Block cursor only** - no underline/bar styles for now
+- **True color inversion** - text under cursor has fg/bg swapped, not semi-transparent overlay
+- **Uses theme.foreground** - no cursor color added to Theme interface
+- **Respects terminal state** - renders only when xterm.js cursor is visible
+
+## API Used
+
+- `terminal.modes.showCursor`: Boolean indicating cursor visibility (DECTCEM state)
+- `terminal.buffer.active.cursorX/Y`: Cursor position
+
+**Note:** Requires `@xterm/headless@6.1.0-beta.156` or later for `showCursor` mode support.
+
+## Follow-up
+
+Create GitHub issue `rfc: force show/hide cursor option` for users who need explicit override control.
diff --git a/docs/plans/2026-02-13-cursor-visibility.md b/docs/plans/2026-02-13-cursor-visibility.md
new file mode 100644
index 0000000..b4ea96f
--- /dev/null
+++ b/docs/plans/2026-02-13-cursor-visibility.md
@@ -0,0 +1,142 @@
+# Cursor Visibility Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Render terminal cursor in screenshots and recordings when visible.
+
+**Architecture:** Add cursor rendering as post-pass in `bufferToSvg`. Check `terminal.modes.showCursor`, render block cursor with inverted colors at cursor position.
+
+**Tech Stack:** TypeScript, xterm.js headless, SVG generation
+
+---
+
+### Task 1: Add cursor rendering to bufferToSvg
+
+**Files:**
+- Modify: `src/lib/buffer-to-svg.ts:159` (after main rendering loop)
+
+**Step 1: Write the cursor rendering code**
+
+Add after line 159 (after the main `for` loop ends, before the `return` statement):
+
+```typescript
+ // Render cursor if visible
+ if (terminal.modes.showCursor) {
+ const cursorX = buffer.cursorX;
+ const cursorY = buffer.cursorY;
+
+ // Only render if cursor is within visible area
+ if (cursorX >= 0 && cursorX < cols && cursorY >= 0 && cursorY < rows) {
+ const cursorXPos = padding + cursorX * charWidth;
+ const cursorYPos = padding + cursorY * lineHeight;
+
+ // Get the cell at cursor position for color inversion
+ const cursorLine = buffer.getLine(cursorY);
+ const cursorCell = cursorLine?.getCell(cursorX);
+
+ // Cursor block uses foreground color
+ const cursorBg = theme.foreground;
+ // Text under cursor uses background color (inversion)
+ const cursorFg = theme.background;
+
+ // Draw cursor block
+ lines.push(
+ ``
+ );
+
+ // Draw inverted character if present
+ if (cursorCell) {
+ const char = cursorCell.getChars();
+ if (char && char.trim()) {
+ const textYPos = cursorYPos + opts.fontSize;
+ lines.push(
+ `${escapeXml(char)}`
+ );
+ }
+ }
+ }
+ }
+```
+
+**Step 2: Build to verify no TypeScript errors**
+
+Run: `npm run build`
+Expected: Build succeeds with no errors
+
+**Step 3: Manual test with a shell session**
+
+Run shellwright, start a session, take a screenshot. Verify cursor appears as a block.
+
+**Step 4: Commit**
+
+```bash
+git add src/lib/buffer-to-svg.ts
+git commit -m "feat: render cursor in screenshots and recordings"
+```
+
+---
+
+### Task 2: Create GitHub RFC issue for force show/hide cursor
+
+**Step 1: Create the issue**
+
+```bash
+gh issue create --title "rfc: force show/hide cursor option" --body "$(cat <<'EOF'
+## Context
+
+Cursor rendering now respects terminal state automatically via `terminal.modes.showCursor`.
+
+## Potential Enhancement
+
+If users need explicit control to force cursor on/off regardless of terminal state, we could add a parameter:
+
+```typescript
+shell_screenshot({ session_id, showCursor?: 'auto' | 'show' | 'hide' })
+```
+
+- `auto` (default): respect terminal state
+- `show`: always render cursor
+- `hide`: never render cursor
+
+## Use Cases
+
+- Force show cursor in TUI apps that hide it
+- Force hide cursor in shell sessions for cleaner screenshots
+
+Please comment if you have a use case for this.
+EOF
+)"
+```
+
+**Step 2: Note the issue URL**
+
+Record the issue URL for reference.
+
+**Step 3: Commit reference (optional)**
+
+If desired, add issue reference to design doc.
+
+---
+
+### Task 3: Update design doc with implementation details
+
+**Files:**
+- Modify: `docs/plans/2026-02-13-cursor-visibility-design.md`
+
+**Step 1: Add API details to design doc**
+
+Add under Implementation section:
+
+```markdown
+## API Used
+
+- `terminal.modes.showCursor`: Boolean indicating cursor visibility (DECTCEM state)
+- `terminal.buffer.active.cursorX/Y`: Cursor position
+```
+
+**Step 2: Commit**
+
+```bash
+git add docs/plans/2026-02-13-cursor-visibility-design.md
+git commit -m "docs: add API details to cursor visibility design"
+```
diff --git a/evaluations/run.ts b/evaluations/run.ts
index ede43f0..ebd860e 100644
--- a/evaluations/run.ts
+++ b/evaluations/run.ts
@@ -144,8 +144,16 @@ async function main() {
console.log("Building shellwright...");
execSync("npm run build", { stdio: "inherit", cwd: ROOT_DIR });
- // Find all scenarios
- const scenarios = await fs.readdir(SCENARIOS_DIR);
+ // Find all scenarios, optionally filtered by command line argument
+ const filterArg = process.argv[2];
+ let scenarios = await fs.readdir(SCENARIOS_DIR);
+ if (filterArg) {
+ scenarios = scenarios.filter((s) => s.includes(filterArg));
+ if (scenarios.length === 0) {
+ console.error(`No scenarios matching "${filterArg}"`);
+ process.exit(1);
+ }
+ }
const results: ScenarioResult[] = [];
for (const scenario of scenarios) {
diff --git a/evaluations/scenarios/cursor-visibility/baseline-local.gif b/evaluations/scenarios/cursor-visibility/baseline-local.gif
new file mode 100644
index 0000000..f747493
Binary files /dev/null and b/evaluations/scenarios/cursor-visibility/baseline-local.gif differ
diff --git a/evaluations/scenarios/cursor-visibility/prompt.md b/evaluations/scenarios/cursor-visibility/prompt.md
new file mode 100644
index 0000000..a49fa70
--- /dev/null
+++ b/evaluations/scenarios/cursor-visibility/prompt.md
@@ -0,0 +1,21 @@
+# Cursor Visibility Test
+
+Verify that the terminal cursor renders correctly in recordings.
+
+## Instructions
+
+1. Start a shell session using `bash` with args `["--login", "-i"]` (80x24, one-dark theme)
+2. Start recording at 10 FPS
+3. Type `echo "testing cursor"` but DO NOT press Enter
+4. Wait 500ms
+5. Press Ctrl+A to move cursor to the beginning of the line
+6. Wait 1 second so the cursor position is clearly visible
+7. Stop recording and save as `recording.gif`
+8. Stop the session
+
+## Expected Result
+
+A short recording showing:
+- The text `echo "testing cursor"` being typed
+- The cursor moving to the beginning of the line (on the 'e' of 'echo')
+- The cursor rendered as a block with inverted colors (the 'e' should appear with swapped foreground/background)
diff --git a/package-lock.json b/package-lock.json
index dbdff49..013c7f2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,12 +11,12 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.24.3",
"@resvg/resvg-js": "^2.6.2",
- "@xterm/headless": "^5.5.0",
+ "@xterm/headless": "^6.1.0-beta.156",
"commander": "^14.0.2",
"express": "^5.2.1",
"gifenc": "^1.0.3",
"jimp": "^1.6.0",
- "node-pty": "^1.0.0",
+ "node-pty": "1.0.0",
"strip-ansi": "^7.1.2",
"zod": "^3.25.0"
},
@@ -3296,10 +3296,13 @@
]
},
"node_modules/@xterm/headless": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
- "integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
- "license": "MIT"
+ "version": "6.1.0-beta.156",
+ "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.156.tgz",
+ "integrity": "sha512-dfdVGgvF4Jfr32Hb/LszxoefJL1C9JHLWV9e00ZytmiiqEB4cjMwtUdrqJf6gFUmygcQwvIL5Dj37XOsLoPNfA==",
+ "license": "MIT",
+ "workspaces": [
+ "addons/*"
+ ]
},
"node_modules/abort-controller": {
"version": "3.0.0",
diff --git a/package.json b/package.json
index cc22510..29ba8ec 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.24.3",
"@resvg/resvg-js": "^2.6.2",
- "@xterm/headless": "^5.5.0",
+ "@xterm/headless": "^6.1.0-beta.156",
"commander": "^14.0.2",
"express": "^5.2.1",
"gifenc": "^1.0.3",
diff --git a/src/lib/buffer-to-svg.ts b/src/lib/buffer-to-svg.ts
index 88c11f9..1891dcb 100644
--- a/src/lib/buffer-to-svg.ts
+++ b/src/lib/buffer-to-svg.ts
@@ -158,6 +158,43 @@ export function bufferToSvg(
}
}
+ // Render cursor if visible
+ if (terminal.modes.showCursor) {
+ const cursorX = buffer.cursorX;
+ const cursorY = buffer.cursorY;
+
+ // Only render if cursor is within visible area
+ if (cursorX >= 0 && cursorX < cols && cursorY >= 0 && cursorY < rows) {
+ const cursorXPos = padding + cursorX * charWidth;
+ const cursorYPos = padding + cursorY * lineHeight;
+
+ // Get the cell at cursor position for color inversion
+ const cursorLine = buffer.getLine(cursorY);
+ const cursorCell = cursorLine?.getCell(cursorX);
+
+ // Cursor block uses foreground color
+ const cursorBg = theme.foreground;
+ // Text under cursor uses background color (inversion)
+ const cursorFg = theme.background;
+
+ // Draw cursor block
+ lines.push(
+ ``
+ );
+
+ // Draw inverted character if present
+ if (cursorCell) {
+ const char = cursorCell.getChars();
+ if (char && char.trim()) {
+ const textYPos = cursorYPos + opts.fontSize;
+ lines.push(
+ `${escapeXml(char)}`
+ );
+ }
+ }
+ }
+ }
+
return `