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
45 changes: 44 additions & 1 deletion internal/session/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand 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},
Expand Down
48 changes: 48 additions & 0 deletions internal/session/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
20 changes: 2 additions & 18 deletions web/src/components/key-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
data-flue-keybar=""
className={cn(
'absolute bottom-3 left-1/2 z-10 flex max-w-[calc(100%-1.5rem)] -translate-x-1/2 items-center gap-x-1 overflow-x-auto overscroll-x-contain',
'absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-x-1',
'rounded-lg bg-(--chip-bg) p-1 shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm',
)}
>
Expand All @@ -64,20 +62,6 @@ export function KeyBar(props: {
>
ctrl
</button>
<button
type="button"
title="Paste from clipboard"
onPointerDown={(e) => {
e.preventDefault()
props.onPaste()
}}
onClick={(e) => {
if (e.detail === 0) props.onPaste()
}}
className={cn(chip, 'text-(--chip-dim) hover:text-(--chip-fg) active:bg-(--chip-wash)')}
>
paste
</button>
{KEYS.map((k) => (
<button
key={k.key}
Expand Down
98 changes: 38 additions & 60 deletions web/src/components/terminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import { createFakeEmulator, type FakeEmulator } from '@/testing/emulator'
import { attached, fakeClient, sizeChanged, type FakeSocket } from '@/testing/socket'
import { RESIZE_SETTLE_MS, Terminal, TERMINAL_SHORTCUT_HINT } from './terminal'

const REAL_CLIPBOARD = Object.getOwnPropertyDescriptor(navigator, 'clipboard')

/**
* One emulator per mount, all of them kept.
*
Expand Down Expand Up @@ -119,8 +117,6 @@ afterEach(() => {
document.documentElement.style.backgroundColor = ''
document.body.style.backgroundColor = ''
localStorage.clear()
if (REAL_CLIPBOARD) Object.defineProperty(navigator, 'clipboard', REAL_CLIPBOARD)
else Reflect.deleteProperty(navigator, 'clipboard')
})

/**
Expand Down Expand Up @@ -187,6 +183,43 @@ describe('Terminal', () => {
expect(em.live().text()).toBe('\x1bcfresh')
})

it('stops reporting the pointer once the replayed backlog has drained', () => {
// The bug: a daemon restart replays a snapshot's scrollback, and that
// scrollback carries the mouse-tracking sequence of a program that was
// killed with the daemon and so never wrote its own reset. The emulator
// ends the replay armed, with a brand new shell behind it, and every
// pointer move over the terminal is an SGR report typed at the prompt.
const { sock, em } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)

act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0, head: 8 })))
act(() => sock.emitOutput(1, 'backlog!'))

// After the backlog, never before it: clearing the modes first would be
// undone by the very bytes that set them.
expect(em.live().reportingStops).toEqual([1])
})

it('leaves the modes alone until the whole backlog is in', () => {
const { sock, em } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)

act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0, head: 12 })))
act(() => sock.emitOutput(1, 'part'))

expect(em.live().reportingStops).toEqual([])
})

it('says nothing about the modes when there is no backlog to replay', () => {
// A freshly spawned session has head === seq. Nothing was replayed, so
// there is no stale state to answer for, and a program that armed
// tracking on its first line must keep it.
const { sock, em } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)

act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0 })))
act(() => sock.emitOutput(1, '\x1b[?1003h'))

expect(em.live().reportingStops).toEqual([])
})

it('does not reset when the attach is an ordinary continuation', () => {
const { sock, em } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)

Expand Down Expand Up @@ -1461,61 +1494,6 @@ describe('Terminal', () => {
expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
})

it('pastes through the emulator from the mobile clipboard', async () => {
coarsePointer()
const readText = vi.fn().mockResolvedValue('git status\n')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText },
})
const { sock, em } = mountTerminal((e) => (
<Terminal sessionId="s1" createEmulator={e.create} />
))
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))

fireEvent.pointerDown(key('paste'))

await waitFor(() => expect(em.live().pasted).toEqual(['git status\n']))
expect(readText).toHaveBeenCalledTimes(1)
expect(sock.input()).toEqual([{ ref: 1, text: 'git status\n' }])
})

it('does not apply the latched Ctrl modifier to a one-character paste', async () => {
coarsePointer()
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText: vi.fn().mockResolvedValue('c') },
})
const { sock } = mountTerminal((e) => (
<Terminal sessionId="s1" createEmulator={e.create} />
))
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))

fireEvent.pointerDown(key('ctrl'))
fireEvent.pointerDown(key('paste'))

await waitFor(() => expect(sock.input()).toEqual([{ ref: 1, text: 'c' }]))
expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
})

it('explains when the browser refuses the clipboard', async () => {
coarsePointer()
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { readText: vi.fn().mockRejectedValue(new Error('denied')) },
})
const { sock } = mountTerminal((e) => (
<Terminal sessionId="s1" createEmulator={e.create} />
))
act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))

fireEvent.pointerDown(key('paste'))

expect((await screen.findByRole('alert')).textContent).toBe(
'This browser would not hand flue the clipboard.',
)
})

it('drops bar keys pressed before the attach comes back', () => {
coarsePointer()
const { sock } = mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
Expand All @@ -1526,7 +1504,7 @@ describe('Terminal', () => {
it('reserves bottom room in the inset so the bar covers no rows', () => {
coarsePointer()
mountTerminal((e) => <Terminal sessionId="s1" createEmulator={e.create} />)
expect(inset().className).toContain('bottom-20')
expect(inset().className).toContain('bottom-16')
})
})
})
Expand Down
Loading