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 ? (