diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 35dfa010..8f7fc01e 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -25,6 +25,9 @@ * from its terminal — announced by a hint the textarea is described by; * Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode * (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's. + * An input method that edits the textarea without composition events (macOS + * Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone + * dropped its capitals and the words it rewrites. * URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies * (OSC 52 — tmux's default `set-clipboard external` passes its own * copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser @@ -49,6 +52,7 @@ import { useTheme } from "../../hooks/useTheme"; import { terminalThemeFor } from "../../lib/terminalTheme"; import { api } from "../../lib/api"; import { notePaneFocus, registerPane } from "../../lib/panePaste"; +import { attachImeInput } from "../../lib/imeInput"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; type DropState = "hint" | "uploading" | "failed"; @@ -519,29 +523,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter const reassert = takeLead; term.textarea?.addEventListener("focus", reassert); - // Safari with macOS's own Vietnamese Telex types a word by REPLACING it: - // after the first letter every key fires `insertReplacementText` carrying - // the whole word ("x" → "xi" → "xin"), before its keydown. xterm forwards - // only `insertText`, and its keyCode-229 fallback snapshots the textarea - // after the change, so all but each word's first letter was lost (xterm.js - // #5887). Send the edit ourselves: a DEL per replaced char, then the tail. - let beforeReplace = ""; - const onBeforeReplace = (e: Event) => { - if ((e as InputEvent).inputType === "insertReplacementText") { - beforeReplace = term.textarea?.value ?? ""; - } - }; - const onReplace = (e: Event) => { - if ((e as InputEvent).inputType !== "insertReplacementText") return; - const before = Array.from(beforeReplace); - const after = Array.from(term.textarea?.value ?? ""); - let same = 0; - while (same < before.length && before[same] === after[same]) same++; - const edit = "\x7f".repeat(before.length - same) + after.slice(same).join(""); - if (edit) term.input(edit, true); - }; - term.textarea?.addEventListener("beforeinput", onBeforeReplace); - term.textarea?.addEventListener("input", onReplace); + // macOS's own Vietnamese Telex (Safari and Chrome) types into the + // textarea with no composition events: a capital arrives while Shift is + // held and every later key REPLACES the word — both dropped by xterm + // (xterm.js #5887). lib/imeInput.ts sends those edits itself. + const detachIme = attachImeInput(term); const refit = () => { // Never fit a collapsed console — see sendSize. @@ -571,8 +557,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter resizeObserver.disconnect(); document.fonts?.removeEventListener("loadingdone", onFontLoaded); term.textarea?.removeEventListener("focus", reassert); - term.textarea?.removeEventListener("beforeinput", onBeforeReplace); - term.textarea?.removeEventListener("input", onReplace); + detachIme(); resizeDisposable.dispose(); dataDisposable.dispose(); ws?.close(); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 8b6ec171..bd437e14 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -75,6 +75,8 @@ vi.mock("@xterm/xterm", () => ({ paste: pasteMock, input: inputMock, textarea: (textarea = document.createElement("textarea")), + // xterm's own element, holding its textarea (lib/imeInput listens on it). + element: document.createElement("div").appendChild(textarea).parentElement, resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }), })), })); diff --git a/client/src/lib/__tests__/imeInput.test.ts b/client/src/lib/__tests__/imeInput.test.ts new file mode 100644 index 00000000..c6782318 --- /dev/null +++ b/client/src/lib/__tests__/imeInput.test.ts @@ -0,0 +1,159 @@ +/** + * @file imeInput.test.ts + * @description Pins `attachImeInput` against a real event trace of macOS's + * Vietnamese Simple Telex input method in Chrome (typing "Abc xin chào"): the + * IME writes into xterm's textarea with `insertText` / `insertReplacementText` + * and no composition events, its keydown (keyCode 229) arriving AFTER the + * change — which stock xterm 5.5 turned into " x c". The pane must receive + * exactly the text typed, a word the IME rewrites as backspaces plus the new + * tail, and keys xterm already sent from `keypress` (Space) never twice. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { afterEach, describe, expect, it } from "vitest"; +import { attachImeInput, textEdit } from "../imeInput"; + +type Step = [type: string, a: string, b: string, keyCode?: number, shift?: boolean]; + +// Recorded in the user's browser, one line per event on the textarea. +const TELEX: Step[] = [ + ["keydown", "Shift", "ShiftLeft", 16, true], + ["input", "A", "insertText"], + ["keydown", "A", "KeyA", 229, true], + ["keyup", "A", "KeyA", 65, true], + ["keyup", "Shift", "ShiftLeft", 16], + ["input", "Ab", "insertReplacementText"], + ["keydown", "b", "KeyB", 229], + ["keyup", "b", "KeyB", 66], + ["input", "Abc", "insertReplacementText"], + ["keydown", "c", "KeyC", 229], + ["keyup", "c", "KeyC", 67], + ["keydown", " ", "Space", 32], + ["keypress", " ", "Space", 32], + ["input", " ", "insertText"], + ["keyup", " ", "Space", 32], + ["input", "x", "insertText"], + ["keydown", "x", "KeyX", 229], + ["keyup", "x", "KeyX", 88], + ["input", "xi", "insertReplacementText"], + ["keydown", "i", "KeyI", 229], + ["input", "xin", "insertReplacementText"], + ["keydown", "n", "KeyN", 229], + ["keydown", " ", "Space", 32], + ["keypress", " ", "Space", 32], + ["input", " ", "insertText"], + ["input", "c", "insertText"], + ["keydown", "c", "KeyC", 229], + ["input", "ch", "insertReplacementText"], + ["keydown", "h", "KeyH", 229], + ["input", "cha", "insertReplacementText"], + ["keydown", "a", "KeyA", 229], + ["input", "chao", "insertReplacementText"], + ["keydown", "o", "KeyO", 229], + ["input", "chào", "insertReplacementText"], + ["keydown", "f", "KeyF", 229], +]; + +let cleanup: (() => void) | null = null; +afterEach(() => { + cleanup?.(); + cleanup = null; + document.body.innerHTML = ""; +}); + +/** A terminal stand-in: xterm's element and textarea, `input` collecting what + * reaches the pane, and `xterm` what xterm's own textarea listeners saw. */ +function setup() { + const element = document.createElement("div"); + const textarea = document.createElement("textarea"); + element.appendChild(textarea); + document.body.appendChild(element); + let sent = ""; + const xterm: string[] = []; + for (const t of ["keydown", "keypress", "input"]) + textarea.addEventListener(t, (e) => xterm.push(`${t}:${(e as KeyboardEvent).key ?? ""}`), true); + // xterm sends a printable key from its keypress handler (Space, here). + textarea.addEventListener("keypress", (e) => (sent += e.key), true); + const term = { element, textarea, input: (d: string) => (sent += d) }; + cleanup = attachImeInput(term); + return { textarea, xterm, sent: () => sent }; +} + +function play(ta: HTMLTextAreaElement, steps: Step[]) { + for (const [type, a, b, kc, shift] of steps) { + if (type === "input") { + const before = ta.value; + const next = b === "insertText" ? before + a : before.replace(/\S*$/, "") + a; + ta.dispatchEvent(new InputEvent("beforeinput", { data: a, inputType: b, bubbles: true })); + ta.value = next; + ta.dispatchEvent(new InputEvent("input", { data: a, inputType: b, bubbles: true })); + } else { + const ev = new KeyboardEvent(type, { key: a, code: b, shiftKey: !!shift, bubbles: true }); + Object.defineProperty(ev, "keyCode", { get: () => kc }); + ta.dispatchEvent(ev); + } + } +} + +/** What a line editor holds after `data` (DEL erases one character). */ +const typed = (data: string) => { + const out: string[] = []; + for (const ch of data) { + if (ch === "\x7f") out.pop(); + else out.push(ch); + } + return out.join(""); +}; + +describe("textEdit", () => { + it("is the backspaces and new tail that turn one value into the other", () => { + expect(textEdit("Abc xin cha", "Abc xin chao")).toBe("o"); + expect(textEdit("Abc xin chao", "Abc xin chào")).toBe("\x7f\x7fào"); + expect(textEdit("x", "x")).toBe(""); + expect(textEdit("ab", "")).toBe("\x7f\x7f"); + }); + + it("counts characters, not UTF-16 units", () => { + expect(textEdit("a😀", "a")).toBe("\x7f"); + }); +}); + +describe("attachImeInput", () => { + it("sends what Telex typed — capitals and rewritten words included", () => { + const { textarea, sent } = setup(); + play(textarea, TELEX); + expect(typed(sent())).toBe("Abc xin chào"); + }); + + it("leaves xterm the keys it sends itself, and hides the IME's from it", () => { + const { textarea, xterm, sent } = setup(); + play(textarea, TELEX); + // Space is xterm's (its keypress sends it, its input event follows); the + // 229 keydowns and the IME's own input events never reach it. + expect(xterm.filter((x) => x.startsWith("keypress"))).toHaveLength(2); + expect(xterm.filter((x) => x.startsWith("input"))).toHaveLength(2); + expect(xterm.some((x) => x === "keydown:A" || x === "keydown:b")).toBe(false); + expect(sent().split(" ")).toHaveLength(3); + }); + + it("does not touch a composition (CJK input) — xterm handles that", () => { + const { textarea, xterm, sent } = setup(); + const ev = new InputEvent("input", { + data: "に", + inputType: "insertCompositionText", + bubbles: true, + }); + textarea.value = "に"; + textarea.dispatchEvent(ev); + expect(sent()).toBe(""); + expect(xterm).toContain("input:"); + }); + + it("stops after dispose", () => { + const { textarea, sent } = setup(); + cleanup?.(); + cleanup = null; + play(textarea, TELEX.slice(0, 2)); + expect(sent()).toBe(""); + }); +}); diff --git a/client/src/lib/imeInput.ts b/client/src/lib/imeInput.ts new file mode 100644 index 00000000..99f41c58 --- /dev/null +++ b/client/src/lib/imeInput.ts @@ -0,0 +1,90 @@ +/** + * @file imeInput.ts + * @description Typing through an input method that edits xterm's textarea + * without composition events — macOS's Vietnamese Telex, in Chrome and + * Safari, writes each letter with `insertText` and rewrites the whole word + * with `insertReplacementText` ("cha" → "chao" → "chào"), its keydown + * (keyCode 229) arriving only AFTER the change. xterm 5.5 drops every + * replacement (xterm.js #5887) and every `input` that lands while a key is + * held (Shift, for a capital), so "Abc xin chào" reached the pane as " x c". + * `attachImeInput` turns each such change into what a line editor needs — a + * backspace per replaced character, then the new tail — and hides those + * events from xterm so nothing is sent twice. A key xterm sends itself (its + * `keypress`, e.g. Space) and a real composition (CJK) stay xterm's. + * @author Nguyễn Ngọc Trí Vĩ + */ + +/** DEL, the byte a terminal's Backspace sends (xterm's too). */ +const DEL = "\x7f"; + +/** The edits this module answers; anything else stays xterm's. */ +const EDITS = new Set(["insertText", "insertReplacementText", "deleteContentBackward"]); + +/** The backspaces and new tail that turn `before` into `after` — per + * character (code point), as the pane's line editor counts them. */ +export function textEdit(before: string, after: string): string { + const a = Array.from(before); + const b = Array.from(after); + let same = 0; + while (same < a.length && same < b.length && a[same] === b[same]) same++; + return DEL.repeat(a.length - same) + b.slice(same).join(""); +} + +/** The parts of an xterm `Terminal` this needs. */ +interface ImeTerminal { + element?: HTMLElement; + textarea?: HTMLTextAreaElement; + input: (data: string, wasUserInput?: boolean) => void; +} + +/** Sends `term`'s non-composition textarea edits to the pane itself; returns + * its dispose. Listens in the capture phase on xterm's element, so it runs + * before xterm's own listeners on the textarea and can keep them from it. */ +export function attachImeInput(term: ImeTerminal): () => void { + const root = term.element; + const ta = term.textarea; + if (!root || !ta) return () => {}; + // The value before the edit in flight (from `beforeinput`). + let before: string | null = null; + // xterm sent this key from its keypress: the input event that follows is its. + let pressed = false; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.target !== ta) return; + pressed = false; + // The IME's own key, its edit already in (or about to come as) an input + // event: xterm's fallback would diff the textarea and send it again. + if (e.keyCode === 229 && !e.isComposing) e.stopPropagation(); + }; + const onKeyPress = (e: KeyboardEvent) => { + if (e.target === ta) pressed = true; + }; + const onBeforeInput = (e: Event) => { + if (e.target === ta) before = ta.value; + }; + const onInput = (e: Event) => { + const ev = e as InputEvent; + if (ev.target !== ta) return; + const was = before; + before = null; + if (pressed) { + pressed = false; + return; + } + if (ev.isComposing || !EDITS.has(ev.inputType) || was === null) return; + e.stopPropagation(); + const data = textEdit(was, ta.value); + if (data) term.input(data, true); + }; + + root.addEventListener("keydown", onKeyDown, true); + root.addEventListener("keypress", onKeyPress, true); + root.addEventListener("beforeinput", onBeforeInput, true); + root.addEventListener("input", onInput, true); + return () => { + root.removeEventListener("keydown", onKeyDown, true); + root.removeEventListener("keypress", onKeyPress, true); + root.removeEventListener("beforeinput", onBeforeInput, true); + root.removeEventListener("input", onInput, true); + }; +} diff --git a/docs/LANES.md b/docs/LANES.md index 1f22ac4d..5723ffcd 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -406,7 +406,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page - **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: ` detection both render in the PipelineMap. While one pane is shown, its split right/down menus sit at the right end of the tab bar; each offers **New terminal** and the same AI tools as the `+` menu. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. There is no Tools menu any more: MCP sync and the sync-base check are the CLI's `agenthub workspace mcp sync` and `agenthub workspace sync-base`, and a readme refresh is a skill typed into a session. - **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states"). - **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too. - Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab). + Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. An input method that edits the terminal's text box without composition events — macOS's Vietnamese Telex, in Chrome and Safari, which writes each letter as `insertText` and rewrites the word being typed as `insertReplacementText` — is sent to the pane by `client/src/lib/imeInput.ts` as backspaces plus the new tail of the word; xterm 5.5 on its own dropped those rewrites and any capital typed with Shift. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab). - **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one. - **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55). - **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-` (its `run_id`), slots 1–9 are `agenthub-lane--`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named ` · `, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=
` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.