["resolvedTheme"];
+ connection: PassthroughConnectionState;
+};
+
+function usePassthroughEffects({
+ props,
+ refs,
+ terminalId,
+ environmentId,
+ taskId,
+ sessionId,
+ canConnect,
+ wsBaseUrl,
+ resolvedTheme,
+ connection,
+}: PassthroughEffectsOptions) {
+ const {
+ mode,
+ label,
+ pendingCommand,
+ onCommandSent,
+ disableWebgl,
+ manualInputRouting,
+ onWsReady,
+ enableTouchScroll,
+ } = props;
+ const { terminalRef, xtermRef, fitAddonRef, wsRef, attachAddonRef } = refs;
+ const { isTerminalReady, isConnected, onTerminalReady, onConnected, onDisconnected } = connection;
const linkHandler = useTerminalLinkHandler();
const terminalFontFamily = useAppStore((s) => s.userSettings.terminalFontFamily);
const terminalFontSize = useAppStore((s) => s.userSettings.terminalFontSize);
const sendResize = useSendResize(wsRef);
const fitAndResize = useFitAndResize({
- xtermRef: refs.xtermRef,
- fitAddonRef: refs.fitAddonRef,
- terminalRef: refs.terminalRef,
+ xtermRef,
+ fitAddonRef,
+ terminalRef,
lastDimensionsRef: refs.lastDimensionsRef,
sendResize,
});
-
const sendInput = useSendInput(wsRef);
const toggleBottomTerminal = useAppStore((s) => s.toggleBottomTerminal);
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
@@ -286,9 +352,9 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
containerRef,
);
useTerminalInit({
- terminalRef: refs.terminalRef,
- xtermRef: refs.xtermRef,
- fitAddonRef: refs.fitAddonRef,
+ terminalRef,
+ xtermRef,
+ fitAddonRef,
isInitializedRef: refs.isInitializedRef,
lastDimensionsRef: refs.lastDimensionsRef,
resizeTimeoutRef: refs.resizeTimeoutRef,
@@ -312,14 +378,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
resolvedTheme,
});
useTerminalBusyTracking(terminalId, xtermRef, mode === "shell", isTerminalReady);
-
- useTouchScroll({
- terminalRef: refs.terminalRef,
- xtermRef: refs.xtermRef,
- enabled: !!enableTouchScroll,
- isTerminalReady,
- });
-
+ useTouchScroll({ terminalRef, xtermRef, enabled: !!enableTouchScroll, isTerminalReady });
useWebSocketConnection({
taskId,
sessionId,
@@ -340,8 +399,47 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
manualInputRouting,
onWsReady,
});
-
usePendingCommand(pendingCommand, isConnected, wsRef, onCommandSent);
+ return { containerRef, search, isConnected };
+}
+
+export function PassthroughTerminal(props: PassthroughTerminalProps) {
+ const { mode, autoFocus, onXtermReady } = props;
+ const { resolvedTheme } = useTheme();
+ const terminalId = mode === "shell" ? props.terminalId : undefined;
+ const environmentId = mode === "shell" ? props.environmentId : undefined;
+ const refs = useTerminalRefs();
+ const { terminalRef, xtermRef, wsRef, attachAddonRef } = refs;
+ const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
+ const environmentSessionId = useEnvironmentSessionId();
+ const sessionId = mode === "agent" ? (props.sessionId ?? storeSessionId) : environmentSessionId;
+ const { session } = useSession(sessionId);
+ const taskId = session?.task_id ?? null;
+ const connectionID = mode === "agent" ? sessionId : environmentId;
+ const environmentEnded = useEnvironmentEnded(environmentId);
+ const canConnect = computeCanConnect(mode, connectionID, sessionId, environmentEnded);
+ const wsBaseUrl = useWsBaseUrl();
+ const connection = usePassthroughConnection({
+ connectionID,
+ autoFocus,
+ onXtermReady,
+ xtermRef,
+ attachAddonRef,
+ wsRef,
+ });
+ const paneState = computeTerminalPaneState(mode, environmentEnded, connection.isConnected);
+ const { containerRef, search } = usePassthroughEffects({
+ props,
+ refs,
+ terminalId,
+ environmentId,
+ taskId,
+ sessionId,
+ canConnect,
+ wsBaseUrl,
+ resolvedTheme,
+ connection,
+ });
return (
{
+ afterEach(() => {
+ document.body.replaceChildren();
+ });
+
+ it("@covers AC-UI-QUICK-TERMINAL-001.11 claims unmodified Escape from xterm", () => {
+ const textarea = document.createElement("textarea");
+ document.body.append(textarea);
+ textarea.focus();
+
+ expect(shouldGuardPassthroughEscape(keyboardEvent("Escape", textarea), textarea)).toBe(true);
+ });
+
+ it("does not claim modified Escape or Escape from another focused control", () => {
+ const textarea = document.createElement("textarea");
+ const button = document.createElement("button");
+ document.body.append(textarea, button);
+
+ expect(
+ shouldGuardPassthroughEscape(keyboardEvent("Escape", textarea, { ctrlKey: true }), textarea),
+ ).toBe(false);
+
+ button.focus();
+ expect(shouldGuardPassthroughEscape(keyboardEvent("Escape", button), textarea)).toBe(false);
+ });
+
+ it("lets the dialog handle Escape after the passthrough connection ends", () => {
+ const textarea = document.createElement("textarea");
+ document.body.append(textarea);
+ textarea.focus();
+
+ expect(shouldGuardPassthroughEscape(keyboardEvent("Escape", textarea), textarea, false)).toBe(
+ false,
+ );
+ });
+});
+
describe("computeCanConnect", () => {
it("lets the backend wait for an agent passthrough session that is not cached as ready", () => {
expect(computeCanConnect("agent", "session-1", "session-1")).toBe(true);
diff --git a/apps/web/e2e/helpers/ws-capture.ts b/apps/web/e2e/helpers/ws-capture.ts
index 177313682c..4ec58cb4d5 100644
--- a/apps/web/e2e/helpers/ws-capture.ts
+++ b/apps/web/e2e/helpers/ws-capture.ts
@@ -53,10 +53,10 @@ function decodeBinaryFrame(payload: Buffer | Uint8Array): string | null {
*
* - JSON `{action: "shell.input", payload: {session_id, data}}` over the
* kandev gateway WS — the per-session default shell.
- * - Raw binary frames over a PassthroughTerminal's dedicated WS — used by
- * mobile multi-terminal where the on-screen terminal owns its own
- * AttachAddon connection. The session ID is unknown for these frames
- * (the WS is env+terminalId scoped) so an empty string is reported.
+ * - Raw text or binary frames over a PassthroughTerminal's dedicated WS.
+ * AttachAddon sends ordinary xterm input as text; manual mobile routing
+ * sends binary. The session ID is unknown for these frames, so an empty
+ * string is reported.
*
* Tests assert on the `data` field, which works the same either way.
*
@@ -67,9 +67,14 @@ function decodeBinaryFrame(payload: Buffer | Uint8Array): string | null {
export function attachShellInputCapture(page: Page): { frames: ShellInputFrame[] } {
const frames: ShellInputFrame[] = [];
page.on("websocket", (ws) => {
+ const isTerminalSocket = new URL(ws.url()).pathname.startsWith("/terminal/");
ws.on("framesent", (event) => {
const payload = event.payload;
if (typeof payload === "string") {
+ if (isTerminalSocket) {
+ if (payload) frames.push({ sessionId: "", data: payload });
+ return;
+ }
if (!payload.includes('"shell.input"')) return;
try {
const msg = JSON.parse(payload) as ParsedFrame;
diff --git a/apps/web/e2e/tests/chat/quick-chat.spec.ts b/apps/web/e2e/tests/chat/quick-chat.spec.ts
index 5bf1a9d782..f78a79e387 100644
--- a/apps/web/e2e/tests/chat/quick-chat.spec.ts
+++ b/apps/web/e2e/tests/chat/quick-chat.spec.ts
@@ -2,7 +2,8 @@ import { type Locator, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
-import { attachAvailableCommandsCapture } from "../../helpers/ws-capture";
+import { attachAvailableCommandsCapture, attachShellInputCapture } from "../../helpers/ws-capture";
+import { SHORTCUTS } from "@/lib/keyboard/constants";
import {
openQuickChatSetup,
openQuickChatWithAgent,
@@ -132,6 +133,66 @@ test.describe("Quick Chat", () => {
await expect(origin).not.toHaveAttribute("data-quick-chat-silent-focus");
});
+ test("@covers AC-UI-QUICK-TERMINAL-001.10 AC-UI-QUICK-TERMINAL-001.11 passthrough Escape stays in the TUI and the shortcut closes Quick Chat", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const capture = attachShellInputCapture(testPage);
+ const { agents } = await apiClient.listAgents();
+ const profileOwner = agents.find((agent) =>
+ agent.profiles?.some((profile) => profile.id === seedData.agentProfileId),
+ );
+ if (!profileOwner) throw new Error("Seed agent profile owner was not found");
+
+ let profileId: string | null = null;
+
+ try {
+ const profile = await apiClient.createAgentProfile(profileOwner.id, "Quick Chat Escape TUI", {
+ model: "mock-fast",
+ cli_passthrough: true,
+ });
+ profileId = profile.id;
+ await apiClient.updateWorkspace(seedData.workspaceId, {
+ default_agent_profile_id: profile.id,
+ });
+
+ const dialog = await openQuickChatSetup(testPage);
+ await dialog.getByTestId("quick-chat-start").click();
+
+ const terminal = dialog.getByTestId("passthrough-terminal");
+ await expect(terminal).toBeVisible({ timeout: 15_000 });
+ await expect(terminal.getByTestId("passthrough-loading")).not.toBeVisible({
+ timeout: 30_000,
+ });
+ await terminal.click();
+ await expect(terminal.locator(".xterm-helper-textarea")).toBeFocused();
+
+ capture.frames.length = 0;
+ await testPage.keyboard.press("Escape");
+
+ await expect(dialog).toBeVisible();
+ await expect
+ .poll(() => capture.frames.some((frame) => frame.data === "\x1b"), {
+ message: "Expected xterm to forward Escape to the passthrough terminal WebSocket",
+ })
+ .toBe(true);
+
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ const quickChatShortcut = `${modifier}+Shift+${SHORTCUTS.QUICK_CHAT.key}`;
+ await testPage.keyboard.press(quickChatShortcut);
+ await expect(dialog).not.toBeVisible();
+ } finally {
+ try {
+ await apiClient.updateWorkspace(seedData.workspaceId, {
+ default_agent_profile_id: seedData.agentProfileId,
+ });
+ } finally {
+ if (profileId) await apiClient.cleanupTestProfiles([profileId]);
+ }
+ }
+ });
+
test("clarification shortcuts work after clicking the message surface", async ({ testPage }) => {
const dialog = await openQuickChatWithAgent(testPage);
await sendQuickChatMessage(dialog, testPage, "/e2e:clarification-multi");
diff --git a/apps/web/hooks/use-quick-chat-launcher.test.ts b/apps/web/hooks/use-quick-chat-launcher.test.ts
index db76dbb841..9ee5e30614 100644
--- a/apps/web/hooks/use-quick-chat-launcher.test.ts
+++ b/apps/web/hooks/use-quick-chat-launcher.test.ts
@@ -1,10 +1,19 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+const requestQuickChatClose = vi.hoisted(() => vi.fn());
+
+vi.mock("@/components/quick-chat/quick-chat-focus", () => ({
+ captureQuickChatLauncherFocus: vi.fn(),
+ requestQuickChatClose,
+}));
+
const openQuickChat = vi.fn();
+const closeQuickChat = vi.fn();
const WORKSPACE_ID = "workspace-1";
const ACTIVE_CONFIG_ID = "config-active";
let activeSessionId: string | null = null;
+let isOpen = false;
let sessions: Array<{
sessionId: string;
workspaceId: string;
@@ -13,7 +22,7 @@ let sessions: Array<{
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
- selector({ openQuickChat, quickChat: { sessions, activeSessionId } }),
+ selector({ openQuickChat, closeQuickChat, quickChat: { isOpen, sessions, activeSessionId } }),
}));
import { useQuickChatLauncher } from "./use-quick-chat-launcher";
@@ -21,10 +30,38 @@ import { useQuickChatLauncher } from "./use-quick-chat-launcher";
beforeEach(() => {
sessions = [];
activeSessionId = null;
+ isOpen = false;
openQuickChat.mockReset();
+ closeQuickChat.mockReset();
+ requestQuickChatClose.mockReset();
+ requestQuickChatClose.mockReturnValue(false);
});
describe("useQuickChatLauncher typed sessions", () => {
+ it("@covers AC-UI-QUICK-TERMINAL-001.10 closes an open dialog when toggle behavior is enabled", () => {
+ isOpen = true;
+ requestQuickChatClose.mockReturnValue(true);
+ const { result } = renderHook(() =>
+ useQuickChatLauncher(WORKSPACE_ID, "chat", { toggleWhenOpen: true }),
+ );
+
+ act(() => result.current());
+
+ expect(requestQuickChatClose).toHaveBeenCalledTimes(1);
+ expect(closeQuickChat).not.toHaveBeenCalled();
+ expect(openQuickChat).not.toHaveBeenCalled();
+ });
+
+ it("keeps ordinary launchers open-only when the dialog is already open", () => {
+ isOpen = true;
+ const { result } = renderHook(() => useQuickChatLauncher(WORKSPACE_ID));
+
+ act(() => result.current());
+
+ expect(closeQuickChat).not.toHaveBeenCalled();
+ expect(openQuickChat).toHaveBeenCalledWith("", WORKSPACE_ID, undefined, "chat");
+ });
+
it("opens an ordinary session from the generic quick chat launcher", () => {
sessions = [
{ sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat" },
diff --git a/apps/web/hooks/use-quick-chat-launcher.ts b/apps/web/hooks/use-quick-chat-launcher.ts
index 833215da1b..19a8ec86a5 100644
--- a/apps/web/hooks/use-quick-chat-launcher.ts
+++ b/apps/web/hooks/use-quick-chat-launcher.ts
@@ -1,10 +1,14 @@
import { useCallback } from "react";
import { useAppStore } from "@/components/state-provider";
-import { captureQuickChatLauncherFocus } from "@/components/quick-chat/quick-chat-focus";
+import {
+ captureQuickChatLauncherFocus,
+ requestQuickChatClose,
+} from "@/components/quick-chat/quick-chat-focus";
import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types";
type QuickChatLauncherOptions = {
silentFocusReturn?: boolean;
+ toggleWhenOpen?: boolean;
};
/**
@@ -17,12 +21,19 @@ export function useQuickChatLauncher(
options: QuickChatLauncherOptions = {},
) {
const silentFocusReturn = options.silentFocusReturn ?? true;
+ const toggleWhenOpen = options.toggleWhenOpen ?? false;
const openQuickChat = useAppStore((state) => state.openQuickChat);
+ const closeQuickChat = useAppStore((state) => state.closeQuickChat);
+ const isQuickChatOpen = useAppStore((state) => state.quickChat.isOpen);
const quickChatSessions = useAppStore((state) => state.quickChat.sessions);
const activeSessionId = useAppStore((state) => state.quickChat.activeSessionId);
const handleOpenQuickChat = useCallback(() => {
if (!workspaceId) return;
+ if (toggleWhenOpen && isQuickChatOpen) {
+ if (!requestQuickChatClose()) closeQuickChat();
+ return;
+ }
captureQuickChatLauncherFocus({ silent: silentFocusReturn });
// If there's an existing session, open it. Otherwise just open the modal with agent picker
@@ -43,7 +54,17 @@ export function useQuickChatLauncher(
// Open modal without a session - will show agent picker
openQuickChat("", workspaceId, undefined, kind);
}
- }, [workspaceId, kind, quickChatSessions, activeSessionId, openQuickChat, silentFocusReturn]);
+ }, [
+ workspaceId,
+ toggleWhenOpen,
+ isQuickChatOpen,
+ closeQuickChat,
+ silentFocusReturn,
+ quickChatSessions,
+ kind,
+ activeSessionId,
+ openQuickChat,
+ ]);
return handleOpenQuickChat;
}
diff --git a/docs/plans/quick-chat-passthrough-escape/plan.md b/docs/plans/quick-chat-passthrough-escape/plan.md
new file mode 100644
index 0000000000..d7b0b73dc0
--- /dev/null
+++ b/docs/plans/quick-chat-passthrough-escape/plan.md
@@ -0,0 +1,107 @@
+---
+created: 2026-08-28
+status: done
+requirements:
+ - REQ-UI-QUICK-TERMINAL-001
+system_design:
+ - ../../specs/ui/system-design/quick-terminal.md
+legacy_specs: []
+---
+
+# Implementation Plan: Quick Chat Passthrough Escape
+
+## Overview
+
+Make the existing configurable Quick Chat action a safe dialog toggle, then let a focused agent
+passthrough terminal keep unmodified Escape for its TUI. Both changes ship as one vertical repair
+because terminal Escape cannot be freed until the shared dialog has a discoverable keyboard close
+path.
+
+## Scope
+
+### In scope
+
+- Toggle the shared Quick Chat dialog from the existing configurable `QUICK_CHAT` action.
+- Keep other Quick Chat launchers open/select-only.
+- Prevent Radix dismissal only while a connected xterm terminal with an installed AttachAddon and
+ open WebSocket owns unmodified Escape.
+- Preserve xterm's normal Escape-byte delivery to the agent terminal WebSocket.
+- Preserve nested Quick Chat escape guards and the existing mobile close action.
+
+### Out of scope
+
+- A new "Escape closes Quick Chat" preference.
+- Changes to agent cancellation APIs or provider-specific key mappings.
+- Changes to bottom terminal, mobile terminal, or Settings terminal composition.
+- Changes to pointer dismissal or Quick Chat geometry.
+
+## Technical approach
+
+### Configurable dialog toggle
+
+Extend `useQuickChatLauncher` with an explicit toggle option. When enabled and the live shared
+dialog state is open, request the registered modal close handler and fall back to `closeQuickChat`
+when no handler exists; otherwise retain the current kind-scoped session/setup selection. Enable
+this option only for the ordinary Quick Chat action registered by
+`GlobalCommands`, which already resolves the user's `QUICK_CHAT` override.
+Register that binding in capture phase with propagation stopped after a match so it wins before
+xterm and remains an application command rather than terminal input.
+
+### Focus-scoped terminal Escape
+
+Add a stable Escape predicate to `PassthroughTerminal` and register it through
+`useClarificationEscapeGuard`. Arm it only while the terminal is connected, its AttachAddon is
+installed, and its WebSocket is open; then match only unmodified Escape directed at xterm's helper textarea.
+The dialog prevents its own dismissal while allowing the same keyboard event to continue to xterm
+and `AttachAddon`.
+
+### Responsive behavior
+
+The desktop outcome is a keyboard-toggleable shared dialog whose focused passthrough terminal owns
+Escape. The mobile entry remains the existing Home/task-switcher Quick Chat action, and the nearest
+shipped exemplar is `mobile-quick-chat-entry.spec.ts`: the full-height dialog keeps its visible
+close control, internal scroll ownership, dynamic viewport sizing, and safe-area behavior. No
+mobile composition or touch target changes are required.
+
+## Tests
+
+- `apps/web/hooks/use-quick-chat-launcher.test.ts`: cover opt-in close behavior, retained default
+ open behavior, and session preservation through the existing close action.
+- `apps/web/components/quick-chat/quick-chat-focus.test.ts` and
+ `apps/web/components/quick-chat/use-quick-chat-modal.test.ts`: cover the registered close
+ lifecycle and cancellation of a deferred agent start.
+- `apps/web/components/global-commands.test.tsx`: prove the configurable ordinary Quick Chat action
+ opts into toggle behavior while Configuration Chat does not, and that the keyboard listener has
+ capture-phase precedence over xterm.
+- `apps/web/components/task/use-passthrough-terminal.test.ts`: cover the focus- and modifier-scoped
+ Escape predicate.
+- `apps/web/components/quick-chat/quick-chat-modal.test.tsx`: retain the existing nested-widget
+ escape-guard regressions.
+
+## E2E tests
+
+- `apps/web/e2e/tests/chat/quick-chat.spec.ts`: for
+ `AC-UI-QUICK-TERMINAL-001.10` and `AC-UI-QUICK-TERMINAL-001.11`, create a passthrough Quick Chat,
+ focus xterm, observe an Escape frame on its terminal WebSocket, assert the dialog remains open,
+ then use the configured Quick Chat shortcut to close it.
+- `apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts`: existing
+ `AC-UI-QUICK-TERMINAL-001.12` coverage proves the phone entry and explicit touch close path.
+
+## Work orders
+
+- [x] [Task 01: Repair Quick Chat passthrough cancellation](task-01-passthrough-cancellation.md)
+
+## Verification results
+
+- Focused Vitest suites: 6 files and 71 tests passed, including the registered close lifecycle and
+ deferred-start cancellation regression.
+- Desktop Chromium E2E: passthrough Escape delivery and Quick Chat shortcut dismissal passed.
+- Mobile Chrome E2E: Home-header launch and explicit touch dismissal passed.
+- Targeted ESLint, specification lint, and `git diff --check` passed.
+
+## Risks
+
+- A predicate broader than xterm's helper textarea could swallow Escape from terminal search or
+ other nested controls.
+- Calling `stopPropagation()` would starve xterm; the dialog guard must use `preventDefault()` only.
+- Applying toggle semantics to every launcher would change existing kind-selection behavior.
diff --git a/docs/plans/quick-chat-passthrough-escape/task-01-passthrough-cancellation.md b/docs/plans/quick-chat-passthrough-escape/task-01-passthrough-cancellation.md
new file mode 100644
index 0000000000..2e39a54959
--- /dev/null
+++ b/docs/plans/quick-chat-passthrough-escape/task-01-passthrough-cancellation.md
@@ -0,0 +1,109 @@
+---
+id: "01-passthrough-cancellation"
+title: "Repair Quick Chat passthrough cancellation"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-UI-QUICK-TERMINAL-001
+acceptance_criteria:
+ - AC-UI-QUICK-TERMINAL-001.10
+ - AC-UI-QUICK-TERMINAL-001.11
+ - AC-UI-QUICK-TERMINAL-001.12
+system_design:
+ - ../../specs/ui/system-design/quick-terminal.md
+---
+
+# Task 01: Repair Quick Chat Passthrough Cancellation
+
+## Summary
+
+Give the existing configurable Quick Chat action opt-in toggle semantics and protect a focused
+agent passthrough xterm from Radix's Escape dismissal. Prove that Escape reaches the terminal while
+the dialog stays open, and that the configured action remains a reliable close path.
+
+## In scope
+
+- Add and test the toggle option on `useQuickChatLauncher`.
+- Enable the option for the global ordinary Quick Chat action only.
+- Register a focus-scoped, unmodified Escape predicate from `PassthroughTerminal` only while its
+ AttachAddon and terminal WebSocket are connected.
+- Add desktop browser evidence for terminal delivery, dialog retention, and shortcut dismissal.
+- Re-run the existing mobile touch-close scenario.
+
+## Out of scope
+
+- A new user setting or locale copy.
+- Synthetic Escape writes or changes to agent cancellation APIs.
+- Terminal behavior outside a guard-enabled dismissible surface.
+- UI layout, pointer dismissal, or mobile composition changes.
+
+## Acceptance
+
+- The configured Quick Chat action closes an open shared dialog and keeps its sessions intact; all
+ non-toggle launchers retain their current selection behavior.
+- Unmodified Escape from a connected xterm helper textarea is forwarded on the agent terminal
+ WebSocket and does not close Quick Chat; disconnected terminals and other focus targets retain
+ existing escape behavior.
+- Existing nested-widget and mobile touch-dismissal paths remain green.
+
+## Verification
+
+```bash
+cd apps && pnpm --filter @kandev/web test -- --run hooks/use-quick-chat-launcher.test.ts components/global-commands.test.tsx components/task/use-passthrough-terminal.test.ts components/quick-chat/quick-chat-modal.test.tsx
+cd apps/web && pnpm e2e:run tests/chat/quick-chat.spec.ts -- --grep "passthrough Escape"
+cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-quick-chat-entry.spec.ts -- --grep "opens from the home header and closes with the touch control"
+```
+
+## Files likely touched
+
+- `apps/web/hooks/use-quick-chat-launcher.ts`
+- `apps/web/hooks/use-quick-chat-launcher.test.ts`
+- `apps/web/components/global-commands.tsx`
+- `apps/web/components/global-commands.test.tsx`
+- `apps/web/components/task/passthrough-terminal.tsx`
+- `apps/web/components/task/use-passthrough-terminal.test.ts`
+- `apps/web/e2e/helpers/ws-capture.ts`
+- `apps/web/e2e/tests/chat/quick-chat.spec.ts`
+
+## Dependencies
+
+None.
+
+## Risks
+
+- Radix handles Escape in document capture before xterm; the guard must match the terminal's exact
+ focus and modifier scope without stopping propagation.
+- The E2E WebSocket observer must attach before the terminal socket opens and filter out the main
+ JSON gateway plus unrelated terminal frames.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- `REQ-UI-QUICK-TERMINAL-001`, especially acceptance criteria `.10` through `.12`.
+- `docs/specs/ui/system-design/quick-terminal.md`, section "Launcher toggle and terminal Escape
+ routing".
+- Existing Quick Chat escape-guard tests and terminal WebSocket capture patterns.
+
+## Results
+
+- Added opt-in toggle semantics to the shared Quick Chat launcher and enabled them only for the
+ configurable global Quick Chat command. The launcher requests the mounted modal close lifecycle,
+ falling back to the store action only when no handler is registered. The capture-phase command
+ closes an open dialog before xterm can consume the configured chord.
+- Registered a focused, unmodified-Escape guard from `PassthroughTerminal` only while its
+ AttachAddon is installed on an open WebSocket. Radix keeps the dialog open while xterm forwards
+ the same key as `\x1b` through `AttachAddon`.
+- Extended the E2E WebSocket observer to capture AttachAddon's raw text frames in addition to the
+ existing JSON gateway and binary terminal frames.
+- RED evidence: the focused unit tests initially failed on the missing toggle, shortcut options,
+ and terminal predicate; the desktop E2E initially failed because Escape closed the dialog.
+- GREEN evidence:
+ - `cd apps && pnpm --filter @kandev/web test -- --run hooks/use-quick-chat-launcher.test.ts components/global-commands.test.tsx components/task/use-passthrough-terminal.test.ts components/quick-chat/quick-chat-modal.test.tsx` (4 files, 41 tests passed)
+ - `cd apps/web && pnpm e2e:run tests/chat/quick-chat.spec.ts -- --grep "passthrough Escape"` (1 Chromium test passed)
+ - `cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-quick-chat-entry.spec.ts -- --grep "opens from the home header and closes with the touch control"` (1 mobile-chrome test passed)
+ - Targeted ESLint, `python3 scripts/lint-spec-files.py --all`, and `git diff --check` passed.
diff --git a/docs/specs/ui/requirements/quick-terminal.md b/docs/specs/ui/requirements/quick-terminal.md
index 1c3e50c5de..75fb6551b8 100644
--- a/docs/specs/ui/requirements/quick-terminal.md
+++ b/docs/specs/ui/requirements/quick-terminal.md
@@ -2,7 +2,7 @@
status: active
system: ui
created: 2026-08-03
-updated: 2026-08-27
+updated: 2026-08-28
owners:
- kandev
---
@@ -29,11 +29,15 @@ Quick Chat and Quick Terminal are both short-lived utilities reached from the sa
- **AC-UI-QUICK-TERMINAL-001.7:** Renameable conversation tabs expose **Rename** from a context menu on fine-pointer devices and from a visible tab action on coarse-pointer devices. The backing-task rename persistence remains unchanged. Terminal labels stay fixed.
- **AC-UI-QUICK-TERMINAL-001.8:** Multiple terminal tabs can run concurrently. Input, output, resize, exit, and error state belong to the selected terminal and must not affect sibling terminals.
- **AC-UI-QUICK-TERMINAL-001.9:** When Escape closes the shared dialog, the system shall return focus to the launcher without a visible focus indicator. When focus later returns through ordinary keyboard navigation, the launcher shall show its normal focus indicator.
+- **AC-UI-QUICK-TERMINAL-001.10:** The user-configurable Quick Chat keyboard shortcut shall toggle the shared dialog. Activating the configured shortcut while the dialog is closed opens ordinary Quick Chat; activating it while the dialog is open closes the dialog without removing its chat or terminal sessions.
+- **AC-UI-QUICK-TERMINAL-001.11:** When a connected agent passthrough terminal owns keyboard focus inside the shared dialog, with its xterm AttachAddon installed on an open terminal WebSocket, unmodified Escape shall reach the agent TUI and the dialog shall remain open. Escape from non-terminal focus targets, or while that terminal connection is unavailable, retains the existing dialog and nested-widget behavior.
+- **AC-UI-QUICK-TERMINAL-001.12:** Phone and coarse-pointer users shall retain the existing visible Quick Chat close action and touch dismissal paths without depending on a hardware keyboard shortcut.
## Out of scope
- Changing pointer dismissal, tooltip behavior, or focus indicators on unrelated controls.
- Changing the Quick Chat layout or its mobile touch controls.
+- Adding a separate preference for whether Escape closes Quick Chat. Focus ownership determines the behavior.
### REQ-UI-QUICK-TERMINAL-002: Quick Chat Tab Organization
diff --git a/docs/specs/ui/system-design/quick-terminal.md b/docs/specs/ui/system-design/quick-terminal.md
index 495be6ecab..37a106fcf7 100644
--- a/docs/specs/ui/system-design/quick-terminal.md
+++ b/docs/specs/ui/system-design/quick-terminal.md
@@ -5,7 +5,7 @@ requirements:
- REQ-UI-QUICK-TERMINAL-001
- REQ-UI-QUICK-TERMINAL-002
created: 2026-08-03
-updated: 2026-08-27
+updated: 2026-08-28
owners:
- kandev
---
@@ -20,7 +20,7 @@ records and Quick Terminal descriptors remain owned by their existing backend se
| Requirement | Design section |
| --- | --- |
-| `REQ-UI-QUICK-TERMINAL-001` | [Migrated source detail](#migrated-source-detail), [Launcher focus return](#launcher-focus-return) |
+| `REQ-UI-QUICK-TERMINAL-001` | [Migrated source detail](#migrated-source-detail), [Launcher focus return](#launcher-focus-return), [Launcher toggle and terminal Escape routing](#launcher-toggle-and-terminal-escape-routing) |
| `REQ-UI-QUICK-TERMINAL-002` | [Tab order and editing](#tab-order-and-editing) |
## Tab order and editing
@@ -226,6 +226,36 @@ does not add the marker or move focus.
This contract applies to Quick Chat and Quick Terminal launchers that use the shared provider. It
does not change pointer dismissal, Configuration Chat focus ownership, or unrelated controls.
+## Launcher toggle and terminal Escape routing
+
+The configurable `QUICK_CHAT` binding uses a toggle-capable ordinary-chat launcher. The launcher
+reads the live `quickChat.isOpen` state: it requests the mounted modal's close lifecycle, falling
+back to `closeQuickChat` when no modal handler is registered, and otherwise follows the existing
+workspace- and kind-scoped `openQuickChat` selection flow. Only the global keyboard/command action
+enables toggle behavior for ordinary Quick Chat. The Settings Configuration Chat floating launcher
+is an explicit pointer/touch exception: it also closes its controlled panel on reactivation. Other
+pointer and touch launchers continue to open or select their matching content, and the existing
+shortcut override remains the single settings and persistence contract. The global binding listens
+in capture phase and stops a matched toggle chord before focus-trapped controls such as xterm can
+consume it as terminal input.
+
+`PassthroughTerminal` registers a memoized predicate with the existing
+`ClarificationEscapeGuardProvider`. The predicate is armed only while the terminal is connected,
+its `AttachAddon` is installed, and its dedicated WebSocket is open; then it claims only an
+unmodified `Escape` whose event target, or current focus fallback, is xterm's helper textarea.
+Radix consults the predicate from its document capture listener. A match calls `preventDefault()` to
+stop dialog dismissal but does not stop propagation, so xterm's normal keyboard handler emits the
+Escape byte and `AttachAddon` forwards it on the dedicated terminal WebSocket.
+
+The guard hook remains a no-op outside a provider. Bottom-panel and mobile terminal embeddings
+therefore keep their current behavior, and focus on a Quick Chat tab, composer, setup control, or
+terminal search control does not arm the terminal guard. Existing clarification, suggestion, and
+reverse-search guards keep their two-stage behavior.
+
+This change adds no setting, persisted state, or mobile composition. The phone dialog retains its
+full-height surface and visible close action; the existing mobile Quick Chat entry test remains the
+touch-dismissal evidence.
+
## Data model
Decision: [ADR-2026-08-05-server-owned-quick-terminal-descriptors](../../../decisions/2026-08-05-server-owned-quick-terminal-descriptors.md)