fix(terminals): le reattach xterm ne réinjecte plus les réponses synthétiques du replay scrollback — #292 (term.onData n'est actif qu'après le callback final du term.write(scrollback): les réponses aux requêtes historiques ESC[1;1R/modes ne peuplent plus pending ni ne sont réécrites dans le PTY par adopt, ce qui bouclait CPU 100% sur les agents Git TUI Native d'UnitySoundTool/UnityPlugin; activation de l'input aussi sur les chemins fallback reattach-échoué et terminal neuf; test de régression: zéro write PTY pendant le replay synthétique puis exactement un write pour Hello\r; QA verte: 3 fichiers/27 tests TerminalView, npm run build frontend, AppImage 0.3.0 rebuild sha256 4165521e...; réserve: repro live UI trois projets non validée depuis sandbox)
This commit is contained in:
@ -1,11 +1,18 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { render, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways, ReattachResult, TerminalHandle } from "@/ports";
|
||||||
import { MockTerminalGateway } from "@/adapters/mock";
|
import { MockTerminalGateway } from "@/adapters/mock";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
|
|
||||||
const terminalOptions: unknown[] = [];
|
const terminalOptions: unknown[] = [];
|
||||||
|
const xtermState: {
|
||||||
|
keyHandler: ((data: string) => void) | null;
|
||||||
|
answerOnWrite: string | null;
|
||||||
|
} = {
|
||||||
|
keyHandler: null,
|
||||||
|
answerOnWrite: null,
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock("@xterm/xterm", () => ({
|
vi.mock("@xterm/xterm", () => ({
|
||||||
Terminal: class {
|
Terminal: class {
|
||||||
@ -18,11 +25,19 @@ vi.mock("@xterm/xterm", () => ({
|
|||||||
|
|
||||||
loadAddon() {}
|
loadAddon() {}
|
||||||
open() {}
|
open() {}
|
||||||
onData() {
|
onData(callback: (data: string) => void) {
|
||||||
|
xtermState.keyHandler = callback;
|
||||||
return { dispose() {} };
|
return { dispose() {} };
|
||||||
}
|
}
|
||||||
write() {}
|
write(_data: string | Uint8Array, callback?: () => void) {
|
||||||
input() {}
|
if (xtermState.answerOnWrite) {
|
||||||
|
xtermState.keyHandler?.(xtermState.answerOnWrite);
|
||||||
|
}
|
||||||
|
callback?.();
|
||||||
|
}
|
||||||
|
input(data: string) {
|
||||||
|
xtermState.keyHandler?.(data);
|
||||||
|
}
|
||||||
focus() {}
|
focus() {}
|
||||||
dispose() {}
|
dispose() {}
|
||||||
},
|
},
|
||||||
@ -49,6 +64,12 @@ import {
|
|||||||
TERMINAL_SCROLLBACK_LINES,
|
TERMINAL_SCROLLBACK_LINES,
|
||||||
} from "./TerminalView";
|
} from "./TerminalView";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
terminalOptions.length = 0;
|
||||||
|
xtermState.keyHandler = null;
|
||||||
|
xtermState.answerOnWrite = null;
|
||||||
|
});
|
||||||
|
|
||||||
describe("TerminalView scrollback", () => {
|
describe("TerminalView scrollback", () => {
|
||||||
it("configures xterm with a deep scrollback for chatty OpenCode agents", async () => {
|
it("configures xterm with a deep scrollback for chatty OpenCode agents", async () => {
|
||||||
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
|
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
|
||||||
@ -64,4 +85,51 @@ describe("TerminalView scrollback", () => {
|
|||||||
});
|
});
|
||||||
expect(TERMINAL_SCROLLBACK_LINES).toBeGreaterThan(1_000);
|
expect(TERMINAL_SCROLLBACK_LINES).toBeGreaterThan(1_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not relay synthetic xterm answers from reattach replay, then writes human input once", async () => {
|
||||||
|
const write = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const handle: TerminalHandle = {
|
||||||
|
sessionId: "live-loop-regression",
|
||||||
|
write,
|
||||||
|
resize: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detach: vi.fn(),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const reattach = vi.fn(async () => {
|
||||||
|
const result: ReattachResult = {
|
||||||
|
handle,
|
||||||
|
// Represents retained TUI output containing a terminal query. The xterm
|
||||||
|
// stub answers from write(), exactly where the real ESC[1;1R burst was
|
||||||
|
// produced on the affected Git agents.
|
||||||
|
scrollback: new TextEncoder().encode("\x1b[6n"),
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const gateways = {
|
||||||
|
terminal: {
|
||||||
|
openTerminal: vi.fn(),
|
||||||
|
reattach,
|
||||||
|
closeTerminal: vi.fn(),
|
||||||
|
},
|
||||||
|
} as unknown as Gateways;
|
||||||
|
xtermState.answerOnWrite = "\x1b[1;1R";
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<TerminalView cwd="/cwd" sessionId="live-loop-regression" />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(reattach).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||||
|
|
||||||
|
// The response synthesized while replaying retained output never reaches
|
||||||
|
// pending/adopt and therefore never loops back into the PTY.
|
||||||
|
expect(write).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
xtermState.keyHandler!("Hello\r");
|
||||||
|
|
||||||
|
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
||||||
|
expect(new TextDecoder().decode(write.mock.calls[0][0])).toBe("Hello\r");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -238,30 +238,34 @@ export function TerminalView({
|
|||||||
// not race the injected text. Keystrokes arriving before the PTY opened are
|
// not race the injected text. Keystrokes arriving before the PTY opened are
|
||||||
// buffered. The output path below is unchanged in both modes.
|
// buffered. The output path below is unchanged in both modes.
|
||||||
let pending = "";
|
let pending = "";
|
||||||
const onKey = term.onData((data) => {
|
let onKey: { dispose: () => void } | null = null;
|
||||||
const portal = portalRef.current;
|
const activateInput = () => {
|
||||||
if (agentModeRef.current && portal) {
|
if (disposed || onKey) return;
|
||||||
// Always report for counting (even while suspended — the portal decides
|
onKey = term.onData((data) => {
|
||||||
// what counts), then drop the relay while the portal is injecting.
|
const portal = portalRef.current;
|
||||||
portal.onHumanData(data);
|
if (agentModeRef.current && portal) {
|
||||||
if (portal.isSuspended()) return;
|
// Always report for counting (even while suspended — the portal decides
|
||||||
}
|
// what counts), then drop the relay while the portal is injecting.
|
||||||
if (handle) void handle.write(encoder.encode(data));
|
portal.onHumanData(data);
|
||||||
else pending += data;
|
if (portal.isSuspended()) return;
|
||||||
});
|
}
|
||||||
|
if (handle) void handle.write(encoder.encode(data));
|
||||||
|
else pending += data;
|
||||||
|
});
|
||||||
|
|
||||||
// Publish the input API now that the keystroke relay above is live, so an
|
// Publish the input API only once the keystroke relay is live, so an
|
||||||
// injected key is handled exactly like a typed one. Guarded on `disposed`:
|
// injected key is handled exactly like a typed one. Guarded on `disposed`:
|
||||||
// the caller may still hold this object after the cell unmounts, and driving
|
// the caller may still hold this object after the cell unmounts, and driving
|
||||||
// a disposed xterm throws.
|
// a disposed xterm throws.
|
||||||
onReadyRef.current?.({
|
onReadyRef.current?.({
|
||||||
send: (data) => {
|
send: (data) => {
|
||||||
if (!disposed) term.input(data);
|
if (!disposed) term.input(data);
|
||||||
},
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
if (!disposed) term.focus();
|
if (!disposed) term.focus();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onData = (bytes: Uint8Array) => {
|
const onData = (bytes: Uint8Array) => {
|
||||||
if (!disposed) term.write(bytes);
|
if (!disposed) term.write(bytes);
|
||||||
@ -321,13 +325,27 @@ export function TerminalView({
|
|||||||
h.detach();
|
h.detach();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (scrollback.length > 0) term.write(scrollback);
|
const finishReattach = () => {
|
||||||
adopt(h);
|
if (disposed) {
|
||||||
|
h.detach();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A scrollback replay can make xterm answer historical terminal
|
||||||
|
// queries (cursor position, device attributes, modes…). Keep the
|
||||||
|
// input relay absent until xterm confirms the replay is complete,
|
||||||
|
// otherwise those synthetic answers enter `pending` and are sent
|
||||||
|
// back into the live PTY by `adopt`, creating a feedback loop.
|
||||||
|
activateInput();
|
||||||
|
adopt(h);
|
||||||
|
};
|
||||||
|
if (scrollback.length > 0) term.write(scrollback, finishReattach);
|
||||||
|
else finishReattach();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// The session is gone (explicitly closed / exited): fall back to a
|
// The session is gone (explicitly closed / exited): fall back to a
|
||||||
// fresh terminal so the cell still works.
|
// fresh terminal so the cell still works.
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
activateInput();
|
||||||
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||||
.then((h) => {
|
.then((h) => {
|
||||||
onSessionIdRef.current?.(h.sessionId);
|
onSessionIdRef.current?.(h.sessionId);
|
||||||
@ -336,6 +354,9 @@ export function TerminalView({
|
|||||||
.catch(onOpenError);
|
.catch(onOpenError);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// A fresh terminal has no historical output to replay. Enable input now
|
||||||
|
// so genuine keystrokes typed while the opener resolves remain buffered.
|
||||||
|
activateInput();
|
||||||
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||||
.then((h) => {
|
.then((h) => {
|
||||||
onSessionIdRef.current?.(h.sessionId);
|
onSessionIdRef.current?.(h.sessionId);
|
||||||
@ -468,7 +489,7 @@ export function TerminalView({
|
|||||||
window.removeEventListener("pageshow", scheduleSettledRefit);
|
window.removeEventListener("pageshow", scheduleSettledRefit);
|
||||||
document.removeEventListener("visibilitychange", scheduleVisibleRefit);
|
document.removeEventListener("visibilitychange", scheduleVisibleRefit);
|
||||||
window.visualViewport?.removeEventListener("resize", scheduleSettledRefit);
|
window.visualViewport?.removeEventListener("resize", scheduleSettledRefit);
|
||||||
onKey.dispose();
|
onKey?.dispose();
|
||||||
portalRef.current?.unbindHandle();
|
portalRef.current?.unbindHandle();
|
||||||
// DETACH, never close: tearing the view down (navigation / layout change)
|
// DETACH, never close: tearing the view down (navigation / layout change)
|
||||||
// must leave the backend PTY running so the AI isn't cut off. Killing the
|
// must leave the backend PTY running so the AI isn't cut off. Killing the
|
||||||
|
|||||||
Reference in New Issue
Block a user