Skip to content

fix: keep the cursor hidden across a card switch - #312

Merged
omartelo merged 1 commit into
mainfrom
fix/terminal-cursor-visibility
Aug 17, 2026
Merged

omartelo merged 1 commit into
mainfrom
fix/terminal-cursor-visibility

Conversation

@omartelo

@omartelo omartelo commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

The bug

A bubbletea TUI running in a card came back from a card switch with a blinking block cursor parked in the middle of its output — one it had hidden at startup and never asked back.

Root cause

Hiding a session serializes its terminal and destroys it; showing it writes the snapshot into a fresh one. SerializeAddon@0.14.0 replays only the modes it can read off term.modes — checked against the shipped bundle, that is ?1h ?6h ?7l ?9h ?45h ?66h ?1000h ?1002h ?1003h ?1004h ?1049h ?2004h. ?25 is not among them: xterm keeps cursor visibility on the private coreService.isCursorHidden, which the public API does not expose.

A fresh terminal shows its cursor. So every full-screen TUI that hid the real one to draw its own — bubbletea and anything like it — got lich's blinking block back, at whatever position the snapshot restored.

Same family as the mouse-encoding gap already documented in term-modes.ts; this is its sibling.

The fix

  • term-view.ts — cursorHidden(term), a third private read beside cellDimensions and mouseEncoding, in the file invariant feat: multi-session terminals per project #1 already exempts.
  • term-modes.ts — cursorVisibilitySequence(hidden), \x1b[?25l or "", and the file's doc comment now covers both modes the snapshot cannot carry.
  • TerminalView.tsx — mouseEncodingRef becomes carriedModesRef and holds both sequences. Written between the snapshot and the replayed tail, so anything the app changed while hidden still wins, and kept across the overflow path where the snapshot is dropped: these describe the app, not the buffer.

Left out, deliberately

Cursor shape (DECSCUSR) is still not carried — a TUI that chose a bar or underline cursor gets lich's block back. It only bites a TUI that shows a cursor at all, which is not this bug. Named in docs/ceilings.md beside the bullet it belongs to.

Test plan

  • pnpm test — 989 passed
  • go test ./... — 1617 passed
  • tsc --noEmit, vite build, biome check clean; gofmt -l . and go vet ./... clean
  • New cases in term-modes.test.ts for both branches of cursorVisibilitySequence
  • By hand in task dev: run a bubbletea TUI in a card, switch away and back — no stray cursor. (The frontend gate is node-only and cannot render.)

Hiding a session serializes its terminal and destroys it; showing it
writes the snapshot into a fresh one. xterm's SerializeAddon replays only
the modes it can read off term.modes, and cursor visibility is not one of
them: it lives on the private coreService, so ?25 is never recorded. A
fresh terminal shows its cursor, so every full-screen TUI that hid the
real one to draw its own came back with lich's blinking block parked
wherever the snapshot left the cursor.

Restore it the way the mouse encoding already is — read at hide, written
back between the snapshot and the replayed tail, so output the app
produced while hidden still wins. Both now ride one carried-modes string.

Cursor shape (DECSCUSR) is still not carried and is named as such in
docs/ceilings.md: it only bites a TUI that shows a cursor at all.
@omartelo
omartelo merged commit 665a4b1 into main Aug 17, 2026
4 checks passed
@omartelo
omartelo deleted the fix/terminal-cursor-visibility branch August 17, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant