Skip to content
Merged
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
186 changes: 186 additions & 0 deletions packages/web-core/src/shared/components/TerminalMobileControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
import type { Terminal } from '@xterm/xterm';
import {
CopyIcon,
ClipboardTextIcon,
KeyboardIcon,
CaretLeftIcon,
CaretRightIcon,
} from '@phosphor-icons/react';

import { cn } from '@/shared/lib/utils';
import { useIsTouchDevice } from '@/shared/hooks/useIsMobile';
import { extractViewportText } from '@/shared/lib/terminalViewportText';

interface TerminalMobileControlsProps {
/** Live terminal accessor — refs don't trigger renders, so read on demand. */
getTerminal: () => Terminal | null;
}

const STATUS_MS = 1600;

const BUTTON_CLASS =
'flex items-center justify-center size-11 rounded-md bg-secondary border ' +
'text-low hover:text-normal active:bg-primary transition-colors';

/**
* Touch-only Copy / Paste / Keyboard affordances for the terminal. Desktop keeps
* its mouse/keyboard flow (drag-select, right-click, Ctrl/Cmd+V) untouched — this
* renders nothing unless the device is touch-capable.
*
* Mounted as a sibling of (NOT inside) the xterm element so taps never reach
* xterm's focus/selection handling. Collapsible and pinned top-right so it can't
* cover claude's bottom input. Every action gives explicit feedback (mobile
* clipboard calls fail silently otherwise).
*/
export function TerminalMobileControls({
getTerminal,
}: TerminalMobileControlsProps) {
const isTouch = useIsTouchDevice();
const [expanded, setExpanded] = useState(true);
const [status, setStatus] = useState<string | null>(null);
const statusTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(
() => () => {
if (statusTimer.current) clearTimeout(statusTimer.current);
},
[]
);

if (!isTouch) return null;

const flash = (msg: string) => {
setStatus(msg);
if (statusTimer.current) clearTimeout(statusTimer.current);
statusTimer.current = setTimeout(() => setStatus(null), STATUS_MS);
};

const handleKeyboard = () => {
getTerminal()?.focus();
};

const handlePaste = async () => {
const term = getTerminal();
if (!term) return;
// Insecure contexts / some WebViews have no Clipboard API at all — optional
// chaining would otherwise resolve to undefined and look like "empty".
if (!navigator.clipboard?.readText) {
flash('Paste unavailable');
return;
}
try {
const text = await navigator.clipboard.readText();
if (!text) {
flash('Clipboard empty');
return;
}
term.paste(text);
flash('Pasted');
} catch {
flash('Paste blocked');
}
};

const handleCopy = async () => {
const term = getTerminal();
if (!term) return;
// Guard the write API up front so we never flash "Copied" without copying.
if (!navigator.clipboard?.writeText) {
flash('Copy unavailable');
return;
}
let text: string;
let label: string;
if (term.hasSelection()) {
text = term.getSelection();
label = 'Copied selection';
} else {
const buf = term.buffer.active;
text = extractViewportText(buf, buf.viewportY, term.rows);
label = 'Copied screen';
}
if (!text) {
flash('Nothing to copy');
return;
}
try {
await navigator.clipboard.writeText(text);
flash(label);
} catch {
flash('Copy blocked');
}
};

const actions = [
{ label: 'Copy from terminal', Icon: CopyIcon, onClick: handleCopy },
{
label: 'Paste into terminal',
Icon: ClipboardTextIcon,
onClick: handlePaste,
},
{ label: 'Show keyboard', Icon: KeyboardIcon, onClick: handleKeyboard },
];

// Belt-and-suspenders: keep taps on the controls from reaching the terminal.
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();

return (
<div
className="absolute top-1 right-1 z-10 flex items-center gap-1"
style={{
paddingTop: 'env(safe-area-inset-top, 0px)',
paddingRight: 'env(safe-area-inset-right, 0px)',
}}
onPointerDown={stop}
onTouchStart={stop}
onMouseDown={stop}
onContextMenu={stop}
>
{status && (
<span
role="status"
aria-live="polite"
className="rounded bg-secondary border px-2 py-1 text-xs text-normal"
>
{status}
</span>
)}
{expanded &&
actions.map(({ label, Icon, onClick }) => (
<button
key={label}
type="button"
className={BUTTON_CLASS}
aria-label={label}
onClick={onClick}
>
<Icon className="size-icon-sm" weight="bold" aria-hidden="true" />
</button>
))}
<button
type="button"
className={cn(BUTTON_CLASS, 'opacity-80')}
aria-label={
expanded ? 'Hide terminal controls' : 'Show terminal controls'
}
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<CaretRightIcon
className="size-icon-sm"
weight="bold"
aria-hidden="true"
/>
) : (
<CaretLeftIcon
className="size-icon-sm"
weight="bold"
aria-hidden="true"
/>
)}
</button>
</div>
);
}
13 changes: 12 additions & 1 deletion packages/web-core/src/shared/components/XTermInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
getTerminalTheme,
} from '@/shared/lib/terminalTheme';
import { buildTerminalWsUrl } from '@/shared/lib/terminalWsUrl';
import { installTerminalTouchScroll } from '@/shared/lib/terminalTouchScroll';
import { useTerminal } from '@/shared/hooks/useTerminal';
import { TerminalMobileControls } from './TerminalMobileControls';

