From 00a32220d1401a64fda89f986c8bfb39a6611fd4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 9 Sep 2026 14:30:39 +0200 Subject: [PATCH] =?UTF-8?q?fix(terminals):=20le=20reattach=20xterm=20ne=20?= =?UTF-8?q?r=C3=A9injecte=20plus=20les=20r=C3=A9ponses=20synth=C3=A9tiques?= =?UTF-8?q?=20du=20replay=20scrollback=20=E2=80=94=20#292=20(term.onData?= =?UTF-8?q?=20n'est=20actif=20qu'apr=C3=A8s=20le=20callback=20final=20du?= =?UTF-8?q?=20term.write(scrollback):=20les=20r=C3=A9ponses=20aux=20requ?= =?UTF-8?q?=C3=AAtes=20historiques=20ESC[1;1R/modes=20ne=20peuplent=20plus?= =?UTF-8?q?=20pending=20ni=20ne=20sont=20r=C3=A9=C3=A9crites=20dans=20le?= =?UTF-8?q?=20PTY=20par=20adopt,=20ce=20qui=20bouclait=20CPU=20100%=20sur?= =?UTF-8?q?=20les=20agents=20Git=20TUI=20Native=20d'UnitySoundTool/UnityPl?= =?UTF-8?q?ugin;=20activation=20de=20l'input=20aussi=20sur=20les=20chemins?= =?UTF-8?q?=20fallback=20reattach-=C3=A9chou=C3=A9=20et=20terminal=20neuf;?= =?UTF-8?q?=20test=20de=20r=C3=A9gression:=20z=C3=A9ro=20write=20PTY=20pen?= =?UTF-8?q?dant=20le=20replay=20synth=C3=A9tique=20puis=20exactement=20un?= =?UTF-8?q?=20write=20pour=20Hello\r;=20QA=20verte:=203=20fichiers/27=20te?= =?UTF-8?q?sts=20TerminalView,=20npm=20run=20build=20frontend,=20AppImage?= =?UTF-8?q?=200.3.0=20rebuild=20sha256=204165521e...;=20r=C3=A9serve:=20re?= =?UTF-8?q?pro=20live=20UI=20trois=20projets=20non=20valid=C3=A9e=20depuis?= =?UTF-8?q?=20sandbox)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TerminalView.scrollback.test.tsx | 80 +++++++++++++++++-- .../src/features/terminals/TerminalView.tsx | 73 +++++++++++------ 2 files changed, 121 insertions(+), 32 deletions(-) diff --git a/frontend/src/features/terminals/TerminalView.scrollback.test.tsx b/frontend/src/features/terminals/TerminalView.scrollback.test.tsx index 2341f90e..bdb0ad3d 100644 --- a/frontend/src/features/terminals/TerminalView.scrollback.test.tsx +++ b/frontend/src/features/terminals/TerminalView.scrollback.test.tsx @@ -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( + + + , + ); + + 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"); + }); }); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index d373cfd0..a24b35fc 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -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