fix(terminal): preserve zellij mouse mode on reconnect
This commit is contained in:
parent
0ee86a6314
commit
4d3696df28
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue