From 73522133e97635c51e4a1dac8e10808a377e1a4f Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 12 Aug 2026 21:00:00 +0530 Subject: [PATCH 1/2] fix(session,web): a revived session stops typing the mouse at its own prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session that came back from a daemon restart typed at itself. Every pointer move over the terminal put a run of "35;61;22M35;61;21M…" at the shell prompt, and on a phone every touch did the same. Those are SGR mouse reports. A snapshot's ring is bytes, not state, so reviving one re-runs every mode change the dead shell ever wrote — including the sequence that turned mouse tracking on inside a program that was killed with the daemon and so never wrote the sequence that turns it back off. The client replays that, ends the replay armed, and reports the pointer to a brand new shell that has no idea what an SGR report is. Fixed at both ends, because either alone leaves a hole. Revive now writes a reset between the replayed scrollback and its marker, which covers the modes a dead program can strand: mouse tracking in every protocol and encoding, focus reporting, bracketed paste, application cursor keys, and the display state — scrolling region, charset, autowrap, cursor visibility — that would otherwise make the new shell unreadable rather than merely noisy. And the client clears mouse and focus reporting the moment a replayed backlog has drained, which covers the other way in: a program killed mid-session, with no restart involved. Only those two there, deliberately. Application cursor keys and bracketed paste are stale in the same way but change what a keystroke means rather than inventing keystrokes, so clearing them against a live program would break arrows and pastes in a client that had nothing wrong with it. The alternate screen is deliberately not left. A dead full-screen program will have been in it, but swapping in a main buffer whose contents were evicted from the ring long ago would trade a tidy terminal for an empty one where somebody's scrollback used to be. Also undoes two things #66 did to the controls. The floating chips went to 48px squares and the key bar to eight tall chips, which no longer fit and turned the bar into a scrolling strip across the bottom of the screen — and every chip on it fires on pointerdown, so dragging that strip sent whatever key the thumb started on. The paste chip fired the clipboard. Paste goes back to the platform. xterm keeps its input in a real textarea but ships it zero-sized and parked off-page, so a long press has nothing to land on and no phone will offer a paste menu over a div. On a coarse pointer that element now gets the terminal's own box, so the press finds editable text and the paste travels xterm's own path, which is what keeps newline handling and bracketed-paste mode right. Co-Authored-By: Claude Opus 5 (1M context) --- internal/session/snapshot.go | 45 ++++++++++++- internal/session/snapshot_test.go | 48 ++++++++++++++ web/src/components/key-bar.tsx | 20 +----- web/src/components/terminal.test.tsx | 98 +++++++++++----------------- web/src/components/terminal.tsx | 80 ++++++++--------------- web/src/components/theme-menu.tsx | 2 +- web/src/emulator/emulator.test.ts | 39 +++++++---- web/src/emulator/types.ts | 31 +++++++-- web/src/emulator/xterm.ts | 27 +++++++- web/src/styles.build.test.ts | 23 +++++++ web/src/styles.css | 64 ++++++++++++++++++ web/src/testing/emulator.ts | 25 +++++-- 12 files changed, 343 insertions(+), 159 deletions(-) diff --git a/internal/session/snapshot.go b/internal/session/snapshot.go index 8fb6fa0..5062046 100644 --- a/internal/session/snapshot.go +++ b/internal/session/snapshot.go @@ -65,6 +65,46 @@ type Snapshot struct { var reviveMarker = []byte( "\r\n\x1b[2m── daemon restarted · previous shell ended here ──\x1b[0m\r\n\r\n") +// settleModes puts the emulator back into a state a fresh shell can be +// trusted in, and is written after the replayed scrollback and before the +// marker. +// +// A snapshot's ring is the bytes the dead shell wrote, and a program that was +// killed with the daemon wrote no epilogue: the sequence that turned a mode +// on is in the ring and the sequence that turns it back off was never +// written at all. Replay that into a client and its emulator ends the replay +// holding modes that belong to a program which no longer exists, with a +// brand new prompt behind them. +// +// The mouse block is the one that bites hardest, because it puts bytes on the +// wire with nobody typing. Mouse tracking left on means every pointer move +// over the terminal is encoded and sent, and the shell has no idea what an +// SGR report is, so it lands at the prompt as "35;61;22M35;61;21M…" — one +// run per pixel of travel. Focus reporting is the same trick with fewer +// bytes: ^[[I and ^[[O every time the tab is looked at. Application cursor +// keys is the quiet one, where the arrows send SS3 to a shell expecting CSI. +// +// The rest is display state rather than input, and is here because a shell +// nobody can read is its own kind of broken: a leftover scrolling region +// pins the new prompt inside a window the old program chose, the line-drawing +// charset renders every word as box art, and a hidden cursor stays hidden. +// +// The alternate screen is deliberately *not* left, though a dead full-screen +// program will have been in it. Leaving it would swap in a main buffer whose +// contents were evicted from the ring long ago, so the price of a tidier +// terminal would be showing the person an empty one where their scrollback +// used to be. What they have now is the last frame of the program that died, +// which is at least what they were looking at. +var settleModes = []byte( + // Mouse reporting, every protocol and every encoding. + "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l" + + // Focus reporting, bracketed paste, application cursor keys. + "\x1b[?1004l\x1b[?2004l\x1b[?1l" + + // Autowrap, a visible cursor, the full scrolling region. + "\x1b[?7h\x1b[?25h\x1b[r" + + // US-ASCII back in G0, and default attributes. + "\x1b(B\x1b[m") + // reviveNote writes the seam, plus — when the snapshot knew of one — the // command that picks the interrupted conversation back up. // @@ -156,8 +196,11 @@ func (r *Registry) Revive(snap Snapshot) (*Session, error) { cwd, _ = os.UserHomeDir() } note := reviveNote(snap.ClaudeSession) - preload := make([]byte, 0, len(snap.Ring)+len(note)) + preload := make([]byte, 0, len(snap.Ring)+len(settleModes)+len(note)) preload = append(preload, snap.Ring...) + // After the scrollback, so the replay cannot undo it, and before the + // marker, so the seam is the first thing the settled terminal draws. + preload = append(preload, settleModes...) preload = append(preload, note...) return r.start( SpawnOpts{Cwd: cwd, Cols: snap.Cols, Rows: snap.Rows}, diff --git a/internal/session/snapshot_test.go b/internal/session/snapshot_test.go index 349b8e4..dac60b8 100644 --- a/internal/session/snapshot_test.go +++ b/internal/session/snapshot_test.go @@ -103,6 +103,54 @@ func TestReviveRestoresIdentityAndScrollback(t *testing.T) { } } +// TestReviveSettlesTheModesTheDeadShellLeftBehind is the fix for a session +// that came back reporting the pointer. +// +// The shell that died was inside a program holding mouse tracking on, and a +// killed program writes no reset. Replay that ring into a client and its +// emulator turns mouse reporting on with a fresh prompt behind it: every +// pointer move becomes an SGR report typed at the shell, which is what +// "35;61;22M35;61;21M…" at a prompt is. The same is true of focus reporting +// and of a leftover scrolling region or charset, none of which the new shell +// asked for and none of which it will clear. +func TestReviveSettlesTheModesTheDeadShellLeftBehind(t *testing.T) { + r := NewRegistry(nil) + s, err := r.Revive(Snapshot{ + V: 1, + ID: "cafebabe00000006", + Cwd: t.TempDir(), + Ring: []byte("a program was here\x1b[?1003h\x1b[?1006h\x1b[?1004h"), + }) + if err != nil { + t.Fatalf("Revive: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + sub := s.Subscribe(0) + defer s.Unsubscribe(sub) + + for _, mode := range []string{ + "\x1b[?1000l", "\x1b[?1002l", "\x1b[?1003l", + "\x1b[?1005l", "\x1b[?1006l", "\x1b[?1015l", "\x1b[?1016l", + "\x1b[?1004l", "\x1b[?2004l", "\x1b[?1l", + "\x1b[?7h", "\x1b[?25h", "\x1b[r", "\x1b(B", "\x1b[m", + } { + if !bytes.Contains(sub.Backlog, []byte(mode)) { + t.Fatalf("the revived backlog never clears %q: %q", mode, sub.Backlog) + } + } + + // Order is the whole point: clearing before the replay would be undone by + // the very bytes it exists to answer for. + settle := bytes.Index(sub.Backlog, []byte("\x1b[?1003l")) + stale := bytes.Index(sub.Backlog, []byte("\x1b[?1003h")) + marker := bytes.Index(sub.Backlog, []byte("daemon restarted")) + if stale < 0 || !(stale < settle && settle < marker) { + t.Fatalf("want the stale set, then the reset, then the marker; got %d, %d, %d in %q", + stale, settle, marker, sub.Backlog) + } +} + func TestReviveFallsBackToHomeWhenTheCwdIsGone(t *testing.T) { r := NewRegistry(nil) s, err := r.Revive(Snapshot{V: 1, ID: "cafebabe00000002", Cwd: "/no/such/dir/anywhere"}) diff --git a/web/src/components/key-bar.tsx b/web/src/components/key-bar.tsx index cf8adee..4af1d45 100644 --- a/web/src/components/key-bar.tsx +++ b/web/src/components/key-bar.tsx @@ -32,15 +32,13 @@ export function KeyBar(props: { ctrl: boolean onCtrl: () => void onKey: (key: BarKey) => void - onPaste: () => void }) { - const chip = - 'min-h-12 shrink-0 rounded-md px-3 py-1.5 font-mono text-sm/4 transition-colors select-none' + const chip = 'rounded-md px-2.5 py-1.5 font-mono text-sm/4 transition-colors select-none' return (
@@ -64,20 +62,6 @@ export function KeyBar(props: { > ctrl - {KEYS.map((k) => (