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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ examples/*/screenshots/
output/
.mcp.json
.playwright-mcp/
.worktrees/
43 changes: 43 additions & 0 deletions docs/plans/2026-02-13-cursor-visibility-design.md
Original file line number Diff line number Diff line change
@@ -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.
142 changes: 142 additions & 0 deletions docs/plans/2026-02-13-cursor-visibility.md
Original file line number Diff line number Diff line change
@@ -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(
`<rect x="${cursorXPos}" y="${cursorYPos}" width="${charWidth}" height="${lineHeight}" fill="${cursorBg}"/>`
);

// Draw inverted character if present
if (cursorCell) {
const char = cursorCell.getChars();
if (char && char.trim()) {
const textYPos = cursorYPos + opts.fontSize;
lines.push(
`<text x="${cursorXPos}" y="${textYPos}" fill="${cursorFg}">${escapeXml(char)}</text>`
);
}
}
}
}
```

**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"
```
12 changes: 10 additions & 2 deletions evaluations/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions evaluations/scenarios/cursor-visibility/prompt.md
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 9 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions src/lib/buffer-to-svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<rect x="${cursorXPos}" y="${cursorYPos}" width="${charWidth}" height="${lineHeight}" fill="${cursorBg}"/>`
);

// Draw inverted character if present
if (cursorCell) {
const char = cursorCell.getChars();
if (char && char.trim()) {
const textYPos = cursorYPos + opts.fontSize;
lines.push(
`<text x="${cursorXPos}" y="${textYPos}" fill="${cursorFg}">${escapeXml(char)}</text>`
);
}
}
}
}

return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" font-family="${opts.fontFamily}" font-size="${opts.fontSize}">
<rect width="100%" height="100%" fill="${theme.background}"/>
<g fill="${theme.foreground}">
Expand Down