diff --git a/frontend/src/renderer/components/TerminalPane.tsx b/frontend/src/renderer/components/TerminalPane.tsx index 05c86b5cb..fc9631191 100644 --- a/frontend/src/renderer/components/TerminalPane.tsx +++ b/frontend/src/renderer/components/TerminalPane.tsx @@ -7,7 +7,7 @@ import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; import type { WorkspaceSession } from "../types/workspace"; import type { Theme } from "../stores/ui-store"; -import { useTerminalSession, type TerminalSessionState } from "../hooks/useTerminalSession"; +import { useTerminalSession, type AttachableTerminal, type TerminalSessionState } from "../hooks/useTerminalSession"; type TerminalPaneProps = { session?: WorkspaceSession; @@ -70,6 +70,8 @@ function bannerText(state: TerminalSessionState, error?: string): string | undef return undefined; } +const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H"; + function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -84,6 +86,9 @@ function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) { fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 13, lineHeight: 1.35, + // Zellij owns scrollback inside the attached pane; keeping xterm + // scrollback creates a dead scrollbar beside the alt-buffer app. + scrollback: 0, theme: terminalTheme(theme), }); terminalRef.current = terminal; @@ -112,7 +117,20 @@ function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) { if (session?.terminalHandleId) { rafId = requestAnimationFrame(() => { fitTerminal(); - detach = attach(terminal); + const attachable: AttachableTerminal = { + get cols() { + return terminal.cols; + }, + get rows() { + return terminal.rows; + }, + write: (data) => terminal.write(data), + writeln: (line) => terminal.writeln(line), + clear: () => terminal.write(CLEAR_SEQUENCE), + onData: (listener) => terminal.onData(listener), + onResize: (listener) => terminal.onResize(listener), + }; + detach = attach(attachable); }); } else { rafId = requestAnimationFrame(fitTerminal); diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx index 663d627f7..af97483a1 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx +++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx @@ -79,7 +79,7 @@ function createFakeMux(): FakeMux { type FakeTerminal = AttachableTerminal & { lines: string[]; - resets: number; + clears: number; typeKeys(data: string): void; emitResize(cols: number, rows: number): void; }; @@ -91,11 +91,11 @@ function createFakeTerminal(): FakeTerminal { cols: 80, rows: 24, lines: [], - resets: 0, + clears: 0, write: (bytes) => terminal.lines.push(new TextDecoder().decode(bytes)), writeln: (line) => terminal.lines.push(line), - reset: () => { - terminal.resets += 1; + clear: () => { + terminal.clears += 1; }, onData: (listener) => { dataListeners.add(listener); @@ -189,7 +189,7 @@ describe("useTerminalSession", () => { expect(muxes).toHaveLength(1); }); - it("reattaches with a fresh mux after a socket drop, resetting the stale screen", () => { + it("reattaches with a fresh mux after a socket drop, clearing the stale screen", () => { const { view, terminal, muxes } = setup(); act(() => muxes[0].emitOpened("handle-1")); act(() => muxes[0].emitConnection("closed")); @@ -197,7 +197,7 @@ describe("useTerminalSession", () => { act(() => void vi.advanceTimersByTime(500)); expect(muxes).toHaveLength(2); expect(muxes[0].disposed).toBe(true); - expect(terminal.resets).toBe(1); // the server replays the output ring on open + expect(terminal.clears).toBe(1); // the server replays the output ring on open expect(muxes[1].opens).toEqual([["handle-1", 80, 24]]); act(() => muxes[1].emitOpened("handle-1")); expect(view.result.current.state).toBe("attached"); diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts index 97c315200..dcfb8eee4 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.ts +++ b/frontend/src/renderer/hooks/useTerminalSession.ts @@ -23,7 +23,11 @@ export type AttachableTerminal = { rows: number; write: (data: Uint8Array) => void; writeln: (line: string) => void; - reset: () => void; + /** + * Erase screen + scrollback while preserving terminal modes. A full reset + * drops zellij mouse tracking, so wheel scroll stops after reconnect. + */ + clear: () => void; onData: (listener: (data: string) => void) => { dispose: () => void }; onResize: (listener: (size: { cols: number; rows: number }) => void) => { dispose: () => void }; }; @@ -154,8 +158,9 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option terminal.writeln(`\x1b[2mAttaching to ${sessionRef.current?.title ?? handle}…\x1b[0m`); } else { // The server replays the recent-output ring on open (backend - // internal/terminal/ring.go); drop the stale screen so it isn't doubled. - terminal.reset(); + // internal/terminal/ring.go); clear the stale screen so it isn't doubled. + // Screen-clear only, never reset(): RIS wipes zellij's mouse mode. + terminal.clear(); } r.firstAttach = false;