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.