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 `