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

View File

@ -238,7 +238,10 @@ 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 activateInput = () => {
if (disposed || onKey) return;
onKey = term.onData((data) => {
const portal = portalRef.current; const portal = portalRef.current;
if (agentModeRef.current && portal) { if (agentModeRef.current && portal) {
// Always report for counting (even while suspended — the portal decides // Always report for counting (even while suspended — the portal decides
@ -250,7 +253,7 @@ export function TerminalView({
else pending += 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.
@ -262,6 +265,7 @@ export function TerminalView({
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 = () => {
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); 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