Compare commits

..

18 Commits

Author SHA1 Message Date
Simos Mikelatos
14e16dac58 fix: hide voice options until enabled 2026-06-26 14:06:17 +00:00
Haile
a1c48e5b1d Merge branch 'main' into fix/voice-tts-format-settings 2026-06-25 17:13:12 +03:00
Haileyesus
0e6373305b fix(voice): separate client and server backends
User-selected backend URLs must remain usable without letting clients control server requests.

Call custom providers from the browser while keeping the server proxy bound to its configured host.

This restores voice controls for frontend settings without reopening the SSRF path.
2026-06-25 17:10:42 +03:00
Haileyesus
43c0cca96e fix(voice): validate config and request boundaries
Malformed stored settings could break voice requests instead of using safe defaults.

Health results could outlive auth changes. URL checks also did not guard the fetch sink.

Remove constant recorder branches so lifecycle cancellation stays clear.
2026-06-25 16:52:54 +03:00
Haileyesus
af16d8ebdc fix(voice): harden recording and backend behavior
Redirects could bypass the backend URL guard, and TTS playback waited for full buffering.

Recording could overlap or finish after teardown. Controls also ignored backend readiness.

Explicit formats and config-aware cache keys prevent stale audio after settings change.
2026-06-25 16:35:30 +03:00
Haile
b0a49120cc Merge branch 'main' into fix/voice-tts-format-settings 2026-06-25 15:56:07 +03:00
Haileyesus
8cbfac6ab1 fix(voice): expose TTS format in user settings 2026-06-24 10:05:35 +03:00
newsbubbles
9919851be7 Merge upstream/main into feat/voice (resolve voice vs browser-use + websocket-unify) 2026-06-20 16:17:24 +01:00
Haile
66b0766013 Merge branch 'main' into feat/voice 2026-06-15 15:27:18 +03:00
newsbubbles
95a75aac47 fix(voice): make stop() idempotent so a double tap can't throw
guard on the recorder's own state instead of react state, so a double tap or
the mic and send buttons both firing won't call stop() on an already-inactive
MediaRecorder.
2026-06-13 13:34:18 +01:00
newsbubbles
952ddb9eb7 feat(voice): send transcript with the main send button while recording
while dictating, the main send button stops recording, transcribes, and sends
in one tap, matching the codex-style flow. the mic button still stops and drops
the transcript into the input box to edit before sending. voice recording state
is lifted into the composer so both buttons share it, and the send button is
enabled (not grayed) while recording. also fix a pre-existing type error: the
quick-settings preferences map was missing voiceEnabled.
2026-06-13 13:09:14 +01:00
newsbubbles
7f8ae7023d fix(voice): harden timeout parsing, tts input check, and player abort
- fall back to the default when VOICE_TIMEOUT_MS is non-numeric or <= 0, so a
  bad override can't make the abort fire immediately
- type-check the tts `text` before calling .trim() so a non-string body returns
  400 instead of throwing
- abort the in-flight TTS fetch on stop() and on a superseding play, so tapping
  read-aloud repeatedly doesn't leave orphaned requests generating audio
2026-06-13 12:09:51 +01:00
newsbubbles
1203760ba8 docs(voice): provider-agnostic wording and jsdoc on proxy functions
drop leftover sidecar/faster-whisper references now that the backend is any
openai-compatible voice api, and add jsdoc to the voice-proxy functions so the
docstring coverage check passes.
2026-06-13 11:55:46 +01:00
newsbubbles
f285715e31 fix(voice): address review (SSRF guard, auth mapping, client timeout)
Validates the user-supplied backend URL (http/https only, blocks the link-local
metadata range) to prevent SSRF; remaps upstream 401/403 so a bad voice API key
isn't read as the app's own auth failing; adds a client-side AbortController timeout
on the read-aloud request so the button can't sit in loading if a request stalls.
2026-06-10 17:56:02 +01:00
newsbubbles
cb3ad16139 fix(voice): play read-aloud through an app-level player to stop cutoffs
Read-aloud now runs in a single module-level player outside the React tree instead
of per-message component state. Switching chats or re-rendering a message no longer
revokes the blob URL mid-play (the 'Invalid URI' cutoff). Adds content-keyed caching so
re-listening doesn't regenerate, and reuses one audio element (also unlocks iOS once).
2026-06-09 15:19:36 +01:00
newsbubbles
32a6405537 fix(voice): relax backend timeout and surface timeout errors
Bumps the proxy timeout to 5 minutes (VOICE_TIMEOUT_MS) since local TTS can
synthesize long messages at roughly real-time, and returns a clear timed-out
message (504) instead of failing silently. The read-aloud button now shows
backend errors.
2026-06-09 12:13:06 +01:00
newsbubbles
711936d279 refactor(voice): provider-agnostic backend and in-app config
Switches the voice proxy to the OpenAI audio API (/v1/audio/transcriptions and
/v1/audio/speech) so it works with OpenAI, Groq, or a local server. Adds a
Settings -> Voice tab (base URL, API key, models, voice) plus a Quick Settings
toggle, and removes the bundled Python sidecar.

Review fixes: stop mic tracks on unmount, clear the global TTS stop handler and
revoke leaked blob URLs, add fetch timeouts in the proxy, surface mic errors in
the button, trim before appending transcripts, and drop the repo-wide wav ignore.
2026-06-09 10:05:06 +01:00
newsbubbles
d05585e1f4 feat(voice): add optional speech-to-text input and read-aloud TTS
Adds a push-to-talk mic button in the composer and a read-aloud button on
assistant messages. Both are opt-in and hidden unless a voice backend is
configured via VOICE_SIDECAR_URL.

The auth-gated /api/voice proxy forwards to a configurable backend exposing
/transcribe and /tts (provider-agnostic); the frontend probes /api/voice/health
and hides the controls when disabled. Adds i18n keys and docs/voice.md.

Includes a local, no-API-key reference backend in voice-sidecar/ (faster-whisper
for STT, Kokoro-82M for TTS, both CPU-capable).
2026-06-08 00:48:24 +01:00
10 changed files with 230 additions and 978 deletions

View File

@@ -1,5 +1,6 @@
import type { ITerminalOptions } from '@xterm/xterm'; import type { ITerminalOptions } from '@xterm/xterm';
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
export const SHELL_RESTART_DELAY_MS = 200; export const SHELL_RESTART_DELAY_MS = 200;
export const TERMINAL_INIT_DELAY_MS = 100; export const TERMINAL_INIT_DELAY_MS = 100;
export const TERMINAL_RESIZE_DELAY_MS = 50; export const TERMINAL_RESIZE_DELAY_MS = 50;

View File

@@ -24,6 +24,7 @@ type UseShellConnectionOptions = {
autoConnect: boolean; autoConnect: boolean;
closeSocket: () => void; closeSocket: () => void;
clearTerminalScreen: () => void; clearTerminalScreen: () => void;
setAuthUrl: (nextAuthUrl: string) => void;
onOutputRef?: MutableRefObject<(() => void) | null>; onOutputRef?: MutableRefObject<(() => void) | null>;
}; };
@@ -48,6 +49,7 @@ export function useShellConnection({
autoConnect, autoConnect,
closeSocket, closeSocket,
clearTerminalScreen, clearTerminalScreen,
setAuthUrl,
onOutputRef, onOutputRef,
}: UseShellConnectionOptions): UseShellConnectionResult { }: UseShellConnectionOptions): UseShellConnectionResult {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@@ -98,8 +100,14 @@ export function useShellConnection({
return; return;
} }
if (message.type === 'auth_url' || message.type === 'url_open') {
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
if (nextAuthUrl) {
setAuthUrl(nextAuthUrl);
}
}
}, },
[handleProcessCompletion, onOutputRef, terminalRef], [handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
); );
const connectWebSocket = useCallback( const connectWebSocket = useCallback(
@@ -125,6 +133,7 @@ export function useShellConnection({
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
setAuthUrl('');
window.setTimeout(() => { window.setTimeout(() => {
const currentTerminal = terminalRef.current; const currentTerminal = terminalRef.current;
@@ -187,6 +196,7 @@ export function useShellConnection({
isPlainShellRef, isPlainShellRef,
selectedProjectRef, selectedProjectRef,
selectedSessionRef, selectedSessionRef,
setAuthUrl,
terminalRef, terminalRef,
wsRef, wsRef,
], ],
@@ -215,7 +225,8 @@ export function useShellConnection({
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false; forceRestartOnInitRef.current = false;
}, [clearTerminalScreen, closeSocket]); setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => { useEffect(() => {
if ( if (

View File

@@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types'; import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { useShellConnection } from './useShellConnection'; import { useShellConnection } from './useShellConnection';
import { useShellTerminal } from './useShellTerminal'; import { useShellTerminal } from './useShellTerminal';
@@ -23,11 +22,15 @@ export function useShellRuntime({
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [authUrl, setAuthUrl] = useState('');
const [authUrlVersion, setAuthUrlVersion] = useState(0);
const selectedProjectRef = useRef(selectedProject); const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession); const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand); const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell); const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete); const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null); const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
// Keep mutable values in refs so websocket handlers always read current data. // Keep mutable values in refs so websocket handlers always read current data.
@@ -39,6 +42,12 @@ export function useShellRuntime({
onProcessCompleteRef.current = onProcessComplete; onProcessCompleteRef.current = onProcessComplete;
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]); }, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
authUrlRef.current = nextAuthUrl;
setAuthUrl(nextAuthUrl);
setAuthUrlVersion((previous) => previous + 1);
}, []);
const closeSocket = useCallback(() => { const closeSocket = useCallback(() => {
const activeSocket = wsRef.current; const activeSocket = wsRef.current;
if (!activeSocket) { if (!activeSocket) {
@@ -55,6 +64,32 @@ export function useShellRuntime({
wsRef.current = null; wsRef.current = null;
}, []); }, []);
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) {
return false;
}
const popup = window.open(url, '_blank');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener.
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) {
return false;
}
return copyTextToClipboard(url);
}, []);
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({ const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,
@@ -63,6 +98,10 @@ export function useShellRuntime({
selectedProject, selectedProject,
minimal, minimal,
isRestarting, isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket, closeSocket,
}); });
@@ -79,6 +118,7 @@ export function useShellRuntime({
autoConnect, autoConnect,
closeSocket, closeSocket,
clearTerminalScreen, clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
onOutputRef, onOutputRef,
}); });
@@ -116,7 +156,11 @@ export function useShellRuntime({
isConnected, isConnected,
isInitialized, isInitialized,
isConnecting, isConnecting,
authUrl,
authUrlVersion,
connectToShell, connectToShell,
disconnectFromShell, disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}; };
} }

View File

@@ -4,18 +4,15 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl'; import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { import {
CODEX_DEVICE_AUTH_URL,
TERMINAL_INIT_DELAY_MS, TERMINAL_INIT_DELAY_MS,
TERMINAL_OPTIONS, TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS, TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants'; } from '../constants/constants';
import { import { copyTextToClipboard } from '../../../utils/clipboard';
installMobileTerminalSelection, import { isCodexLoginCommand } from '../utils/auth';
type MobileTerminalSelectionManager,
} from '../utils/mobileTerminalSelection';
import { sendSocketMessage } from '../utils/socket'; import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles'; import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -27,6 +24,10 @@ type UseShellTerminalOptions = {
selectedProject: Project | null | undefined; selectedProject: Project | null | undefined;
minimal: boolean; minimal: boolean;
isRestarting: boolean; isRestarting: boolean;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
authUrlRef: MutableRefObject<string>;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
closeSocket: () => void; closeSocket: () => void;
}; };
@@ -44,11 +45,14 @@ export function useShellTerminal({
selectedProject, selectedProject,
minimal, minimal,
isRestarting, isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket, closeSocket,
}: UseShellTerminalOptions): UseShellTerminalResult { }: UseShellTerminalOptions): UseShellTerminalResult {
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const resizeTimeoutRef = useRef<number | null>(null); const resizeTimeoutRef = useRef<number | null>(null);
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || ''; const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
const hasSelectedProject = Boolean(selectedProject); const hasSelectedProject = Boolean(selectedProject);
@@ -66,11 +70,6 @@ export function useShellTerminal({
}, [terminalRef]); }, [terminalRef]);
const disposeTerminal = useCallback(() => { const disposeTerminal = useCallback(() => {
if (mobileSelectionRef.current) {
mobileSelectionRef.current.dispose();
mobileSelectionRef.current = null;
}
if (terminalRef.current) { if (terminalRef.current) {
terminalRef.current.dispose(); terminalRef.current.dispose();
terminalRef.current = null; terminalRef.current = null;
@@ -81,8 +80,7 @@ export function useShellTerminal({
}, [fitAddonRef, terminalRef]); }, [fitAddonRef, terminalRef]);
useEffect(() => { useEffect(() => {
const terminalContainer = terminalContainerRef.current; if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
return; return;
} }
@@ -104,28 +102,7 @@ export function useShellTerminal({
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
} }
nextTerminal.open(terminalContainer); nextTerminal.open(terminalContainerRef.current);
mobileSelectionRef.current = installMobileTerminalSelection(
nextTerminal,
terminalContainer,
{
onFontSizeChange: (fontSize) => {
nextTerminal.options.fontSize = fontSize;
const currentFitAddon = fitAddonRef.current;
if (currentFitAddon) {
currentFitAddon.fit();
sendSocketMessage(wsRef.current, {
type: 'resize',
cols: nextTerminal.cols,
rows: nextTerminal.rows,
});
} else {
nextTerminal.refresh(0, nextTerminal.rows - 1);
}
},
},
);
const copyTerminalSelection = async () => { const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection(); const selection = nextTerminal.getSelection();
@@ -156,9 +133,29 @@ export function useShellTerminal({
void copyTextToClipboard(selection); void copyTextToClipboard(selection);
}; };
terminalContainer.addEventListener('copy', handleTerminalCopy); terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => { nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
event.preventDefault();
event.stopPropagation();
void copyAuthUrlToClipboard(activeAuthUrl);
return false;
}
if ( if (
event.type === 'keydown' && event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) && (event.ctrlKey || event.metaKey) &&
@@ -243,10 +240,10 @@ export function useShellTerminal({
}, TERMINAL_RESIZE_DELAY_MS); }, TERMINAL_RESIZE_DELAY_MS);
}); });
resizeObserver.observe(terminalContainer); resizeObserver.observe(terminalContainerRef.current);
return () => { return () => {
terminalContainer.removeEventListener('copy', handleTerminalCopy); terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect(); resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) { if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current); window.clearTimeout(resizeTimeoutRef.current);
@@ -257,12 +254,16 @@ export function useShellTerminal({
disposeTerminal(); disposeTerminal();
}; };
}, [ }, [
authUrlRef,
closeSocket, closeSocket,
copyAuthUrlToClipboard,
disposeTerminal, disposeTerminal,
fitAddonRef, fitAddonRef,
initialCommandRef,
isPlainShellRef,
isRestarting, isRestarting,
hasSelectedProject,
minimal, minimal,
hasSelectedProject,
selectedProjectKey, selectedProjectKey,
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,

View File

@@ -4,6 +4,8 @@ import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
export type ShellInitMessage = { export type ShellInitMessage = {
type: 'init'; type: 'init';
projectPath: string; projectPath: string;
@@ -52,6 +54,7 @@ export type ShellSharedRefs = {
wsRef: MutableRefObject<WebSocket | null>; wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>; terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>; fitAddonRef: MutableRefObject<FitAddon | null>;
authUrlRef: MutableRefObject<string>;
selectedProjectRef: MutableRefObject<Project | null | undefined>; selectedProjectRef: MutableRefObject<Project | null | undefined>;
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>; selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
initialCommandRef: MutableRefObject<string | null | undefined>; initialCommandRef: MutableRefObject<string | null | undefined>;
@@ -66,6 +69,10 @@ export type UseShellRuntimeResult = {
isConnected: boolean; isConnected: boolean;
isInitialized: boolean; isInitialized: boolean;
isConnecting: boolean; isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void; connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
}; };

View File

@@ -1,4 +1,17 @@
import type { ProjectSession } from '../../../types/app'; import type { ProjectSession } from '../../../types/app';
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
export function isCodexLoginCommand(command: string | null | undefined): boolean {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
if (isCodexLoginCommand(command)) {
return CODEX_DEVICE_AUTH_URL;
}
return authUrl;
}
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null { export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
if (!session) { if (!session) {
@@ -8,4 +21,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
return session.__provider === 'cursor' return session.__provider === 'cursor'
? session.name || 'Untitled Session' ? session.name || 'Untitled Session'
: session.summary || 'New Session'; : session.summary || 'New Session';
} }

View File

@@ -1,925 +0,0 @@
import type { IDisposable, Terminal } from '@xterm/xterm';
import { copyTextToClipboard } from '../../../utils/clipboard';
type TerminalCoords = {
col: number;
row: number;
};
type TouchCoords = {
clientX: number;
clientY: number;
};
type CellDimensions = {
width: number;
height: number;
};
type DragHandle = 'start' | 'end';
type TerminalWithRenderService = Terminal & {
_core?: {
_renderService?: {
dimensions?: {
css?: {
cell?: {
width?: number;
height?: number;
};
};
};
};
};
};
export type MobileTerminalSelectionManager = {
dispose: () => void;
updateHandles: () => void;
};
const LONG_PRESS_MS = 600;
const MOVE_THRESHOLD_PX = 8;
const HANDLE_SIZE_PX = 22;
const FINGER_OFFSET_PX = 40;
const CONTEXT_MENU_GAP_PX = 12;
const CONTEXT_MENU_EDGE_PADDING_PX = 8;
const ZOOM_THROTTLE_MS = 50;
const DEFAULT_MIN_FONT_SIZE = 8;
const DEFAULT_MAX_FONT_SIZE = 48;
type ContextMenuItem = {
label: string;
action: () => void;
};
export type MobileTerminalSelectionOptions = {
minFontSize?: number;
maxFontSize?: number;
onFontSizeChange?: (fontSize: number) => void;
};
function isTouchSelectionEnvironment(): boolean {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return false;
}
return (
navigator.maxTouchPoints > 0 ||
'ontouchstart' in window ||
window.matchMedia?.('(pointer: coarse)').matches === true
);
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function getDistance(start: TouchCoords, end: TouchCoords): number {
return Math.hypot(end.clientX - start.clientX, end.clientY - start.clientY);
}
class ShellMobileSelectionCore implements MobileTerminalSelectionManager {
private readonly terminal: Terminal;
private readonly terminalContent: HTMLElement;
private readonly overlay: HTMLDivElement;
private readonly startHandle: HTMLDivElement;
private readonly endHandle: HTMLDivElement;
private readonly contextMenu: HTMLDivElement;
private readonly disposables: IDisposable[] = [];
private readonly originalPosition: string;
private didSetPosition = false;
private isDestroyed = false;
private isSelecting = false;
private isHandleDragging = false;
private dragHandle: DragHandle | null = null;
private selectionStart: TerminalCoords | null = null;
private selectionEnd: TerminalCoords | null = null;
private touchStart: TouchCoords | null = null;
private pendingClearTouch: { point: TouchCoords; moved: boolean } | null = null;
private tapHoldTimeout: number | null = null;
private cellDimensions: CellDimensions = { width: 0, height: 0 };
private isContextMenuVisible = false;
private readonly minFontSize: number;
private readonly maxFontSize: number;
private readonly onFontSizeChange: (fontSize: number) => void;
private isPinching = false;
private pinchStartDistance = 0;
private initialFontSize = 0;
private lastZoomTime = 0;
constructor(
terminal: Terminal,
terminalContent: HTMLElement,
options: MobileTerminalSelectionOptions = {},
) {
this.terminal = terminal;
this.terminalContent = terminalContent;
this.originalPosition = terminalContent.style.position;
const minFontSize = Number(options.minFontSize) || DEFAULT_MIN_FONT_SIZE;
const maxFontSize = Number(options.maxFontSize) || DEFAULT_MAX_FONT_SIZE;
this.minFontSize = Math.min(minFontSize, maxFontSize);
this.maxFontSize = Math.max(minFontSize, maxFontSize);
this.onFontSizeChange =
options.onFontSizeChange ??
((fontSize) => {
this.terminal.options.fontSize = fontSize;
this.terminal.refresh(0, this.terminal.rows - 1);
});
if (window.getComputedStyle(terminalContent).position === 'static') {
terminalContent.style.position = 'relative';
this.didSetPosition = true;
}
this.overlay = this.createSelectionOverlay();
this.startHandle = this.createHandle('start');
this.endHandle = this.createHandle('end');
this.contextMenu = this.createContextMenu();
this.overlay.append(this.startHandle, this.endHandle, this.contextMenu);
this.terminalContent.appendChild(this.overlay);
this.attachEventListeners();
this.updateCellDimensions();
}
private createSelectionOverlay(): HTMLDivElement {
const overlay = document.createElement('div');
overlay.className = 'shell-mobile-selection-overlay';
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.overflow = 'hidden';
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '30';
return overlay;
}
private createHandle(type: DragHandle): HTMLDivElement {
const handle = document.createElement('div');
handle.className = `shell-mobile-selection-handle shell-mobile-selection-handle-${type}`;
handle.dataset.handleType = type;
handle.style.position = 'absolute';
handle.style.width = `${HANDLE_SIZE_PX}px`;
handle.style.height = `${HANDLE_SIZE_PX}px`;
handle.style.borderRadius = '50%';
handle.style.background = '#3b82f6';
handle.style.border = '2px solid #fff';
handle.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
handle.style.display = 'none';
handle.style.pointerEvents = 'auto';
handle.style.touchAction = 'none';
handle.style.zIndex = '31';
return handle;
}
private createContextMenu(): HTMLDivElement {
const menu = document.createElement('div');
menu.className = 'shell-mobile-selection-menu';
menu.style.position = 'absolute';
menu.style.display = 'none';
menu.style.alignItems = 'stretch';
menu.style.padding = '4px';
menu.style.gap = '2px';
menu.style.background = '#1f2937';
menu.style.border = '1px solid rgba(255,255,255,0.12)';
menu.style.borderRadius = '10px';
menu.style.boxShadow = '0 6px 20px rgba(0,0,0,0.4)';
menu.style.pointerEvents = 'auto';
menu.style.touchAction = 'none';
menu.style.zIndex = '32';
menu.style.whiteSpace = 'nowrap';
menu.style.userSelect = 'none';
const items: ContextMenuItem[] = [
{ label: 'Copy', action: () => this.copySelection() },
{ label: 'Select All', action: () => this.selectAllText() },
];
for (const item of items) {
menu.appendChild(this.createContextMenuButton(item));
}
return menu;
}
private createContextMenuButton(item: ContextMenuItem): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.textContent = item.label;
button.style.appearance = 'none';
button.style.border = 'none';
button.style.margin = '0';
button.style.padding = '8px 14px';
button.style.background = 'transparent';
button.style.color = '#f9fafb';
button.style.fontSize = '14px';
button.style.fontFamily = 'inherit';
button.style.lineHeight = '1';
button.style.borderRadius = '6px';
button.style.cursor = 'pointer';
button.style.pointerEvents = 'auto';
button.style.touchAction = 'none';
let actionExecuted = false;
const arm = (event: Event): void => {
event.preventDefault();
event.stopPropagation();
actionExecuted = false;
};
const run = (event: Event): void => {
event.preventDefault();
event.stopPropagation();
if (actionExecuted) {
return;
}
actionExecuted = true;
item.action();
};
button.addEventListener('touchstart', arm, { passive: false });
button.addEventListener('touchend', run, { passive: false });
button.addEventListener('mousedown', arm);
button.addEventListener('mouseup', run);
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
});
return button;
}
private attachEventListeners(): void {
if (!this.terminal.element) {
return;
}
this.terminal.element.addEventListener('touchstart', this.onTerminalTouchStart, {
passive: false,
});
this.terminal.element.addEventListener('touchmove', this.onTerminalTouchMove, {
passive: false,
});
this.terminal.element.addEventListener('touchend', this.onTerminalTouchEnd, {
passive: false,
});
this.terminal.element.addEventListener('touchcancel', this.onTerminalTouchCancel, {
passive: false,
});
this.startHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false });
this.startHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false });
this.startHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false });
this.startHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false });
this.endHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false });
this.endHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false });
this.endHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false });
this.endHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false });
document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: true });
this.disposables.push(
this.terminal.onSelectionChange(this.onSelectionChange),
this.terminal.onResize(this.onTerminalResize),
this.terminal.onScroll(this.onTerminalScroll),
);
}
private onTerminalTouchStart = (event: TouchEvent): void => {
if (event.touches.length === 2) {
event.preventDefault();
this.startPinchZoom(event);
return;
}
if (event.touches.length !== 1) {
this.clearTapHoldTimeout();
return;
}
const touch = this.toTouchCoords(event.touches[0]);
this.touchStart = touch;
if (this.isSelecting) {
this.pendingClearTouch = { point: touch, moved: false };
return;
}
this.clearTapHoldTimeout();
this.tapHoldTimeout = window.setTimeout(() => {
this.tapHoldTimeout = null;
this.startSelection(touch);
}, LONG_PRESS_MS);
};
private onTerminalTouchMove = (event: TouchEvent): void => {
if (event.touches.length === 2 && this.isPinching) {
event.preventDefault();
this.handlePinchZoom(event);
return;
}
if (event.touches.length !== 1) {
this.clearTapHoldTimeout();
return;
}
if (this.isPinching) {
return;
}
const touch = this.toTouchCoords(event.touches[0]);
const touchStart = this.touchStart;
if (this.pendingClearTouch) {
this.pendingClearTouch.moved =
this.pendingClearTouch.moved ||
getDistance(this.pendingClearTouch.point, touch) > MOVE_THRESHOLD_PX;
return;
}
if (!touchStart) {
return;
}
const moved = getDistance(touchStart, touch) > MOVE_THRESHOLD_PX;
if (moved) {
this.clearTapHoldTimeout();
}
if (this.isSelecting && !this.isHandleDragging) {
event.preventDefault();
this.extendSelection(touch);
}
};
private onTerminalTouchEnd = (): void => {
if (this.isPinching) {
this.endPinchZoom();
return;
}
this.clearTapHoldTimeout();
this.touchStart = null;
if (!this.pendingClearTouch) {
return;
}
const shouldClear = this.isSelecting && !this.pendingClearTouch.moved && !this.isHandleDragging;
this.pendingClearTouch = null;
if (shouldClear) {
this.clearSelection();
}
};
private onTerminalTouchCancel = (): void => {
if (this.isPinching) {
this.endPinchZoom();
}
this.clearTapHoldTimeout();
this.touchStart = null;
this.pendingClearTouch = null;
};
private onHandleTouchStart = (event: TouchEvent): void => {
event.preventDefault();
event.stopPropagation();
if (event.touches.length !== 1) {
return;
}
const target = event.currentTarget as HTMLElement;
this.dragHandle = target.dataset.handleType === 'start' ? 'start' : 'end';
this.isHandleDragging = true;
this.pendingClearTouch = null;
};
private onHandleTouchMove = (event: TouchEvent): void => {
if (!this.isHandleDragging || !this.dragHandle || event.touches.length !== 1) {
return;
}
event.preventDefault();
event.stopPropagation();
const touch = this.toTouchCoords(event.touches[0]);
const adjustedTouch = {
clientX: touch.clientX,
clientY: touch.clientY - FINGER_OFFSET_PX,
};
const coords = this.touchToTerminalCoords(adjustedTouch);
if (!coords) {
return;
}
if (this.dragHandle === 'start') {
this.selectionStart = coords;
} else {
this.selectionEnd = coords;
}
this.swapHandlesIfNeeded();
this.updateSelection();
};
private onHandleTouchEnd = (event: TouchEvent): void => {
if (!this.isHandleDragging) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isHandleDragging = false;
this.dragHandle = null;
};
private onSelectionChange = (): void => {
if (!this.isSelecting) {
return;
}
if (!this.terminal.hasSelection()) {
this.resetSelectionState();
return;
}
this.updateHandles();
};
private onTerminalResize = (): void => {
this.updateCellDimensions();
this.updateHandles();
};
private onTerminalScroll = (): void => {
this.updateHandles();
};
private onDocumentTouchStart = (event: TouchEvent): void => {
if (!this.isSelecting || !event.target) {
return;
}
if (this.terminalContent.contains(event.target as Node)) {
return;
}
this.clearSelection();
};
private startSelection(touch: TouchCoords): void {
const coords = this.touchToTerminalCoords(touch);
if (!coords) {
return;
}
const wordBounds = this.getWordBoundsAt(coords);
this.selectionStart = wordBounds?.start ?? coords;
this.selectionEnd = wordBounds?.end ?? coords;
this.isSelecting = true;
this.updateSelection();
this.showHandles();
this.showContextMenu();
}
private extendSelection(touch: TouchCoords): void {
const coords = this.touchToTerminalCoords(touch);
if (!coords) {
return;
}
this.selectionEnd = coords;
this.updateSelection();
}
private updateSelection(): void {
if (!this.selectionStart || !this.selectionEnd) {
return;
}
const { start, end } = this.getOrderedSelection();
const length = this.calculateSelectionLength(start, end);
if (length <= 0) {
return;
}
this.terminal.select(start.col, start.row, length);
this.updateHandles();
}
private calculateSelectionLength(start: TerminalCoords, end: TerminalCoords): number {
if (start.row === end.row) {
return end.col - start.col + 1;
}
return (end.row - start.row) * this.terminal.cols - start.col + end.col + 1;
}
private getOrderedSelection(): { start: TerminalCoords; end: TerminalCoords } {
const start = this.selectionStart;
const end = this.selectionEnd;
if (!start || !end) {
throw new Error('Cannot order empty terminal selection');
}
if (start.row < end.row || (start.row === end.row && start.col <= end.col)) {
return { start, end };
}
return { start: end, end: start };
}
private swapHandlesIfNeeded(): void {
if (!this.selectionStart || !this.selectionEnd || !this.dragHandle) {
return;
}
const { start, end } = this.getOrderedSelection();
if (start === this.selectionStart && end === this.selectionEnd) {
return;
}
this.selectionStart = start;
this.selectionEnd = end;
this.dragHandle = this.dragHandle === 'start' ? 'end' : 'start';
}
private showHandles(): void {
this.startHandle.style.display = 'block';
this.endHandle.style.display = 'block';
this.updateHandles();
}
private hideHandles(): void {
this.startHandle.style.display = 'none';
this.endHandle.style.display = 'none';
}
private showContextMenu(): void {
this.contextMenu.style.display = 'flex';
this.isContextMenuVisible = true;
this.positionContextMenu();
}
private hideContextMenu(): void {
this.contextMenu.style.display = 'none';
this.isContextMenuVisible = false;
}
private positionContextMenu(): void {
if (!this.isContextMenuVisible) {
return;
}
const containerRect = this.terminalContent.getBoundingClientRect();
const menuWidth = this.contextMenu.offsetWidth || 0;
const menuHeight = this.contextMenu.offsetHeight || 0;
const ordered =
this.selectionStart && this.selectionEnd ? this.getOrderedSelection() : null;
const startPosition = ordered ? this.terminalCoordsToPixels(ordered.start) : null;
const endPosition = ordered ? this.terminalCoordsToPixels(ordered.end) : null;
let menuX: number;
let menuY: number;
if (startPosition || endPosition) {
const topY = Math.min(
startPosition?.y ?? endPosition!.y,
endPosition?.y ?? startPosition!.y,
);
const centerX =
startPosition && endPosition
? (startPosition.x + endPosition.x) / 2
: (startPosition ?? endPosition)!.x;
menuX = centerX - menuWidth / 2;
menuY = topY - menuHeight - CONTEXT_MENU_GAP_PX;
// Not enough room above the selection: drop below the handles instead.
if (menuY < CONTEXT_MENU_EDGE_PADDING_PX) {
const bottomY = Math.max(
startPosition?.y ?? endPosition!.y,
endPosition?.y ?? startPosition!.y,
);
menuY = bottomY + this.cellDimensions.height + HANDLE_SIZE_PX + CONTEXT_MENU_GAP_PX;
}
} else {
// Whole-buffer selection (Select All): pin to the bottom center.
menuX = (containerRect.width - menuWidth) / 2;
menuY = containerRect.height - menuHeight - CONTEXT_MENU_GAP_PX;
}
const maxX = containerRect.width - menuWidth - CONTEXT_MENU_EDGE_PADDING_PX;
const maxY = containerRect.height - menuHeight - CONTEXT_MENU_EDGE_PADDING_PX;
menuX = clamp(menuX, CONTEXT_MENU_EDGE_PADDING_PX, Math.max(CONTEXT_MENU_EDGE_PADDING_PX, maxX));
menuY = clamp(menuY, CONTEXT_MENU_EDGE_PADDING_PX, Math.max(CONTEXT_MENU_EDGE_PADDING_PX, maxY));
this.contextMenu.style.left = `${menuX}px`;
this.contextMenu.style.top = `${menuY}px`;
}
private copySelection(): void {
const selectionText = this.terminal.getSelection();
if (selectionText) {
void copyTextToClipboard(selectionText);
}
this.clearSelection();
}
private selectAllText(): void {
this.terminal.selectAll();
this.selectionStart = null;
this.selectionEnd = null;
this.isSelecting = true;
this.hideHandles();
if (this.terminal.hasSelection()) {
this.showContextMenu();
} else {
this.clearSelection();
}
}
private startPinchZoom(event: TouchEvent): void {
if (event.touches.length !== 2) {
return;
}
this.clearTapHoldTimeout();
if (this.isSelecting) {
this.clearSelection();
}
this.isPinching = true;
this.initialFontSize = this.terminal.options.fontSize ?? DEFAULT_MIN_FONT_SIZE;
this.pinchStartDistance = this.getTouchDistance(event.touches[0], event.touches[1]);
this.lastZoomTime = 0;
}
private handlePinchZoom(event: TouchEvent): void {
if (!this.isPinching || event.touches.length !== 2 || this.pinchStartDistance <= 0) {
return;
}
const now = Date.now();
if (now - this.lastZoomTime < ZOOM_THROTTLE_MS) {
return;
}
this.lastZoomTime = now;
const currentDistance = this.getTouchDistance(event.touches[0], event.touches[1]);
const scale = currentDistance / this.pinchStartDistance;
const nextFontSize = clamp(
Math.round(this.initialFontSize * scale),
this.minFontSize,
this.maxFontSize,
);
if (nextFontSize !== this.terminal.options.fontSize) {
this.onFontSizeChange(nextFontSize);
}
}
private endPinchZoom(): void {
this.isPinching = false;
this.pinchStartDistance = 0;
this.initialFontSize = 0;
}
private getTouchDistance(first: Touch, second: Touch): number {
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
}
updateHandles(): void {
if (this.isContextMenuVisible) {
this.positionContextMenu();
}
if (!this.isSelecting || !this.selectionStart || !this.selectionEnd) {
this.hideHandles();
return;
}
const { start, end } = this.getOrderedSelection();
const startPosition = this.terminalCoordsToPixels(start);
const endPosition = this.terminalCoordsToPixels(end);
if (startPosition) {
this.startHandle.style.display = 'block';
this.startHandle.style.left = `${startPosition.x - HANDLE_SIZE_PX / 2}px`;
this.startHandle.style.top = `${startPosition.y + this.cellDimensions.height + 4}px`;
} else {
this.startHandle.style.display = 'none';
}
if (endPosition) {
this.endHandle.style.display = 'block';
this.endHandle.style.left = `${endPosition.x + this.cellDimensions.width - HANDLE_SIZE_PX / 2}px`;
this.endHandle.style.top = `${endPosition.y + this.cellDimensions.height + 4}px`;
} else {
this.endHandle.style.display = 'none';
}
}
private clearSelection(): void {
this.terminal.clearSelection();
this.resetSelectionState();
}
private resetSelectionState(): void {
this.isSelecting = false;
this.isHandleDragging = false;
this.dragHandle = null;
this.selectionStart = null;
this.selectionEnd = null;
this.pendingClearTouch = null;
this.touchStart = null;
this.hideHandles();
this.hideContextMenu();
this.clearTapHoldTimeout();
}
private touchToTerminalCoords(touch: TouchCoords): TerminalCoords | null {
const screenElement = this.getTerminalScreenElement();
if (!screenElement) {
return null;
}
const rect = screenElement.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
return null;
}
this.updateCellDimensions();
if (!this.cellDimensions.width || !this.cellDimensions.height) {
return null;
}
const col = clamp(Math.floor(x / this.cellDimensions.width), 0, this.terminal.cols - 1);
const row = Math.floor(y / this.cellDimensions.height) + this.terminal.buffer.active.viewportY;
return {
col,
row: Math.max(0, row),
};
}
private terminalCoordsToPixels(coords: TerminalCoords): { x: number; y: number } | null {
const screenElement = this.getTerminalScreenElement();
if (!screenElement) {
return null;
}
this.updateCellDimensions();
const visibleRow = coords.row - this.terminal.buffer.active.viewportY;
if (visibleRow < 0 || visibleRow >= this.terminal.rows) {
return null;
}
const screenRect = screenElement.getBoundingClientRect();
const containerRect = this.terminalContent.getBoundingClientRect();
return {
x: screenRect.left - containerRect.left + coords.col * this.cellDimensions.width,
y: screenRect.top - containerRect.top + visibleRow * this.cellDimensions.height,
};
}
private updateCellDimensions(): void {
const renderCell = (this.terminal as TerminalWithRenderService)._core?._renderService
?.dimensions?.css?.cell;
if (renderCell?.width && renderCell.height) {
this.cellDimensions = {
width: renderCell.width,
height: renderCell.height,
};
return;
}
const screenElement = this.getTerminalScreenElement();
const rect = screenElement?.getBoundingClientRect();
if (!rect || !this.terminal.cols || !this.terminal.rows) {
this.cellDimensions = { width: 0, height: 0 };
return;
}
this.cellDimensions = {
width: rect.width / this.terminal.cols,
height: rect.height / this.terminal.rows,
};
}
private getWordBoundsAt(coords: TerminalCoords): {
start: TerminalCoords;
end: TerminalCoords;
} | null {
const line = this.terminal.buffer.active.getLine(coords.row);
if (!line) {
return null;
}
const lineText = line.translateToString(false);
if (!lineText || coords.col >= lineText.length || /\s/.test(lineText[coords.col])) {
return null;
}
let startCol = coords.col;
let endCol = coords.col;
while (startCol > 0 && !/\s/.test(lineText[startCol - 1])) {
startCol--;
}
while (endCol < lineText.length - 1 && !/\s/.test(lineText[endCol + 1])) {
endCol++;
}
return {
start: { row: coords.row, col: startCol },
end: { row: coords.row, col: endCol },
};
}
private getTerminalScreenElement(): HTMLElement | null {
return (
this.terminal.element?.querySelector<HTMLElement>('.xterm-screen') ??
this.terminal.element ??
null
);
}
private toTouchCoords(touch: Touch): TouchCoords {
return {
clientX: touch.clientX,
clientY: touch.clientY,
};
}
private clearTapHoldTimeout(): void {
if (this.tapHoldTimeout === null) {
return;
}
window.clearTimeout(this.tapHoldTimeout);
this.tapHoldTimeout = null;
}
dispose(): void {
if (this.isDestroyed) {
return;
}
this.isDestroyed = true;
this.clearTapHoldTimeout();
this.terminal.element?.removeEventListener('touchstart', this.onTerminalTouchStart);
this.terminal.element?.removeEventListener('touchmove', this.onTerminalTouchMove);
this.terminal.element?.removeEventListener('touchend', this.onTerminalTouchEnd);
this.terminal.element?.removeEventListener('touchcancel', this.onTerminalTouchCancel);
this.startHandle.removeEventListener('touchstart', this.onHandleTouchStart);
this.startHandle.removeEventListener('touchmove', this.onHandleTouchMove);
this.startHandle.removeEventListener('touchend', this.onHandleTouchEnd);
this.startHandle.removeEventListener('touchcancel', this.onHandleTouchEnd);
this.endHandle.removeEventListener('touchstart', this.onHandleTouchStart);
this.endHandle.removeEventListener('touchmove', this.onHandleTouchMove);
this.endHandle.removeEventListener('touchend', this.onHandleTouchEnd);
this.endHandle.removeEventListener('touchcancel', this.onHandleTouchEnd);
document.removeEventListener('touchstart', this.onDocumentTouchStart);
this.disposables.forEach((disposable) => disposable.dispose());
this.disposables.length = 0;
this.overlay.remove();
if (this.didSetPosition) {
this.terminalContent.style.position = this.originalPosition;
}
}
}
export function installMobileTerminalSelection(
terminal: Terminal,
terminalContent: HTMLElement,
options: MobileTerminalSelectionOptions = {},
): MobileTerminalSelectionManager | null {
if (!isTouchSelectionEnvironment() || !terminal.element) {
return null;
}
return new ShellMobileSelectionCore(terminal, terminalContent, options);
}

