diff --git a/changelog.d/core-replies-queued-input.md b/changelog.d/core-replies-queued-input.md new file mode 100644 index 0000000000..fbd2efb467 --- /dev/null +++ b/changelog.d/core-replies-queued-input.md @@ -0,0 +1,3 @@ +### Fixed +- On herdr-core, the terminal's own answers (colours, focus) are no longer typed into the program + when they arrive while a key-bar key is still on its way. diff --git a/server/core/input.ts b/server/core/input.ts index a0c6d311b5..239398c13a 100644 --- a/server/core/input.ts +++ b/server/core/input.ts @@ -8,7 +8,7 @@ import { ATTACH_HELD_MESSAGE } from "./terminal-process.ts"; import { paneAgentOf } from "../pane-context.ts"; import type { TerminalSighting } from "./terminals.ts"; import type { ClientMessage } from "../../shared/protocol.ts"; -import { isMouseReport, typingGuards } from "../input-guard.ts"; +import { isMouseReport, isTerminalReply, typingGuards } from "../input-guard.ts"; import { HerdrError, paneRead, paneSendKeys, paneSendText } from "../herdr/client.ts"; import { secretPrompt, validSecret } from "../../shared/secret-prompt.ts"; import { insertTextProblem } from "../../shared/terminal-insert.ts"; @@ -150,8 +150,10 @@ export function createInputLane({ TYPED_SETTLE_MS, KEY_SETTLE_MS, clients, sight if (paneQueues.has(message.pane_id) || stop) { const text = message.text; // a click or a wheel (a click replayed behind a held arrow) is the attach's own mouse: - // herdr encodes it for the program's mouse mode, send_text would type its bytes (#667) - const mouse = isMouseReport(text); + // herdr encodes it for the program's mouse mode, send_text would type its bytes (#667). + // The terminal's answers to its attach's questions go to that attach too, or they are typed (#2816): + // only what the browser's terminal made itself, so a paste of the same bytes is still typed + const viaAttach = isMouseReport(text) || (message.automatic === true && isTerminalReply(text)); // typed into the attach it saw: one that ended meanwhile (and was attached again) takes none of it const roles = connectionOf(client).roles; const allowed = () => mayType(client) && connectionOf(client).roles === roles && typingGuards.queuedTyping(sighting.facts()); @@ -163,8 +165,8 @@ export function createInputLane({ TYPED_SETTLE_MS, KEY_SETTLE_MS, clients, sight // nothing typed outlives its connection if (!clients.has(client)) return; authorizeSocket(client); - if (mouse) { - if (!allowed() || !sighting.write(text)) { inputFailed(); return; } + if (viaAttach) { + if (!allowed() || !sighting.write(text, message.automatic === true)) { inputFailed(); return; } typedIntoPty(text); receipt(true); return; diff --git a/server/input-guard.test.ts b/server/input-guard.test.ts index c1d1323b87..cc50a16224 100644 --- a/server/input-guard.test.ts +++ b/server/input-guard.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { isMouseReport, sightingFacts, typingGuards } from "./input-guard.ts"; +import { isMouseReport, isTerminalReply, sightingFacts, typingGuards } from "./input-guard.ts"; const attach = (...members: string[]) => ({ clients: new Set(members), ready: true, process: {}, kind: "pty" as const, cols: 80 }); /** typing taken while `origin` was the attach the client was in (none: typed with no attach of its own), now that `current` is the pane's */ @@ -28,6 +28,18 @@ test("a click or a wheel is told from typing, so a queued one keeps the attach i } }); +test("the terminal's own answers are told from typing, so queued ones go to the attach that asked (#2816)", () => { + expect(isTerminalReply("\x1b[I")).toBe(true); + expect(isTerminalReply("\x1b[O")).toBe(true); + expect(isTerminalReply("\x1b]11;rgb:1818/1616/1313\x07")).toBe(true); + expect(isTerminalReply("\x1b[6;17;8t")).toBe(true); + expect(isTerminalReply("\x1b[I\x1b]10;rgb:d8d8/d0d0/c3c3\x1b\\\x1b]12;rgb:f0f0/a8a8/3030\x1b\\\x1b[?1;2c")).toBe(true); + // typing, keys and an answer mixed with typing are not answers + for (const text of ["", "I", "\x1b[A", "\x1b[1;2A", "\x1bOB", "\x1b]11;?\x1b\\", "\x1b[Ix", "x\x1b[I", "\x1b]13;rgb:0/0/0\x07", "\x1b]11;hello\rworld\x07"]) { + expect(isTerminalReply(text)).toBe(false); + } +}); + test("typing taken into an attach goes only into that attach, while it still has the typist", () => { const origin = attach("a"); expect(typedThen(origin, origin, "a")).toBe(true); diff --git a/server/input-guard.ts b/server/input-guard.ts index 5e77727bc8..436f35e309 100644 --- a/server/input-guard.ts +++ b/server/input-guard.ts @@ -101,3 +101,14 @@ const MOUSE_REPORTS = /^(?:\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M[\s\S]{3})+$/; export function isMouseReport(text: string): boolean { return MOUSE_REPORTS.test(text); } + +/** One or more of the answers a herdr-core attach takes out of the input (`takeReplies`): colours, cell size, DA1, focus. */ +const TERMINAL_REPLIES = /^(?:\x1b\]1[012];[^\x07\x1b\r\n?]*(?:\x07|\x1b\\)|\x1b\[6;\d+;\d+t|\x1b\[\?[\d;]*c|\x1b\[[IO])+$/; + +/** + * Whether `text` is nothing but the terminal's own answers to what its attach asked. The attach + * takes them out of what reaches the program, while `pane.send_text` would type them into it (#2816). + */ +export function isTerminalReply(text: string): boolean { + return TERMINAL_REPLIES.test(text); +} diff --git a/server/keys-lifecycle.contract.test.ts b/server/keys-lifecycle.contract.test.ts index 6e0b0f45c3..2b94cfed25 100644 --- a/server/keys-lifecycle.contract.test.ts +++ b/server/keys-lifecycle.contract.test.ts @@ -8,9 +8,11 @@ import { PtySession } from "./pty/session.ts"; /** A left click as xterm reports it with herdr's SGR mouse mode on: press, then release. */ const CLICK = "\x1b[<0;5;3M\x1b[<0;5;3m"; +/** xterm's answers to a herdr-core attach's questions (focus, colours, cursor colour, DA1), as #2816 saw them typed. */ +const REPLIES = "\x1b[I\x1b]10;rgb:d8d8/d0d0/c3c3\x1b\\\x1b]11;rgb:1818/1616/1313\x1b\\\x1b]12;rgb:f0f0/a8a8/3030\x1b\\\x1b[?1;2c"; /** Hold one keys RPC so the next chord or text waits across attachment transitions. */ -async function queuedKeys(leave: "none" | "detach" | "replace" | "reattach" | "refresh", input = false, text = "queued text") { +async function queuedKeys(leave: "none" | "detach" | "replace" | "reattach" | "refresh", input = false, text = "queued text", automatic = false) { const root = mkdtempSync(join(tmpdir(), "ovis-keys-lifecycle-")); const server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: root }); const sockets: WebSocket[] = []; @@ -68,7 +70,7 @@ async function queuedKeys(leave: "none" | "detach" | "replace" | "reattach" | "r } owner.send({ type: "keys", pane_id: pane, keys: ["ctrl+right"] }); await until(() => calls.length === 1); - owner.send(input ? { type: "input", pane_id: pane, text } + owner.send(input ? { type: "input", pane_id: pane, text, ...(automatic ? { automatic: true } : {}) } : { type: "keys", pane_id: pane, keys: ["ctrl+alt+shift+left"] }); if (leave !== "none" && leave !== "refresh") owner.send({ type: "detach", pane_id: pane }); // An acknowledged later frame is a barrier: the queued key and detach were handled. @@ -86,12 +88,13 @@ async function queuedKeys(leave: "none" | "detach" | "replace" | "reattach" | "r } release(); if (leave === "none" || leave === "refresh") { - if (input && text === CLICK) { + if (input && (text === CLICK || automatic)) { // a mouse report keeps the attach input path, where herdr encodes it for the program's - // own mouse mode; pane.send_text would type its bytes literally (#667) - await until(() => written.includes(CLICK) || texts.length > 0); + // own mouse mode; pane.send_text would type its bytes literally (#667). The terminal's + // answers go to the attach that asked, never typed into the program (#2816) + await until(() => written.includes(text) || texts.length > 0); expect(texts).toEqual([]); - expect(written).toContain(CLICK); + expect(written).toContain(text); expect(owner.seen.filter((frame) => frame.type === "error")).toEqual([]); } else if (input) { await until(() => texts.length === 1); @@ -136,6 +139,8 @@ it("preserves queued text on repeated attach without detach", () => queuedKeys(" it("writes a click queued behind an arrow into the attach, not through send_text", () => queuedKeys("none", true, CLICK), 20_000); it("drops a queued click after its sender detaches while a peer keeps the attachment", () => queuedKeys("detach", true, CLICK), 20_000); it("drops a queued click after its attachment is replaced", () => queuedKeys("replace", true, CLICK), 20_000); +it("writes the terminal's answers queued behind a key into the attach, never typed (#2816)", () => queuedKeys("none", true, REPLIES, true), 20_000); +it("types a paste of the same bytes queued behind a key, as typing", () => queuedKeys("none", true, REPLIES), 20_000); it("cancels a pending attach continuation when detach and reattach installs a new claim", async () => { const root = mkdtempSync(join(tmpdir(), "ovis-attach-claim-"));