From 572f864f4358f4eb4d7abf43d62ce91c3c76f23b Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 17 Aug 2026 18:30:03 -0300 Subject: [PATCH] fix: keep the cursor hidden across a card switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 8 ++++++ docs/ceilings.md | 6 ++++- frontend/src/components/TerminalView.tsx | 25 ++++++++++++------- frontend/src/lib/terminal/term-modes.test.ts | 12 ++++++++- frontend/src/lib/terminal/term-modes.ts | 26 +++++++++++++++----- frontend/src/lib/terminal/term-view.ts | 11 +++++++++ 6 files changed, 71 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c90c87f54..9ed8122d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A full-screen TUI no longer gets a stray blinking cursor back.** Switching + away from a card and back rebuilt its terminal from a snapshot, and that + snapshot does not record whether the app had hidden the cursor — so every TUI + that draws its own (bubbletea, and anything else that hides the real one) came + back with lich's blinking block parked in the middle of its output. Cursor + visibility now rides along with the mouse encoding as state the snapshot is + known not to carry. + - **A relayed message no longer sends the sentence you were typing with it.** A peer's answer or task arriving while you had a half-written prompt was pasted onto your line and submitted with it, and your half was gone. lich now waits diff --git a/docs/ceilings.md b/docs/ceilings.md index 8117b86bd..687cffd4c 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -44,7 +44,11 @@ work when nobody knows it and that the call site never shows. The mechanism and a project is reachable only through the directory picker, and neither the menu nor the palette says so. - **Hidden sessions are serialized and destroyed**: 2MB replay rings on both sides (`frontend/src/lib/terminal/replay-buffer.ts` page-side, `internal/terminal/replay.go` backend-side — the latter - survives a full page reload). Scrollback past the ring is gone, not paged. + survives a full page reload). Scrollback past the ring is gone, not paged. The snapshot carries only the modes + xterm's SerializeAddon reads off `term.modes`; the ones an app relies on and it does not record are restored by + hand in `frontend/src/lib/terminal/term-modes.ts` — today the mouse encoding and cursor visibility. Cursor + *shape* (DECSCUSR) is not among them: a TUI that chose a bar or underline cursor gets lich's block back after a + card switch. - **Single instance via the pinned port**: the bind is the lock (`internal/singleton`); a duplicate launch focuses the running window (best-effort, untested against a real window) and exits 0. - **lich appends to the agent's system prompt, for two providers only** diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx index 8ef9a35e1..382c5708c 100644 --- a/frontend/src/components/TerminalView.tsx +++ b/frontend/src/components/TerminalView.tsx @@ -21,6 +21,7 @@ import { recordChunk } from "@/lib/terminal/term-perf" import { copyToastMessage, COPY_TOAST_DURATION_MS } from "@/lib/terminal/copy-toast" import { decodeBase64 } from "@/lib/terminal/term-frame" import { + cursorHidden, ensureFontLoaded, fitTerminal, mouseEncoding, @@ -30,7 +31,11 @@ import { exitMarker, readSessionExit, type SessionExit } from "@/lib/terminal/se import { TerminalExitBanner } from "./TerminalExitBanner" import { TerminalSearchBar, type SearchResults } from "./TerminalSearchBar" import { useTerminalDrop } from "./useTerminalDrop" -import { linkClickIsOurs, mouseEncodingSequence } from "@/lib/terminal/term-modes" +import { + cursorVisibilitySequence, + linkClickIsOurs, + mouseEncodingSequence, +} from "@/lib/terminal/term-modes" import { createSessionLinkProvider } from "@/lib/terminal/session-link-provider" import { sessionLinkTargets } from "@/lib/terminal/session-links" import { useSettings } from "@/providers/settings" @@ -137,10 +142,10 @@ export function TerminalView({ const startedRef = useRef(false) // Snapshot + queued output of a hidden (destroyed) terminal. const serializedRef = useRef(null) - // The mouse encoding the snapshot cannot carry (term-modes.ts). Kept apart - // from it because it survives the overflow path, where the snapshot is - // dropped: it describes the app, not the buffer. - const mouseEncodingRef = useRef("") + // The modes the snapshot cannot carry (term-modes.ts). Kept apart from it + // because they survive the overflow path, where the snapshot is dropped: + // they describe the app, not the buffer. + const carriedModesRef = useRef("") const replayRef = useRef(makeReplayBuffer()) const visibleRef = useRef(visible) const stillInWorkspaceRef = useRef(stillInWorkspace) @@ -422,7 +427,9 @@ export function TerminalView({ return } serializedRef.current = live.serialize.serialize() - mouseEncodingRef.current = mouseEncodingSequence(mouseEncoding(live.term)) + carriedModesRef.current = + mouseEncodingSequence(mouseEncoding(live.term)) + + cursorVisibilitySequence(cursorHidden(live.term)) live.dispose() liveRef.current = null } @@ -446,14 +453,14 @@ export function TerminalView({ } // Between the snapshot and the queued tail: the tail is newer output, so // anything the app changed there still wins. - if (mouseEncodingRef.current) { - live.term.write(mouseEncodingRef.current) + if (carriedModesRef.current) { + live.term.write(carriedModesRef.current) } for (const chunk of replayRef.current.drain()) { live.term.write(chunk) } serializedRef.current = null - mouseEncodingRef.current = "" + carriedModesRef.current = "" liveRef.current = live } diff --git a/frontend/src/lib/terminal/term-modes.test.ts b/frontend/src/lib/terminal/term-modes.test.ts index 5bec84764..5a98af593 100644 --- a/frontend/src/lib/terminal/term-modes.test.ts +++ b/frontend/src/lib/terminal/term-modes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { linkClickIsOurs, mouseEncodingSequence } from "./term-modes" +import { cursorVisibilitySequence, linkClickIsOurs, mouseEncodingSequence } from "./term-modes" describe("mouseEncodingSequence", () => { it("restores SGR, the encoding every modern TUI selects", () => { @@ -23,6 +23,16 @@ describe("mouseEncodingSequence", () => { }) }) +describe("cursorVisibilitySequence", () => { + it("hides the cursor again for a TUI that had turned it off", () => { + expect(cursorVisibilitySequence(true)).toBe("\x1b[?25l") + }) + + it("writes nothing when it was visible, which a fresh terminal already is", () => { + expect(cursorVisibilitySequence(false)).toBe("") + }) +}) + describe("linkClickIsOurs", () => { it("opens the link on Ctrl+Click, and on Cmd+Click for macOS", () => { expect(linkClickIsOurs({ ctrlKey: true, metaKey: false })).toBe(true) diff --git a/frontend/src/lib/terminal/term-modes.ts b/frontend/src/lib/terminal/term-modes.ts index e6e41cf09..abadbfe41 100644 --- a/frontend/src/lib/terminal/term-modes.ts +++ b/frontend/src/lib/terminal/term-modes.ts @@ -2,12 +2,18 @@ * Terminal modes a snapshot cannot carry. * * Hiding a session serializes the terminal and destroys it; showing it writes - * the snapshot into a fresh one. xterm's SerializeAddon restores the mouse - * *protocol* (`?1000`/`?1002`/`?1003`) but never the *encoding* the app turned - * on with it, so the rebuilt terminal falls back to the legacy X10 encoding — - * which xterm reports on `onBinary`, a channel the transport does not carry. - * The app then sees no clicks, no scroll and no drag at all until it happens to - * re-emit its own encoding, while the keyboard keeps working. + * the snapshot into a fresh one. xterm's SerializeAddon replays the modes it + * can read off `term.modes`, and two an app depends on are not among them: + * + * - The mouse *encoding*. The addon restores the mouse *protocol* + * (`?1000`/`?1002`/`?1003`) but never the encoding the app turned on with it, + * so the rebuilt terminal falls back to the legacy X10 encoding — which xterm + * reports on `onBinary`, a channel the transport does not carry. The app then + * sees no clicks, no scroll and no drag at all until it happens to re-emit + * its own encoding, while the keyboard keeps working. + * - Cursor visibility (`?25`). A fresh terminal shows its cursor, so an app + * that hid it — every full-screen TUI does, drawing its own — gets lich's + * blinking block back, parked wherever the snapshot left the real cursor. */ // Keyed by xterm's CoreMouseEncoding names; DEFAULT (X10) needs no sequence. @@ -26,6 +32,14 @@ export function mouseEncodingSequence(encoding: string | undefined): string { return encoding ? (MOUSE_ENCODING_SEQUENCES[encoding] ?? "") : "" } +/** + * The DECRST sequence that hides the cursor again, or "" when it was visible — + * which is what a fresh terminal already is. + */ +export function cursorVisibilitySequence(hidden: boolean): string { + return hidden ? "\x1b[?25l" : "" +} + /** * Whether a click on a link is ours to open: Ctrl+Click, or Cmd+Click on macOS. * The click is also swallowed before it reaches the PTY (TerminalView), because diff --git a/frontend/src/lib/terminal/term-view.ts b/frontend/src/lib/terminal/term-view.ts index 47e73cecd..eb6f7dfec 100644 --- a/frontend/src/lib/terminal/term-view.ts +++ b/frontend/src/lib/terminal/term-view.ts @@ -45,6 +45,17 @@ export function mouseEncoding(term: Terminal): string | undefined { ?.coreMouseService?.activeEncoding } +// cursorHidden reads whether the app turned the cursor off (DECRST 25) — a +// third private, and the third mode a snapshot cannot carry (term-modes.ts). +// False if xterm ever moves it, which restores nothing and matches a fresh +// terminal's own default. +export function cursorHidden(term: Terminal): boolean { + return ( + (term as unknown as { _core?: { coreService?: { isCursorHidden?: boolean } } })._core + ?.coreService?.isCursorHidden === true + ) +} + // fitTerminal resizes the grid to fill the container edge to edge on the // right/bottom (replacing xterm's FitAddon, which reserves a scrollbar // gutter on the right — see term-fit.ts), minus the fixed left gutter above.