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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
25 changes: 16 additions & 9 deletions frontend/src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -137,10 +142,10 @@ export function TerminalView({
const startedRef = useRef(false)
// Snapshot + queued output of a hidden (destroyed) terminal.
const serializedRef = useRef<string | null>(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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}

Expand Down
12 changes: 11 additions & 1 deletion frontend/src/lib/terminal/term-modes.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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)
Expand Down
26 changes: 20 additions & 6 deletions frontend/src/lib/terminal/term-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/lib/terminal/term-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down