From af247e1098d8f135a6bd730915647a5bed61f3e6 Mon Sep 17 00:00:00 2001 From: Yaswanth Narvaneni Date: Fri, 16 Oct 2026 12:39:02 +0100 Subject: [PATCH] Put the phone input bar in flow below the output On phones, the floating Type button sat over the bottom-right of the terminal output and opened the dock without the keyboard. It becomes a messaging-style bar in the terminal's layout, below the output: a full- width Message field opens Composer with its draft focused, so one tap raises the keyboard, and a terminal button beside it opens Direct. The bar never covers output, names an unsent draft (Continue draft...), and during a long-press selection it stays hidden but keeps its height, so xterm does not resize and drop the selection. Its targets are 48px and scale with the UI. The other floating controls are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWodzpMyqdBcX25EE9HhDG --- FEATURES.md | 11 +-- web/src/App.tsx | 54 +---------- web/src/components/TerminalView.css | 62 +++++++++++++ .../components/TerminalView.inputMode.test.ts | 33 ++++++- web/src/components/TerminalView.tsx | 90 ++++++++++++++++++- web/src/styles/layout/mobile-nav.css | 48 +--------- 6 files changed, 190 insertions(+), 108 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index e4c440fc..c3493e32 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -179,11 +179,12 @@ Drafts stay in this browser and re-anchor to changed files on refresh. - Floating terminal shortcuts (a `2×8` grid plus side buttons) are customizable under **Configuration > Behavior > Mobile terminal shortcuts** and can be dragged to either edge. -- The **Type** button opens an input dock with two modes: **Composer** (edit a - draft with IME, dictation, and images; **Send** adds Enter) and **Direct** - (keys go straight to the terminal). Its tap raises the keyboard in either - mode; afterwards, sends, scrolling, and shortcut actions leave a dismissed - keyboard closed. +- An input bar below the terminal output opens an input dock with two modes: + **Message…** opens **Composer** (edit a draft with IME, dictation, and + images; **Send** adds Enter) with the keyboard raised, and the terminal + button beside it opens **Direct** (keys go straight to the terminal). The + bar sits in the layout, so it never covers output, and shows + **Continue draft…** while a draft is unsent. - The `>_` button in Composer offers slash commands for Claude Code, Codex, Pi, Kimi Code, Grok Build, and Antigravity CLI. - Install as a PWA for an app-style window; see diff --git a/web/src/App.tsx b/web/src/App.tsx index 6f0e11ad..f6efe057 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { createPortal, flushSync } from "react-dom"; +import { createPortal } from "react-dom"; import { bridge } from "./api"; import { listenForTaskNotificationActivation, @@ -54,7 +54,6 @@ import { Minimize2, MoreHorizontal, PanelTop, - SquarePen, SquareStack, SquareTerminal, X, @@ -169,8 +168,6 @@ import { copyTextFromUserGesture } from "./terminalClipboard"; import { terminalPasteRequest } from "./terminalPaste"; import { activateTerminalComposerDraftScope, - readTerminalComposerDraft, - subscribeTerminalComposerDraft, terminalComposerDraftKey, } from "./terminalComposer"; import { terminalMountKey } from "./terminalConnection"; @@ -1405,8 +1402,6 @@ export default function App() { ]); const [openTerminalComposerScopeKey, setOpenTerminalComposerScopeKey] = useState(null); - const [terminalComposerHasDraft, setTerminalComposerHasDraft] = - useState(false); const [paneJumpOpen, setPaneJumpOpen] = useState(false); const [paneJumpIndex, setPaneJumpIndex] = useState(0); // null keeps the most-recently-used list; a string switches to typed search. @@ -1547,18 +1542,6 @@ export default function App() { }, [terminalComposerScopeKey], ); - useEffect(() => { - if (!activeTerminalComposerDraftKey) { - setTerminalComposerHasDraft(false); - return; - } - const update = (draft: string) => setTerminalComposerHasDraft(draft !== ""); - update(readTerminalComposerDraft(activeTerminalComposerDraftKey)); - return subscribeTerminalComposerDraft( - activeTerminalComposerDraftKey, - update, - ); - }, [activeTerminalComposerDraftKey]); const paneJumpOptions = useMemo(() => { const snapshot = { layout: s.layout, @@ -4076,41 +4059,6 @@ export default function App() { - {activeTerminalComposerDraftKey && !terminalComposerOpen ? ( - - ) : null} - {s.updateInfo?.update_available || s.notice ? createPortal(
diff --git a/web/src/components/TerminalView.css b/web/src/components/TerminalView.css index 2ae033eb..dee854bc 100644 --- a/web/src/components/TerminalView.css +++ b/web/src/components/TerminalView.css @@ -444,6 +444,68 @@ } } +.terminal-mobile-input-bar { + display: none; +} + +/* Phones: a messaging-style input bar in flow below the output, so it never + covers terminal text. */ +:where(html[data-layout="mobile"]) { + .terminal-main:has(> .terminal-mobile-input-bar) { + flex-direction: column; + } + + .terminal-mobile-input-bar { + display: flex; + flex: 0 0 auto; + gap: 8px; + padding: 6px calc(8px + env(safe-area-inset-right, 0px)) + calc(8px + env(safe-area-inset-bottom, 0px)) + calc(8px + env(safe-area-inset-left, 0px)); + background: var(--terminal-bg); + } + + /* Hidden for a touch selection, still holding its height. */ + .terminal-mobile-input-bar[data-reserved] { + visibility: hidden; + } + + .terminal-mobile-input-bar button { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + min-width: max(48px, calc(48px / var(--ui-scale, 1))); + min-height: max(48px, calc(48px / var(--ui-scale, 1))); + padding: 0; + color: var(--text-strong); + border: 1px solid transparent; + border-radius: 24px; + background: color-mix(in srgb, var(--text-strong) 8%, var(--terminal-bg)); + touch-action: manipulation; + } + + .terminal-mobile-input-bar .terminal-mobile-message { + flex: 1 1 auto; + min-width: 0; + justify-content: flex-start; + padding: 0 18px; + color: var(--muted); + font-size: 16px; + } + + .terminal-mobile-message-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .terminal-mobile-input-bar button:disabled { + opacity: 0.5; + } +} + .terminal-touch-selection-ui { zoom: calc(1 / var(--ui-scale, 1)); user-select: none; diff --git a/web/src/components/TerminalView.inputMode.test.ts b/web/src/components/TerminalView.inputMode.test.ts index 26882deb..72fb663f 100644 --- a/web/src/components/TerminalView.inputMode.test.ts +++ b/web/src/components/TerminalView.inputMode.test.ts @@ -379,9 +379,9 @@ async function registerDomTests() { ).toHaveLength(before); } async function typeGesture() { - // Match App's Type click: open+commit, then synchronously focus the dock - // root, whose handler routes Direct focus, and in Composer the draft, - // all in one gesture. + // Match a tap that opens the dock: open+commit, then synchronously focus + // the dock root, whose handler routes Direct focus, and in Composer the + // draft, all in one gesture. await act(async () => { flushSync(() => { open = true; @@ -992,6 +992,33 @@ async function registerDomTests() { expect(currentTerminal().focusCount).toBe(0); }); + test("the phone input bar opens Composer with its draft focused, or Direct", async () => { + await mount(); + const label = () => + container.querySelector(".terminal-mobile-message-label")?.textContent; + expect(label()).toBe("Message claude…"); + await click(".terminal-mobile-message"); + expect(container.querySelector(".terminal-mobile-input-bar")).toBeNull(); + expect(selectedMode()).toBe("composer"); + expect(savedMode()).toBe("composer"); + expect(document.activeElement).toBe( + container.querySelector('[aria-label="Terminal input draft"]'), + ); + expectInputBlocked(); + await click('[aria-label="Close terminal input"]'); + await act(async () => writeTerminalComposerDraft(draftKey, "draft")); + expect(label()).toBe("Continue draft…"); + expect( + container + .querySelector(".terminal-mobile-message") + ?.getAttribute("aria-label"), + ).toBe("Open terminal input, unsent draft"); + await click(".terminal-mobile-direct"); + expect(selectedMode()).toBe("direct"); + expect(savedMode()).toBe("direct"); + expect(document.activeElement).toBe(currentTerminal().textarea); + }); + test("manual mode choices survive close and reopen without opening stdin", async () => { await mount(); await setOpen(true); diff --git a/web/src/components/TerminalView.tsx b/web/src/components/TerminalView.tsx index 791c2aad..1f018a4d 100644 --- a/web/src/components/TerminalView.tsx +++ b/web/src/components/TerminalView.tsx @@ -1,4 +1,4 @@ -import { createPortal } from "react-dom"; +import { createPortal, flushSync } from "react-dom"; import { createReviewAnnotation, MAX_QUOTE_LENGTH, @@ -34,6 +34,7 @@ import { installTerminalCompositionRepair } from "../terminalComposition"; import { Columns2, Keyboard, + SquareTerminal, Maximize2, Minimize2, Rows2, @@ -47,6 +48,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import "@xterm/xterm/css/xterm.css"; import { bridge, type ConnectionClient } from "../api"; @@ -77,6 +79,8 @@ import { terminalComposerDraftKey, terminalComposerDraftPaneIds, terminalComposerRequest, + readTerminalComposerDraft, + subscribeTerminalComposerDraft, } from "../terminalComposer"; import { registerTerminalConnectionDisposer, @@ -512,6 +516,24 @@ export function TerminalView({ const activePaneId = selectedPaneInLayout ?? s.layout?.focused_pane_id ?? null; const isActivePane = !!pane && (!paneId || pane.pane_id === activePaneId); + // The phone input bar names an unsent Composer draft. + const inputBarDraftKey = pane + ? terminalComposerDraftKey( + s.activeConnectionId, + s.connectionGeneration, + pane.pane_id, + ) + : null; + const composerHasDraft = useSyncExternalStore( + (listener) => + inputBarDraftKey + ? subscribeTerminalComposerDraft(inputBarDraftKey, listener) + : () => {}, + () => + inputBarDraftKey + ? readTerminalComposerDraft(inputBarDraftKey) !== "" + : false, + ); const canShowAgentHistory = isActivePane && paneHasAgentHistory(pane); const canClosePane = !!pane && paneCanClose(s.panes, pane.pane_id); const paneZoomed = @@ -3498,6 +3520,72 @@ export function TerminalView({ if (event.target === event.currentTarget) focusTerminalInput(); }} /> + {isMobileLayout() && !composerOpen && isActivePane ? ( + // On phones, a messaging-style input bar in flow below the + // output. A touch selection hides it but keeps its height, so + // the terminal does not resize and drop the selection. +
0 || undefined} + > + + +
+ ) : null} {!isMobileLayout() && touchHandles.length === 0 && isActivePane ? (