fix(run): keep Shift+Tab inside the terminal so claude's permission cycle works
xterm emits ESC[Z for Shift+Tab but, unlike plain Tab, never marks the keyboard event cancelled, so the browser ran its default action and moved focus out of the terminal. The pane saw only the first press, which made `shift+tab to cycle` (claude's permission mode) look dead on /run while working fine in a real terminal. A custom key handler now calls preventDefault() on that one combo and still returns true, so xterm's own handling — including the ESC[Z it sends — is untouched; only the focus-moving default is suppressed. Verified against a scratch tmux session: writing ESC[Z into the attach pty cycles claude's status line from `bypass permissions on` to `auto mode on`. Trade-off: Shift+Tab no longer tab-reverses out of the terminal; click elsewhere to move focus.
This commit is contained in:
@@ -40,6 +40,17 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||
// events, so wheel scrolling still works wherever tracking is on.
|
||||
term.attachCustomWheelEventHandler(() => false);
|
||||
|
||||
// xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the
|
||||
// event cancelled, so the browser still runs its default action and moves
|
||||
// focus out of the terminal. The pane then only ever sees the first press,
|
||||
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
|
||||
// here while working in a real terminal. Returning true keeps xterm's own
|
||||
// handling; the preventDefault only stops focus from escaping.
|
||||
term.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.type === "keydown" && ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let ws: WebSocket | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -15,6 +15,7 @@ const onDataHandlers: Array<(d: string) => void> = [];
|
||||
const openMock = vi.fn();
|
||||
const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: vi.fn().mockImplementation(() => ({
|
||||
@@ -27,6 +28,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
dispose: disposeMock,
|
||||
loadAddon: vi.fn(),
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
@@ -60,6 +62,7 @@ describe("TerminalView", () => {
|
||||
onDataHandlers.length = 0;
|
||||
writeMock.mockClear();
|
||||
wheelHandlerMock.mockClear();
|
||||
keyHandlerMock.mockClear();
|
||||
openMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -103,6 +106,23 @@ describe("TerminalView", () => {
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
|
||||
const shiftTab = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true });
|
||||
const preventShiftTab = vi.spyOn(shiftTab, "preventDefault");
|
||||
// true = xterm still processes it and sends ESC[Z; only the browser's
|
||||
// focus-moving default is suppressed.
|
||||
expect(handler(shiftTab)).toBe(true);
|
||||
expect(preventShiftTab).toHaveBeenCalled();
|
||||
|
||||
const plainTab = new KeyboardEvent("keydown", { key: "Tab" });
|
||||
const preventPlainTab = vi.spyOn(plainTab, "preventDefault");
|
||||
expect(handler(plainTab)).toBe(true);
|
||||
expect(preventPlainTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-attaches after the socket drops (a server restart must not freeze the pane)", () => {
|
||||
vi.useFakeTimers();
|
||||
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
|
||||
Reference in New Issue
Block a user