View File

@@ -59,8 +59,12 @@ export default function Shell({
isConnected, isConnected,
isInitialized, isInitialized,
isConnecting, isConnecting,
authUrl,
authUrlVersion,
connectToShell, connectToShell,
disconnectFromShell, disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({ } = useShellRuntime({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -239,7 +243,15 @@ export default function Shell({
if (minimal) { if (minimal) {
return ( return (
<> <>
<ShellMinimalView terminalContainerRef={terminalContainerRef} /> <ShellMinimalView
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
<TerminalShortcutsPanel <TerminalShortcutsPanel
wsRef={wsRef} wsRef={wsRef}
terminalRef={terminalRef} terminalRef={terminalRef}

View File

@@ -1,12 +1,45 @@
import { useEffect, useMemo, useState } from 'react';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { AuthCopyStatus } from '../../types/types';
import { resolveAuthUrlForDisplay } from '../../utils/auth';
type ShellMinimalViewProps = { type ShellMinimalViewProps = {
terminalContainerRef: RefObject<HTMLDivElement>; terminalContainerRef: RefObject<HTMLDivElement>;
authUrl: string;
authUrlVersion: number;
initialCommand: string | null | undefined;
isConnected: boolean;
openAuthUrlInBrowser: (url: string) => boolean;
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
}; };
export default function ShellMinimalView({ export default function ShellMinimalView({
terminalContainerRef, terminalContainerRef,
authUrl,
authUrlVersion,
initialCommand,
isConnected,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}: ShellMinimalViewProps) { }: ShellMinimalViewProps) {
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const displayAuthUrl = useMemo(
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
[authUrl, initialCommand],
);
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
useEffect(() => {
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, [authUrlVersion, displayAuthUrl, isConnected]);
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return ( return (
<div className="relative h-full w-full bg-gray-900"> <div className="relative h-full w-full bg-gray-900">
<div <div
@@ -14,6 +47,67 @@ export default function ShellMinimalView({
className="h-full w-full focus:outline-none" className="h-full w-full focus:outline-none"
style={{ outline: 'none' }} style={{ outline: 'none' }}
/> />
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div> </div>
); );
} }

View File

@@ -137,12 +137,6 @@
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
/* The app shell is a fixed inset-0 container (see AppContent), so the
document itself never needs to scroll. Clipping it removes the phantom
full-height page scrollbar and disables the browser pull-to-refresh
gesture that reloads the page when scrolling up on mobile. */
overflow: hidden;
overscroll-behavior-y: contain;
} }
/* Root element with safe area padding for PWA */ /* Root element with safe area padding for PWA */