Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 1 addition & 53 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createPortal, flushSync } from "react-dom";
import { createPortal } from "react-dom";
import { bridge } from "./api";
import {
listenForTaskNotificationActivation,
Expand Down Expand Up @@ -54,7 +54,6 @@ import {
Minimize2,
MoreHorizontal,
PanelTop,
SquarePen,
SquareStack,
SquareTerminal,
X,
Expand Down Expand Up @@ -169,8 +168,6 @@ import { copyTextFromUserGesture } from "./terminalClipboard";
import { terminalPasteRequest } from "./terminalPaste";
import {
activateTerminalComposerDraftScope,
readTerminalComposerDraft,
subscribeTerminalComposerDraft,
terminalComposerDraftKey,
} from "./terminalComposer";
import { terminalMountKey } from "./terminalConnection";
Expand Down Expand Up @@ -1405,8 +1402,6 @@ export default function App() {
]);
const [openTerminalComposerScopeKey, setOpenTerminalComposerScopeKey] =
useState<string | null>(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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -4076,41 +4059,6 @@ export default function App() {
</div>
</div>

{activeTerminalComposerDraftKey && !terminalComposerOpen ? (
<button
type="button"
className="mobile-composer-shortcut"
title="Open terminal input"
aria-label={`Open terminal input${terminalComposerHasDraft ? ", unsent draft" : ""}`}
aria-haspopup="dialog"
onPointerDown={blurActiveInput}
onClick={() => {
flushSync(() => {
setMobileTabSheetOpen(false);
activateTerminalSurface();
setTerminalComposerOpen(true);
});
// The tap is the request to type. The dock routes Direct to the
// terminal; in Composer, focus the draft within this gesture so
// phones raise the keyboard in one tap. Later dock focus (sends,
// commands, uploads) stays neutral.
const dock =
document.querySelector<HTMLElement>(".terminal-composer");
dock?.focus({ preventScroll: true });
if (dock?.dataset.inputMode === "composer")
dock
.querySelector<HTMLElement>(".terminal-composer-input")
?.focus({ preventScroll: true });
}}
>
<SquarePen size={20} />
{terminalComposerHasDraft ? (
<span className="mobile-composer-draft-dot" aria-hidden="true" />
) : null}
<span className="mobile-nav-label">Type</span>
</button>
) : null}

{s.updateInfo?.update_available || s.notice
? createPortal(
<div className="toast-viewport" aria-live="polite">
Expand Down
62 changes: 62 additions & 0 deletions web/src/components/TerminalView.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 30 additions & 3 deletions web/src/components/TerminalView.inputMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
90 changes: 89 additions & 1 deletion web/src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createPortal } from "react-dom";
import { createPortal, flushSync } from "react-dom";
import {
createReviewAnnotation,
MAX_QUOTE_LENGTH,
Expand Down Expand Up @@ -34,6 +34,7 @@ import { installTerminalCompositionRepair } from "../terminalComposition";
import {
Columns2,
Keyboard,
SquareTerminal,
Maximize2,
Minimize2,
Rows2,
Expand All @@ -47,6 +48,7 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import "@xterm/xterm/css/xterm.css";
import { bridge, type ConnectionClient } from "../api";
Expand Down Expand Up @@ -77,6 +79,8 @@ import {
terminalComposerDraftKey,
terminalComposerDraftPaneIds,
terminalComposerRequest,
readTerminalComposerDraft,
subscribeTerminalComposerDraft,
} from "../terminalComposer";
import {
registerTerminalConnectionDisposer,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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.
<div
className="terminal-mobile-input-bar"
aria-label="Terminal input"
data-reserved={touchHandles.length > 0 || undefined}
>
<button
type="button"
className="terminal-mobile-message"
aria-label={`Open terminal input${composerHasDraft ? ", unsent draft" : ""}`}
title="Open terminal input"
aria-haspopup="dialog"
disabled={
s.status !== "connected" ||
s.connectionPaused ||
!!terminalAttachError
}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
rememberTerminalInputMode("composer");
flushSync(() => setComposerOpen(true));
// The tap is the request to type: focus the draft within
// this gesture so phones raise the keyboard in one tap.
container
?.closest(".terminal-shell")
?.querySelector<HTMLElement>(".terminal-composer-input")
?.focus({ preventScroll: true });
}}
>
<span className="terminal-mobile-message-label">
{composerHasDraft
? "Continue draft…"
: pane.agent?.trim()
? `Message ${pane.agent.trim()}…`
: "Type into the terminal…"}
</span>
</button>
<button
type="button"
className="terminal-mobile-direct"
aria-label="Type directly into the terminal"
title="Type directly into the terminal"
disabled={
directDisabled ||
s.status !== "connected" ||
s.connectionPaused ||
!!terminalAttachError
}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
rememberTerminalInputMode("direct");
flushSync(() => setComposerOpen(true));
// The dock's focus handler hands the keyboard to Direct.
container
?.closest(".terminal-shell")
?.querySelector<HTMLElement>(".terminal-composer")
?.focus({ preventScroll: true });
}}
>
<SquareTerminal size={19} aria-hidden="true" />
</button>
</div>
) : null}
{!isMobileLayout() && touchHandles.length === 0 && isActivePane ? (
<div
className="terminal-mobile-input-actions"
Expand Down
Loading
Loading