fix(terminal): preserve zellij mouse mode on reconnect

This commit is contained in:
yyovil 2026-06-11 16:38:42 +05:30 committed by GitHub
parent 0ee86a6314
commit 4d3696df28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 34 additions and 11 deletions

View File

@ -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<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(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);

View File

@ -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");

View File

@ -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;