interface XTermInstanceProps {
tabId: string;
Expand Down Expand Up @@ -188,6 +190,14 @@ export function XTermInstance({
});
}
});

// Mobile/touch: bridge vertical swipes to the SAME wheel events xterm
// already handles for a desktop mouse wheel (see terminalTouchScroll).
// Attached once on the freshly created element and intentionally NOT
// removed in the mount cleanup below — like the listeners above it lives
// with the element and tears down on terminal.dispose(). Removing it per
// unmount would leave reattached terminals without mobile scrolling.
installTerminalTouchScroll(terminal);
}

terminalRef.current = terminal;
Expand Down Expand Up @@ -258,10 +268,11 @@ export function XTermInstance({
// theme.
<div
ref={resizeRef}
className="w-full h-full px-2 py-1"
className="relative w-full h-full px-2 py-1 overscroll-contain"
style={{ background: TERMINAL_BACKGROUND }}
>
<div ref={containerRef} className="w-full h-full" />
<TerminalMobileControls getTerminal={() => terminalRef.current} />
</div>
);
}
29 changes: 29 additions & 0 deletions packages/web-core/src/shared/hooks/useIsMobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,32 @@ export function useIsRealMobile(): boolean {
const [isReal] = useState(() => isRealMobileDevice());
return isReal;
}

/**
* Capability-based touch detection (coarse pointer OR touch points).
*
* Distinct from the viewport (`useIsMobile`) and user-agent (`useIsRealMobile`)
* detectors above: this answers "can this device touch?", which also covers
* iPadOS with a desktop UA and hybrid touch laptops. Capability doesn't change
* within a session, so it is computed once.
*/
export function isTouchDevice(): boolean {
if (typeof window === 'undefined') return false;
const coarsePointer =
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches;
return coarsePointer || navigator.maxTouchPoints > 0;
}

// No-op subscribe: touch capability doesn't change within a session, so the
// value is read once and the store is never re-subscribed.
const subscribeTouchDevice = () => () => {};

/**
* React hook version of isTouchDevice. Reads via useSyncExternalStore with a
* `false` server snapshot so SSR/hydration stays stable, then reflects the real
* capability after mount — mirroring how useIsMobile reads a browser-only value.
*/
export function useIsTouchDevice(): boolean {
return useSyncExternalStore(subscribeTouchDevice, isTouchDevice, () => false);
}
Loading
Loading