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:
2026-09-09 14:30:39 +02:00
parent c3e8e5f4d2
commit 00a32220d1
2 changed files with 121 additions and 32 deletions

View File

@ -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");
});
});

View File

@ -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