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 { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
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 { DIProvider } from "@/app/di";
|
||||
|
||||
const terminalOptions: unknown[] = [];
|
||||
const xtermState: {
|
||||
keyHandler: ((data: string) => void) | null;
|
||||
answerOnWrite: string | null;
|
||||
} = {
|
||||
keyHandler: null,
|
||||
answerOnWrite: null,
|
||||
};
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
@ -18,11 +25,19 @@ vi.mock("@xterm/xterm", () => ({
|
||||
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
onData() {
|
||||
onData(callback: (data: string) => void) {
|
||||
xtermState.keyHandler = callback;
|
||||
return { dispose() {} };
|
||||
}
|
||||
write() {}
|
||||
input() {}
|
||||
write(_data: string | Uint8Array, callback?: () => void) {
|
||||
if (xtermState.answerOnWrite) {
|
||||
xtermState.keyHandler?.(xtermState.answerOnWrite);
|
||||
}
|
||||
callback?.();
|
||||
}
|
||||
input(data: string) {
|
||||
xtermState.keyHandler?.(data);
|
||||
}
|
||||
focus() {}
|
||||
dispose() {}
|
||||
},
|
||||
@ -49,6 +64,12 @@ import {
|
||||
TERMINAL_SCROLLBACK_LINES,
|
||||
} from "./TerminalView";
|
||||
|
||||
beforeEach(() => {
|
||||
terminalOptions.length = 0;
|
||||
xtermState.keyHandler = null;
|
||||
xtermState.answerOnWrite = null;
|
||||
});
|
||||
|
||||
describe("TerminalView scrollback", () => {
|
||||
it("configures xterm with a deep scrollback for chatty OpenCode agents", async () => {
|
||||
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
|
||||
@ -64,4 +85,51 @@ describe("TerminalView scrollback", () => {
|
||||
});
|
||||
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
|
||||
// buffered. The output path below is unchanged in both modes.
|
||||
let pending = "";
|
||||
const onKey = term.onData((data) => {
|
||||
const portal = portalRef.current;
|
||||
if (agentModeRef.current && portal) {
|
||||
// Always report for counting (even while suspended — the portal decides
|
||||
// what counts), then drop the relay while the portal is injecting.
|
||||
portal.onHumanData(data);
|
||||
if (portal.isSuspended()) return;
|
||||
}
|
||||
if (handle) void handle.write(encoder.encode(data));
|
||||
else pending += data;
|
||||
});
|
||||
let onKey: { dispose: () => void } | null = null;
|
||||
const activateInput = () => {
|
||||
if (disposed || onKey) return;
|
||||
onKey = term.onData((data) => {
|
||||
const portal = portalRef.current;
|
||||
if (agentModeRef.current && portal) {
|
||||
// Always report for counting (even while suspended — the portal decides
|
||||
// what counts), then drop the relay while the portal is injecting.
|
||||
portal.onHumanData(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
|
||||
// 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
|
||||
// a disposed xterm throws.
|
||||
onReadyRef.current?.({
|
||||
send: (data) => {
|
||||
if (!disposed) term.input(data);
|
||||
},
|
||||
focus: () => {
|
||||
if (!disposed) term.focus();
|
||||
},
|
||||
});
|
||||
// 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`:
|
||||
// the caller may still hold this object after the cell unmounts, and driving
|
||||
// a disposed xterm throws.
|
||||
onReadyRef.current?.({
|
||||
send: (data) => {
|
||||
if (!disposed) term.input(data);
|
||||
},
|
||||
focus: () => {
|
||||
if (!disposed) term.focus();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onData = (bytes: Uint8Array) => {
|
||||
if (!disposed) term.write(bytes);
|
||||
@ -321,13 +325,27 @@ export function TerminalView({
|
||||
h.detach();
|
||||
return;
|
||||
}
|
||||
if (scrollback.length > 0) term.write(scrollback);
|
||||
adopt(h);
|
||||
const finishReattach = () => {
|
||||
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(() => {
|
||||
// The session is gone (explicitly closed / exited): fall back to a
|
||||
// fresh terminal so the cell still works.
|
||||
if (disposed) return;
|
||||
activateInput();
|
||||
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||
.then((h) => {
|
||||
onSessionIdRef.current?.(h.sessionId);
|
||||
@ -336,6 +354,9 @@ export function TerminalView({
|
||||
.catch(onOpenError);
|
||||
});
|
||||
} 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)
|
||||
.then((h) => {
|
||||
onSessionIdRef.current?.(h.sessionId);
|
||||
@ -468,7 +489,7 @@ export function TerminalView({
|
||||
window.removeEventListener("pageshow", scheduleSettledRefit);
|
||||
document.removeEventListener("visibilitychange", scheduleVisibleRefit);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleSettledRefit);
|
||||
onKey.dispose();
|
||||
onKey?.dispose();
|
||||
portalRef.current?.unbindHandle();
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user