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) => (