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 { WebLinksAddon } from "@xterm/addon-web-links";
import type { WorkspaceSession } from "../types/workspace"; import type { WorkspaceSession } from "../types/workspace";
import type { Theme } from "../stores/ui-store"; 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 = { type TerminalPaneProps = {
session?: WorkspaceSession; session?: WorkspaceSession;
@ -70,6 +70,8 @@ function bannerText(state: TerminalSessionState, error?: string): string | undef
return undefined; return undefined;
} }
const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H";
function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) { function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | 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', fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 13, fontSize: 13,
lineHeight: 1.35, 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), theme: terminalTheme(theme),
}); });
terminalRef.current = terminal; terminalRef.current = terminal;
@ -112,7 +117,20 @@ function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) {
if (session?.terminalHandleId) { if (session?.terminalHandleId) {
rafId = requestAnimationFrame(() => { rafId = requestAnimationFrame(() => {
fitTerminal(); 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 { } else {
rafId = requestAnimationFrame(fitTerminal); rafId = requestAnimationFrame(fitTerminal);

View File

@ -79,7 +79,7 @@ function createFakeMux(): FakeMux {
type FakeTerminal = AttachableTerminal & { type FakeTerminal = AttachableTerminal & {
lines: string[]; lines: string[];
resets: number; clears: number;
typeKeys(data: string): void; typeKeys(data: string): void;
emitResize(cols: number, rows: number): void; emitResize(cols: number, rows: number): void;
}; };
@ -91,11 +91,11 @@ function createFakeTerminal(): FakeTerminal {
cols: 80, cols: 80,
rows: 24, rows: 24,
lines: [], lines: [],
resets: 0, clears: 0,
write: (bytes) => terminal.lines.push(new TextDecoder().decode(bytes)), write: (bytes) => terminal.lines.push(new TextDecoder().decode(bytes)),
writeln: (line) => terminal.lines.push(line), writeln: (line) => terminal.lines.push(line),
reset: () => { clear: () => {
terminal.resets += 1; terminal.clears += 1;
}, },
onData: (listener) => { onData: (listener) => {
dataListeners.add(listener); dataListeners.add(listener);
@ -189,7 +189,7 @@ describe("useTerminalSession", () => {
expect(muxes).toHaveLength(1); 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(); const { view, terminal, muxes } = setup();
act(() => muxes[0].emitOpened("handle-1")); act(() => muxes[0].emitOpened("handle-1"));
act(() => muxes[0].emitConnection("closed")); act(() => muxes[0].emitConnection("closed"));
@ -197,7 +197,7 @@ describe("useTerminalSession", () => {
act(() => void vi.advanceTimersByTime(500)); act(() => void vi.advanceTimersByTime(500));
expect(muxes).toHaveLength(2); expect(muxes).toHaveLength(2);
expect(muxes[0].disposed).toBe(true); 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]]); expect(muxes[1].opens).toEqual([["handle-1", 80, 24]]);
act(() => muxes[1].emitOpened("handle-1")); act(() => muxes[1].emitOpened("handle-1"));
expect(view.result.current.state).toBe("attached"); expect(view.result.current.state).toBe("attached");

View File

@ -23,7 +23,11 @@ export type AttachableTerminal = {
rows: number; rows: number;
write: (data: Uint8Array) => void; write: (data: Uint8Array) => void;
writeln: (line: string) => 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 }; onData: (listener: (data: string) => void) => { dispose: () => void };
onResize: (listener: (size: { cols: number; rows: number }) => 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`); terminal.writeln(`\x1b[2mAttaching to ${sessionRef.current?.title ?? handle}\x1b[0m`);
} else { } else {
// The server replays the recent-output ring on open (backend // The server replays the recent-output ring on open (backend
// internal/terminal/ring.go); drop the stale screen so it isn't doubled. // internal/terminal/ring.go); clear the stale screen so it isn't doubled.
terminal.reset(); // Screen-clear only, never reset(): RIS wipes zellij's mouse mode.
terminal.clear();
} }
r.firstAttach = false; r.firstAttach = false;