From 740fd7122b2cc7cf246bea0829949a527c26ea9c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 21 Oct 2026 10:48:37 +0800 Subject: [PATCH 1/4] chore(app): update ghostty-web to terminal focus branch --- bun.lock | 7 +- package.json | 1 - packages/app/package.json | 2 +- packages/gui-extensions/package.json | 2 +- patches/ghostty-web@0.3.0.patch | 193 --------------------------- 5 files changed, 5 insertions(+), 200 deletions(-) delete mode 100644 patches/ghostty-web@0.3.0.patch diff --git a/bun.lock b/bun.lock index 649555fe8533..8b4af539cf1d 100644 --- a/bun.lock +++ b/bun.lock @@ -103,7 +103,7 @@ "@types/node": "catalog:", "@typescript/native": "catalog:", "diff": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#e91e38a9ceb13c566ddfa15185a9393849f712a9", "happy-dom": "20.11.1", "tw-animate-css": "1.4.0", "vite": "8.2.2", @@ -415,7 +415,7 @@ "@tanstack/solid-virtual": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#e91e38a9ceb13c566ddfa15185a9393849f712a9", "remeda": "catalog:", "solid-js": "catalog:", "solid-presence": "0.2.0", @@ -1052,7 +1052,6 @@ "patchedDependencies": { "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", "electron-updater@6.8.9": "patches/electron-updater@6.8.9.patch", - "ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch", "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", @@ -3756,7 +3755,7 @@ "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#e91e38a", {}, "anomalyco-ghostty-web-e91e38a", "sha512-EUTn6D7Pc61P8EA4qFiy1KNXW8ORr23mkerDdyZR6ngDh0do/m1QP++t/uzzw6kRpxMes1gRifxjbTvdU7w++Q=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], diff --git a/package.json b/package.json index 225179c8fde2..75b6fb6e464b 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,6 @@ "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch", - "ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch", "vite@8.2.2": "patches/vite@8.2.2.patch", "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", "@corvu/drawer@0.2.4": "patches/@corvu%2Fdrawer@0.2.4.patch", diff --git a/packages/app/package.json b/packages/app/package.json index 45b25c6fc7e0..8ca1647c2772 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -47,7 +47,7 @@ "@types/node": "catalog:", "@typescript/native": "catalog:", "diff": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#e91e38a9ceb13c566ddfa15185a9393849f712a9", "happy-dom": "20.11.1", "tw-animate-css": "1.4.0", "vite": "8.2.2", diff --git a/packages/gui-extensions/package.json b/packages/gui-extensions/package.json index 557d627d6d68..f28981dbe43b 100644 --- a/packages/gui-extensions/package.json +++ b/packages/gui-extensions/package.json @@ -44,7 +44,7 @@ "@tanstack/solid-virtual": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#e91e38a9ceb13c566ddfa15185a9393849f712a9", "remeda": "catalog:", "solid-js": "catalog:", "solid-presence": "0.2.0", diff --git a/patches/ghostty-web@0.3.0.patch b/patches/ghostty-web@0.3.0.patch deleted file mode 100644 index a477518492af..000000000000 --- a/patches/ghostty-web@0.3.0.patch +++ /dev/null @@ -1,193 +0,0 @@ ---- a/dist/ghostty-web.js -+++ b/dist/ghostty-web.js -@@ -1209,7 +1209,7 @@ - */ - getMouseModifiers(A) { - let g = 0; -- return A.shiftKey && (g |= 4), A.metaKey && (g |= 8), A.ctrlKey && (g |= 16), g; -+ return A.shiftKey && (g |= 4), A.altKey && (g |= 8), A.ctrlKey && (g |= 16), g; - } - /** - * Encode mouse event as SGR sequence -@@ -1246,7 +1246,7 @@ - */ - handleMouseDown(A) { - var C; -- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking())) -+ if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()) || A.shiftKey) - return; - const g = this.pixelToCell(A); - if (!g) -@@ -1259,7 +1259,7 @@ - */ - handleMouseUp(A) { - var C; -- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking())) -+ if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()) || A.shiftKey) - return; - const g = this.pixelToCell(A); - if (!g) -@@ -1272,7 +1272,7 @@ - */ - handleMouseMove(A) { - var E, D, i; -- if (this.isDisposed || !((E = this.mouseConfig) != null && E.hasMouseTracking())) -+ if (this.isDisposed || !((E = this.mouseConfig) != null && E.hasMouseTracking()) || A.shiftKey) - return; - const g = ((D = this.getModeCallback) == null ? void 0 : D.call(this, 1002)) ?? !1, I = ((i = this.getModeCallback) == null ? void 0 : i.call(this, 1003)) ?? !1; - if (!g && !I || g && !I && this.mouseButtonsPressed === 0) -@@ -1280,21 +1280,21 @@ - const C = this.pixelToCell(A); - if (!C) - return; -- let Q = 32; -- this.mouseButtonsPressed & 1 ? Q += 0 : this.mouseButtonsPressed & 2 ? Q += 1 : this.mouseButtonsPressed & 4 && (Q += 2), this.sendMouseEvent(Q, C.col, C.row, !1, A); -+ let Q = 35; -+ this.mouseButtonsPressed & 1 ? Q = 32 : this.mouseButtonsPressed & 2 ? Q = 33 : this.mouseButtonsPressed & 4 && (Q = 34), this.sendMouseEvent(Q, C.col, C.row, !1, A); - } - /** - * Handle wheel event (scroll) - */ - handleWheel(A) { -- var C; -- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking())) -+ var Q; -+ if (this.isDisposed || !((Q = this.mouseConfig) != null && Q.hasMouseTracking()) || A.shiftKey) - return; - const g = this.pixelToCell(A); -- if (!g) -+ if (!g || A.deltaX === 0 && A.deltaY === 0) - return; -- const I = A.deltaY < 0 ? 64 : 65; -- this.sendMouseEvent(I, g.col, g.row, !1, A), A.preventDefault(); -+ const C = Math.abs(A.deltaX) > Math.abs(A.deltaY) ? A.deltaX < 0 ? 66 : 67 : A.deltaY < 0 ? 64 : 65; -+ this.sendMouseEvent(C, g.col, g.row, !1, A), A.preventDefault(); - } - /** - * Emit paste data with bracketed paste support -@@ -2353,7 +2353,7 @@ - attachEventListeners() { - const A = this.renderer.getCanvas(); - A.addEventListener("mousedown", (g) => { -- if (g.button === 0) { -+ if (!(this.wasmTerm.hasMouseTracking() && !g.shiftKey) && g.button === 0) { - A.parentElement && A.parentElement.focus(); - const I = this.pixelToCell(g.offsetX, g.offsetY); - this.hasSelection() && this.clearSelection(); -@@ -2408,35 +2408,37 @@ - } - } - }, document.addEventListener("mouseup", this.boundMouseUpHandler), A.addEventListener("click", (g) => { -- if (g.detail === 2) { -- const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.getWordAtCell(I.col, I.row); -- if (C) { -- const Q = this.viewportRowToAbsolute(I.row); -- this.selectionStart = { col: C.startCol, absoluteRow: Q }, this.selectionEnd = { col: C.endCol, absoluteRow: Q }, this.requestRender(); -- const E = this.getSelection(); -- E && (this.copyToClipboard(E), this.selectionChangedEmitter.fire()); -- } -- } else if (g.detail >= 3) { -- const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.viewportRowToAbsolute(I.row), Q = this.wasmTerm.getScrollbackLength(); -- let E = null; -- if (C < Q) -- E = this.wasmTerm.getScrollbackLine(C); -- else { -- const i = C - Q; -- E = this.wasmTerm.getLine(i); -- } -- let D = -1; -- if (E) { -- for (let i = E.length - 1; i >= 0; i--) -- if (E[i] && E[i].codepoint !== 0 && E[i].codepoint !== 32) { -- D = i; -- break; -- } -- } -- if (D >= 0) { -- this.selectionStart = { col: 0, absoluteRow: C }, this.selectionEnd = { col: D, absoluteRow: C }, this.requestRender(); -- const i = this.getSelection(); -- i && (this.copyToClipboard(i), this.selectionChangedEmitter.fire()); -+ if (!(this.wasmTerm.hasMouseTracking() && !g.shiftKey)) { -+ if (g.detail === 2) { -+ const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.getWordAtCell(I.col, I.row); -+ if (C) { -+ const Q = this.viewportRowToAbsolute(I.row); -+ this.selectionStart = { col: C.startCol, absoluteRow: Q }, this.selectionEnd = { col: C.endCol, absoluteRow: Q }, this.requestRender(); -+ const E = this.getSelection(); -+ E && (this.copyToClipboard(E), this.selectionChangedEmitter.fire()); -+ } -+ } else if (g.detail >= 3) { -+ const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.viewportRowToAbsolute(I.row), Q = this.wasmTerm.getScrollbackLength(); -+ let E = null; -+ if (C < Q) -+ E = this.wasmTerm.getScrollbackLine(C); -+ else { -+ const i = C - Q; -+ E = this.wasmTerm.getLine(i); -+ } -+ let D = -1; -+ if (E) { -+ for (let i = E.length - 1; i >= 0; i--) -+ if (E[i] && E[i].codepoint !== 0 && E[i].codepoint !== 32) { -+ D = i; -+ break; -+ } -+ } -+ if (D >= 0) { -+ this.selectionStart = { col: 0, absoluteRow: C }, this.selectionEnd = { col: D, absoluteRow: C }, this.requestRender(); -+ const i = this.getSelection(); -+ i && (this.copyToClipboard(i), this.selectionChangedEmitter.fire()); -+ } - } - } - }), this.boundContextMenuHandler = (g) => { -@@ -2676,23 +2678,27 @@ - const M = await this.linkDetector.getLinkAt(Q, o); - M && (M.activate(I), (I.ctrlKey || I.metaKey) && I.preventDefault()); - }, this.handleWheel = (I) => { -- var Q, E, D; -- if (I.preventDefault(), I.stopPropagation(), this.customWheelEventHandler && this.customWheelEventHandler(I)) -+ var Q, E, D, i; -+ if (I.preventDefault(), this.customWheelEventHandler && this.customWheelEventHandler(I)) { -+ I.stopPropagation(); -+ return; -+ } -+ if ((Q = this.wasmTerm) != null && Q.hasMouseTracking() && !I.shiftKey) - return; -- if (((Q = this.wasmTerm) == null ? void 0 : Q.isAlternateScreen()) ?? !1) { -- const i = I.deltaY > 0 ? "down" : "up", o = Math.min(Math.abs(Math.round(I.deltaY / 33)), 5); -- for (let s = 0; s < o; s++) -- i === "up" ? this.dataEmitter.fire("\x1B[A") : this.dataEmitter.fire("\x1B[B"); -+ if (I.stopPropagation(), ((E = this.wasmTerm) == null ? void 0 : E.isAlternateScreen()) ?? !1) { -+ const o = I.deltaY > 0 ? "down" : "up", s = Math.min(Math.abs(Math.round(I.deltaY / 33)), 5); -+ for (let G = 0; G < s; G++) -+ o === "up" ? this.dataEmitter.fire("\x1B[A") : this.dataEmitter.fire("\x1B[B"); - } else { -- let i; -+ let o; - if (I.deltaMode === WheelEvent.DOM_DELTA_PIXEL) { -- const o = ((D = (E = this.renderer) == null ? void 0 : E.getMetrics()) == null ? void 0 : D.height) ?? 20; -- i = I.deltaY / o; -+ const s = ((i = (D = this.renderer) == null ? void 0 : D.getMetrics()) == null ? void 0 : i.height) ?? 20; -+ o = I.deltaY / s; - } else -- I.deltaMode === WheelEvent.DOM_DELTA_LINE ? i = I.deltaY : I.deltaMode === WheelEvent.DOM_DELTA_PAGE ? i = I.deltaY * this.rows : i = I.deltaY / 33; -- if (i !== 0) { -- const o = this.viewportY - i; -- this.smoothScrollTo(o); -+ I.deltaMode === WheelEvent.DOM_DELTA_LINE ? o = I.deltaY : I.deltaMode === WheelEvent.DOM_DELTA_PAGE ? o = I.deltaY * this.rows : o = I.deltaY / 33; -+ if (o !== 0) { -+ const s = this.viewportY - o; -+ this.smoothScrollTo(s); - } - } - }, this.handleMouseDown = (I) => { -@@ -3254,7 +3260,7 @@ - * Clean up components (called on dispose or error) - */ - cleanupComponents() { -- this.selectionManager && (this.selectionManager.dispose(), this.selectionManager = void 0), this.inputHandler && (this.inputHandler.dispose(), this.inputHandler = void 0), this.renderer && (this.renderer.dispose(), this.renderer = void 0), this.canvas && this.canvas.parentNode && (this.canvas.parentNode.removeChild(this.canvas), this.canvas = void 0), this.textarea && this.textarea.parentNode && (this.textarea.parentNode.removeChild(this.textarea), this.textarea = void 0), this.element && (this.element.removeEventListener("wheel", this.handleWheel), this.element.removeEventListener("mousedown", this.handleMouseDown, { capture: !0 }), this.element.removeEventListener("mousemove", this.handleMouseMove), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.element.removeEventListener("click", this.handleClick), this.element.removeAttribute("contenteditable"), this.element.removeAttribute("role"), this.element.removeAttribute("aria-label"), this.element.removeAttribute("aria-multiline")), this.isOpen && typeof document < "u" && document.removeEventListener("mouseup", this.handleMouseUp), this.scrollbarHideTimeout && (window.clearTimeout(this.scrollbarHideTimeout), this.scrollbarHideTimeout = void 0), this.linkDetector && (this.linkDetector.dispose(), this.linkDetector = void 0), this.wasmTerm && (this.wasmTerm.free(), this.wasmTerm = void 0), this.ghostty = void 0, this.element = void 0, this.textarea = void 0; -+ this.selectionManager && (this.selectionManager.dispose(), this.selectionManager = void 0), this.inputHandler && (this.inputHandler.dispose(), this.inputHandler = void 0), this.renderer && (this.renderer.dispose(), this.renderer = void 0), this.canvas && this.canvas.parentNode && (this.canvas.parentNode.removeChild(this.canvas), this.canvas = void 0), this.textarea && this.textarea.parentNode && (this.textarea.parentNode.removeChild(this.textarea), this.textarea = void 0), this.element && (this.element.removeEventListener("wheel", this.handleWheel, { capture: !0 }), this.element.removeEventListener("mousedown", this.handleMouseDown, { capture: !0 }), this.element.removeEventListener("mousemove", this.handleMouseMove), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.element.removeEventListener("click", this.handleClick), this.element.removeAttribute("contenteditable"), this.element.removeAttribute("role"), this.element.removeAttribute("aria-label"), this.element.removeAttribute("aria-multiline")), this.isOpen && typeof document < "u" && document.removeEventListener("mouseup", this.handleMouseUp), this.scrollbarHideTimeout && (window.clearTimeout(this.scrollbarHideTimeout), this.scrollbarHideTimeout = void 0), this.linkDetector && (this.linkDetector.dispose(), this.linkDetector = void 0), this.wasmTerm && (this.wasmTerm.free(), this.wasmTerm = void 0), this.ghostty = void 0, this.element = void 0, this.textarea = void 0; - } - /** - * Assert terminal is open (throw if not) From 27278248dbf8718bdfd08f2b96c88b11fe987435 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 21 Oct 2026 11:18:16 +0800 Subject: [PATCH 2/4] fix(app): keep focus on a terminal opened with a focus request --- .../component-tests/terminal-focus.spec.ts | 39 +++++++++++ .../src/terminal/terminal.fixture.tsx | 65 +++++++++++++++++++ .../gui-extensions/src/terminal/terminal.tsx | 7 +- 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 packages/app/component-tests/terminal-focus.spec.ts create mode 100644 packages/gui-extensions/src/terminal/terminal.fixture.tsx diff --git a/packages/app/component-tests/terminal-focus.spec.ts b/packages/app/component-tests/terminal-focus.spec.ts new file mode 100644 index 000000000000..9d684fde107a --- /dev/null +++ b/packages/app/component-tests/terminal-focus.spec.ts @@ -0,0 +1,39 @@ +import type { Page } from "@playwright/test" +import { expect, sourceURL, story } from "../../storybook/playwright/story" + +const fixture = sourceURL(new URL("../../gui-extensions/src/terminal/terminal.fixture.tsx", import.meta.url)) + +const mountTerminal = async (page: Page, autoFocus: boolean) => { + await page.evaluate( + async (input) => { + const { mountTerminalFocus } = await import(input.fixture) + mountTerminalFocus({ autoFocus: input.autoFocus }) + }, + { fixture, autoFocus }, + ) + const root = page.getByTestId("terminal-focus-fixture") + await expect(root.locator('[data-component="terminal"] textarea')).toBeAttached() + // The terminal's deferred focus and restore run on timers queued when it opened; let them run first. + await page.evaluate( + () => new Promise((resolve) => setTimeout(() => setTimeout(() => requestAnimationFrame(() => resolve()), 0), 0)), + ) + + return root +} + +story.beforeEach(async ({ mount }) => { + // Any story loads the app styles; the fixture mounts the real terminal beside it. + await mount("ui-line-comment--editor") +}) + +story("a terminal opened with a focus request keeps focus after the request is consumed", async ({ page }) => { + const root = await mountTerminal(page, true) + + await expect(root.locator('[data-component="terminal"] textarea')).toBeFocused() +}) + +story("a terminal opened without a focus request leaves focus in the composer", async ({ page }) => { + const root = await mountTerminal(page, false) + + await expect(root.getByRole("textbox", { name: "Composer", exact: true })).toBeFocused() +}) diff --git a/packages/gui-extensions/src/terminal/terminal.fixture.tsx b/packages/gui-extensions/src/terminal/terminal.fixture.tsx new file mode 100644 index 000000000000..d1befb46d6af --- /dev/null +++ b/packages/gui-extensions/src/terminal/terminal.fixture.tsx @@ -0,0 +1,65 @@ +import { ThemeProvider } from "@opencode/ui/theme/context" +import { createSignal } from "solid-js" +import { render } from "solid-js/web" +import { ExtensionContext, type Context, type ServerRef } from "../sdk" +import { Terminal } from "./terminal" + +/** + * Mounts the real terminal beside a focused composer field, as the side panel does when the user opens a terminal while + * typing. The PTY connection never resolves, so only the mount and focus lifecycle runs. + */ +export function mountTerminalFocus(input: { autoFocus: boolean }) { + const host = document.createElement("div") + host.dataset.testid = "terminal-focus-fixture" + host.style.cssText = "display:flex;flex-direction:column;gap:8px;width:640px;height:360px" + document.body.append(host) + + const composer = document.createElement("textarea") + composer.setAttribute("aria-label", "Composer") + host.append(composer) + composer.focus() + + const surface = document.createElement("div") + surface.style.cssText = "position:relative;flex:1" + host.append(surface) + + // As the panel's focus request does, consuming the request turns autoFocus off. + const [requested, setRequested] = createSignal(input.autoFocus) + const extension = { + t: (key: string) => key, + appearance: { font: () => "monospace" }, + keybinds: { matches: () => false }, + system: { openExternal() {} }, + } as unknown as Context + const server = { + id: "fixture", + name: "fixture", + url: "http://127.0.0.1:1", + client: { pty: { connect: { token: () => new Promise(() => {}) } } }, + } as unknown as ServerRef + + const dispose = render( + () => ( + + + + import("ghostty-web").then(async (mod) => ({ mod, ghostty: await mod.Ghostty.load() })) + } + autoFocus={requested()} + onAutoFocus={() => setRequested(false)} + /> + + + ), + surface, + ) + + return () => { + dispose() + host.remove() + } +} diff --git a/packages/gui-extensions/src/terminal/terminal.tsx b/packages/gui-extensions/src/terminal/terminal.tsx index 95b4251a0901..419f8674ba41 100644 --- a/packages/gui-extensions/src/terminal/terminal.tsx +++ b/packages/gui-extensions/src/terminal/terminal.tsx @@ -507,12 +507,15 @@ export const Terminal = (props: TerminalProps) => { handleLinkClick, }) - if (local.autoFocus === true) { + // onAutoFocus consumes the request, which flips autoFocus, so read it once. + const autoFocus = local.autoFocus === true + + if (autoFocus) { focusTerminal() local.onAutoFocus?.() } - if (local.autoFocus !== true) { + if (!autoFocus) { const restoreFocus = () => { const current = document.activeElement From b5b29d5dc81156cf5ce97ad04007400e2440e22d Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 21 Oct 2026 11:27:31 +0800 Subject: [PATCH 3/4] fix(app): keep focus in the terminal selected with tab shortcuts --- .../gui-extensions/src/terminal/model.test.ts | 53 +++++++++++++++++++ packages/gui-extensions/src/terminal/model.ts | 3 +- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/gui-extensions/src/terminal/model.test.ts diff --git a/packages/gui-extensions/src/terminal/model.test.ts b/packages/gui-extensions/src/terminal/model.test.ts new file mode 100644 index 000000000000..4938f72e5e37 --- /dev/null +++ b/packages/gui-extensions/src/terminal/model.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import type { ServerRef, Sessions, Storage } from "../sdk" +import { createTerminalModel } from "./model" + +const storage = { + memory: (_key: string, options: { initial: unknown }) => [options.initial], + store: () => ({ value: { all: [] }, ready: () => true }), +} as unknown as Storage +const sessions = { list: () => [] } as unknown as Sessions +const server = { id: "server" } as unknown as ServerRef + +const cleanups: VoidFunction[] = [] + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()) + document.body.replaceChildren() +}) + +const workspace = () => + createRoot((dispose) => { + cleanups.push(dispose) + return createTerminalModel({ storage, sessions }).load({ server, directory: "/workspace" }) + }) + +const field = (parent: HTMLElement) => { + const input = document.createElement("input") + parent.append(input) + return input +} + +describe("terminal focus requests", () => { + test("survive the side region focusing its selected tab while switching terminals", () => { + const side = document.createElement("div") + side.id = "review-panel" + document.body.append(side) + const terminal = workspace() + + terminal.requestFocus("pty_2") + field(side).focus() + + expect(terminal.focusRequested("pty_2")).toBe(true) + }) + + test("are cancelled when focus leaves the side region", () => { + const terminal = workspace() + + terminal.requestFocus("pty_2") + field(document.body).focus() + + expect(terminal.focusRequested("pty_2")).toBe(false) + }) +}) diff --git a/packages/gui-extensions/src/terminal/model.ts b/packages/gui-extensions/src/terminal/model.ts index 2379c0cf4370..221c0c5db8f6 100644 --- a/packages/gui-extensions/src/terminal/model.ts +++ b/packages/gui-extensions/src/terminal/model.ts @@ -177,7 +177,8 @@ function createWorkspace(input: { storage: Storage; server: ServerRef; directory if (!(event.target instanceof Element)) return - if (event.target.closest("#terminal-panel")) return + // The side region focuses its selected tab trigger or root while switching tabs; only focus leaving it cancels. + if (event.target.closest("#terminal-panel, #review-panel")) return cancelFocus() } From 2feaa37dab42bc425f96fbc358eeb178b13a2839 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 22 Oct 2026 09:35:08 +0800 Subject: [PATCH 4/4] test(app): document terminal focus test stubs --- packages/gui-extensions/src/terminal/model.test.ts | 7 +++++++ packages/gui-extensions/src/terminal/terminal.fixture.tsx | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/packages/gui-extensions/src/terminal/model.test.ts b/packages/gui-extensions/src/terminal/model.test.ts index 4938f72e5e37..599b60eaf991 100644 --- a/packages/gui-extensions/src/terminal/model.test.ts +++ b/packages/gui-extensions/src/terminal/model.test.ts @@ -3,11 +3,16 @@ import { createRoot } from "solid-js" import type { ServerRef, Sessions, Storage } from "../sdk" import { createTerminalModel } from "./model" +// SAFETY: the workspace model reads only these members when it loads and handles focus. const storage = { memory: (_key: string, options: { initial: unknown }) => [options.initial], store: () => ({ value: { all: [] }, ready: () => true }), } as unknown as Storage + +// SAFETY: load reads only sessions.list and server.id. const sessions = { list: () => [] } as unknown as Sessions + +// SAFETY: load reads only sessions.list and server.id. const server = { id: "server" } as unknown as ServerRef const cleanups: VoidFunction[] = [] @@ -20,12 +25,14 @@ afterEach(() => { const workspace = () => createRoot((dispose) => { cleanups.push(dispose) + return createTerminalModel({ storage, sessions }).load({ server, directory: "/workspace" }) }) const field = (parent: HTMLElement) => { const input = document.createElement("input") parent.append(input) + return input } diff --git a/packages/gui-extensions/src/terminal/terminal.fixture.tsx b/packages/gui-extensions/src/terminal/terminal.fixture.tsx index d1befb46d6af..35c81c8ea749 100644 --- a/packages/gui-extensions/src/terminal/terminal.fixture.tsx +++ b/packages/gui-extensions/src/terminal/terminal.fixture.tsx @@ -25,12 +25,16 @@ export function mountTerminalFocus(input: { autoFocus: boolean }) { // As the panel's focus request does, consuming the request turns autoFocus off. const [requested, setRequested] = createSignal(input.autoFocus) + + // SAFETY: the terminal reads only these extension members while it mounts and focuses. const extension = { t: (key: string) => key, appearance: { font: () => "monospace" }, keybinds: { matches: () => false }, system: { openExternal() {} }, } as unknown as Context + + // SAFETY: the terminal reads only these server members; the PTY token never resolves, so it never connects. const server = { id: "fixture", name: "fixture",