From cd31cd702d7ab8680a52cb85d98e88283540c47f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 16 Jun 2026 04:14:58 +0800 Subject: [PATCH] feat(mobile): add terminal text size (zoom) setting (#5388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release: v1.4.48-rc.0 [rc-slot:2026-06-05-15] * release: v0.0.1-rc.0 [rc-slot:2026-06-06-03] * feat(mobile): add terminal text size (zoom) setting The mobile terminal fits the desktop's full column count to the phone width with a CSS scale, which cancels out xterm's raw fontSize — so there was no way to make text bigger or smaller. Add a persisted baseline zoom multiplier the WebView applies on top of the fit. - Settings → Terminal → "Text size": 50%–200% presets. - Pinch-to-zoom in the terminal snaps to the same presets and persists. - Per-device display preference; does not change the desktop terminal. - Extracts the xterm WebView HTML into terminal-webview-html.ts to keep TerminalWebView.tsx within its max-lines budget. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(mobile): make terminal text size reflow columns instead of CSS scaling Text size now drives the real xterm fontSize rather than a CSS transform baseline. A larger cell means fewer columns fit the phone width, so the existing measure -> terminal.updateViewport pipeline resizes the PTY and the shell rewraps to the new width (and smaller sizes show more columns). Pinch still snaps to a preset, but now changes the font size and reflows instead of scaling pixels; the refit hook re-fits the PTY whenever the scale changes. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(mobile): restore terminal touch scrollback (undefined contentWiderThanViewport) The single-finger touchmove handler gated horizontal panning on contentWiderThanViewport(), which is never defined — so every one-finger touchmove threw a ReferenceError before reaching the vertical-scroll code below it, killing all touch scrolling including scrollback. Replace the call with the inline overflow check clampPan() already uses (scrollWidth * getTotalScale() > innerWidth). Vertical scroll now always runs; two-finger pinch-to-zoom is unaffected. Merged two adjacent var decls to keep the file under its max-lines cap without a disable. Co-Authored-By: Claude Opus 4.8 (1M context) * test(mobile): guard terminal text scale viewport refit Co-authored-by: Orca --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinwoo-H Co-authored-by: Orca --- mobile/.oxlintrc.json | 8 +- .../app/h/[hostId]/session/[worktreeId].tsx | 33 +- mobile/app/terminal-settings.tsx | 71 +- mobile/src/session/TerminalPaneView.tsx | 8 +- mobile/src/storage/preferences.ts | 31 + mobile/src/terminal/TerminalWebView.tsx | 1858 +--------------- .../terminal/terminal-viewport-refit.test.ts | 13 + .../src/terminal/terminal-viewport-refit.ts | 18 + mobile/src/terminal/terminal-webview-html.ts | 1902 +++++++++++++++++ .../terminal-webview-scroll-routing.test.ts | 6 +- 10 files changed, 2111 insertions(+), 1837 deletions(-) create mode 100644 mobile/src/terminal/terminal-webview-html.ts diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 58a74d1021a..5b0c202ea7a 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -28,7 +28,13 @@ { "files": ["src/terminal/TerminalWebView.tsx"], "rules": { - "max-lines": ["error", { "max": 2054, "skipBlankLines": true, "skipComments": true }] + "max-lines": ["error", { "max": 369, "skipBlankLines": true, "skipComments": true }] + } + }, + { + "files": ["src/terminal/terminal-webview-html.ts"], + "rules": { + "max-lines": ["error", { "max": 1773, "skipBlankLines": true, "skipComments": true }] } }, { diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 51e07527433..039f9cf573c 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -45,7 +45,11 @@ import { } from 'lucide-react-native' import type { RpcClient } from '../../../../src/transport/rpc-client' import { loadHosts } from '../../../../src/transport/host-store' -import { loadTerminalAutocompleteEnabled } from '../../../../src/storage/preferences' +import { + loadTerminalAutocompleteEnabled, + loadTerminalTextScale, + saveTerminalTextScale +} from '../../../../src/storage/preferences' import { useHostClient, useForceReconnect, @@ -739,6 +743,9 @@ export default function SessionScreen() { const sessionTabsRef = useRef([]) const [terminalsLoaded, setTerminalsLoaded] = useState(false) const [input, setInput] = useState('') + // Why: baseline terminal zoom, reloaded on focus so a Settings → Terminal change + // applies in place (the terminal panes stay mounted). + const [terminalTextScale, setTerminalTextScale] = useState(1) // Why: local opt-in for keyboard autocomplete/autocorrect on the terminal // command bar; reloaded on focus so a Settings → Terminal toggle takes effect on return. const [autocompleteEnabled, setAutocompleteEnabled] = useState(false) @@ -2082,6 +2089,7 @@ export default function SessionScreen() { deviceTokenRef, initializedHandlesRef, tabStripVisible: terminals.length > 1, + textScale: terminalTextScale, unsubscribeTerminal, subscribeToTerminal }) @@ -2306,6 +2314,22 @@ export default function SessionScreen() { }, [connState, fetchSessionTabs, fetchTerminals]) ) + // Why: pick up the Settings → Terminal text size when returning here — the + // terminal panes stay mounted, so they update in place. + useFocusEffect( + useCallback(() => { + let active = true + void loadTerminalTextScale().then((scale) => { + if (active) { + setTerminalTextScale(scale) + } + }) + return () => { + active = false + } + }, []) + ) + // Why: pick up the Settings → Terminal autocomplete toggle when returning here. useFocusEffect( useCallback(() => { @@ -3997,6 +4021,13 @@ export default function SessionScreen() { active={terminal.handle === activeHandle} keyboardLift={terminal.handle === activeHandle ? activeTerminalKeyboardLift : 0} terminalTheme={terminal.terminalTheme} + textScale={terminalTextScale} + onTextScaleChange={(scale) => { + // Why: pinch-to-zoom in the WebView reports a new preset; persist + // it so the size sticks across panes and app launches. + setTerminalTextScale(scale) + void saveTerminalTextScale(scale) + }} onRef={setTerminalWebViewRef} onWebReady={handleTerminalWebReady} onSelectionMode={handleSelectionMode} diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index eb78e52dba9..81367c7f784 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -8,7 +8,7 @@ import Animated, { useSharedValue } from 'react-native-reanimated' import { useRouter } from 'expo-router' -import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native' +import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' @@ -19,11 +19,34 @@ import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSett import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state' import { loadTerminalAutocompleteEnabled, - saveTerminalAutocompleteEnabled + loadTerminalTextScale, + saveTerminalAutocompleteEnabled, + saveTerminalTextScale } from '../src/storage/preferences' type RestoreValue = 'indefinite' | '60s' | '5m' | '30m' +type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest' + +// scale = baseline zoom the terminal WebView applies on top of fit-to-width. +// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values. +const TEXT_SIZE_OPTIONS: (PickerOption & { scale: number })[] = [ + { value: 'smallest', label: 'Smallest (50%)', scale: 0.5 }, + { value: 'smaller', label: 'Smaller (75%)', scale: 0.75 }, + { value: 'default', label: 'Default (100%)', scale: 1 }, + { value: 'large', label: 'Large (125%)', scale: 1.25 }, + { value: 'larger', label: 'Larger (150%)', scale: 1.5 }, + { value: 'largest', label: 'Largest (200%)', scale: 2 } +] + +function textSizeValueFromScale(scale: number): TextSizeValue { + return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default' +} + +function textSizeSummary(scale: number): string { + return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label +} + const AUTO_RESTORE_FIT_OPTIONS: (PickerOption & { ms: number | null })[] = [ { value: 'indefinite', label: 'Keep at phone size (default)', ms: null }, { value: '60s', label: 'After 1 minute', ms: 60_000 }, @@ -118,6 +141,20 @@ export default function TerminalSettingsScreen() { const [hostMs, setHostMs] = useState>({}) const [pickerHostId, setPickerHostId] = useState(null) + const [textScale, setTextScale] = useState(1) + const [textSizePickerOpen, setTextSizePickerOpen] = useState(false) + useEffect(() => { + void loadTerminalTextScale().then(setTextScale) + }, []) + const selectTextSize = useCallback((value: TextSizeValue) => { + const opt = TEXT_SIZE_OPTIONS.find((o) => o.value === value) + if (!opt) { + return + } + setTextScale(opt.scale) + void saveTerminalTextScale(opt.scale) + }, []) + const [autocompleteEnabled, setAutocompleteEnabled] = useState(false) // Why: a fast toggle before the initial load resolves must win — otherwise the // delayed read would clobber the user's choice with the stored (stale) value. @@ -268,6 +305,27 @@ export default function TerminalSettingsScreen() { )} + TEXT SIZE + + Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes + show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal + itself, which updates this setting. Per-device display only; doesn't change the + desktop terminal. + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setTextSizePickerOpen(true)} + > + + + Text size + {textSizeSummary(textScale)} + + + + + KEYBOARD INPUT Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal @@ -310,6 +368,15 @@ export default function TerminalSettingsScreen() { }} onClose={() => setPickerHostId(null)} /> + + + visible={textSizePickerOpen} + title="Terminal text size" + options={TEXT_SIZE_OPTIONS} + selected={textSizeValueFromScale(textScale)} + onSelect={selectTextSize} + onClose={() => setTextSizePickerOpen(false)} + /> ) } diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index b1eceb5f440..66d05ec9ac9 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -13,6 +13,7 @@ type TerminalPaneViewProps = { active: boolean keyboardLift: number terminalTheme?: MobileTerminalTheme + textScale: number onRef: (handle: string, ref: TerminalWebViewHandle | null) => void onWebReady: (handle: string) => void onSelectionMode: (handle: string, active: boolean) => void @@ -23,6 +24,7 @@ type TerminalPaneViewProps = { onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput: (handle: string, bytes: string) => void onTerminalTap: (handle: string) => void + onTextScaleChange: (scale: number) => void } export function TerminalPaneView({ @@ -30,6 +32,7 @@ export function TerminalPaneView({ active, keyboardLift, terminalTheme, + textScale, onRef, onWebReady, onSelectionMode, @@ -39,7 +42,8 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, - onTerminalTap + onTerminalTap, + onTextScaleChange }: TerminalPaneViewProps) { const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { @@ -63,6 +67,7 @@ export function TerminalPaneView({ ref={setRef} style={styles.terminalWebView} terminalTheme={terminalTheme} + textScale={textScale} onWebReady={() => onWebReady(handle)} onSelectionMode={(a) => onSelectionMode(handle, a)} onSelectionCopy={(t) => onSelectionCopy(handle, t)} @@ -72,6 +77,7 @@ export function TerminalPaneView({ onHaptic={onHaptic} onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} onTerminalTap={() => onTerminalTap(handle)} + onTextScaleChange={onTextScaleChange} /> ) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 6b85bbd26bc..82f2c65e02f 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -25,6 +25,37 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise { + try { + const raw = await AsyncStorage.getItem(TEXT_SCALE_KEY) + if (raw === null) { + return DEFAULT_TEXT_SCALE + } + const parsed = Number(raw) + return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed) + ? parsed + : DEFAULT_TEXT_SCALE + } catch { + return DEFAULT_TEXT_SCALE + } +} + +export async function saveTerminalTextScale(scale: number): Promise { + await AsyncStorage.setItem(TEXT_SCALE_KEY, String(scale)) +} + const AUTOCOMPLETE_KEY = 'orca:terminalAutocompleteEnabled' // Why: terminal command inputs default to autocorrect/suggestions OFF so the diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 6d67e00c613..7208b17796c 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -4,6 +4,7 @@ import { WebView } from 'react-native-webview' import type { WebViewMessageEvent } from 'react-native-webview' import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import { colors } from '../theme/mobile-theme' +import { XTERM_HTML } from './terminal-webview-html' type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' @@ -32,6 +33,9 @@ export type TerminalSelectionEvents = { onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string) => void onTerminalTap?: () => void + // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it + // here so the app persists it and keeps Settings + other panes in sync. + onTextScaleChange?: (scale: number) => void } export type TerminalWebViewHandle = { @@ -53,6 +57,9 @@ export type TerminalWebViewHandle = { type Props = { style?: StyleProp terminalTheme?: MobileTerminalTheme + // Why: baseline zoom multiplier ("text size") applied on top of the fit-to-width + // scale; raw xterm fontSize can't drive apparent size because the fit cancels it. + textScale?: number onWebReady?: () => void } & TerminalSelectionEvents @@ -65,7 +72,9 @@ type TerminalMessage = rows: number initialData?: string terminalTheme?: MobileTerminalTheme + fontScale?: number } + | { type: 'set-font-scale'; id?: number; fontScale: number } | { type: 'resize'; id?: number; cols: number; rows: number } | { type: 'clear'; id?: number } | { type: 'measure'; id?: number; containerHeight?: number } @@ -77,1837 +86,11 @@ type TerminalMessage = const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000 const MAX_PENDING_WEB_WRITE_MESSAGES = 4096 -const DEFAULT_TERMINAL_THEME: MobileTerminalTheme['theme'] = { - background: colors.terminalBg, - foreground: '#c0caf5', - cursor: '#c0caf5', - cursorAccent: colors.terminalBg, - selectionBackground: '#33467c', - selectionForeground: '#c0caf5', - black: '#15161e', - red: '#f7768e', - green: '#9ece6a', - yellow: '#e0af68', - blue: '#7aa2f7', - magenta: '#bb9af7', - cyan: '#7dcfff', - white: '#a9b1d6', - brightBlack: '#414868', - brightRed: '#f7768e', - brightGreen: '#9ece6a', - brightYellow: '#e0af68', - brightBlue: '#7aa2f7', - brightMagenta: '#bb9af7', - brightCyan: '#7dcfff', - brightWhite: '#c0caf5' -} - -// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor -// positioning designed for the desktop's terminal dimensions (~150+ cols). -// We initialize xterm at the desktop's exact cols/rows so those escape codes -// render correctly, then use a measured CSS transform: scale() to fit the -// canvas into the phone viewport. The scale is computed after xterm opens -// by measuring the rendered surface width, not hardcoded, so it adapts to -// any terminal column count (80, 150, 200+). All touch gestures (scroll, -// pinch-to-zoom, pan) are handled by custom JS rather than native WebView -// behavior, so they work correctly with the CSS scale transform. -const XTERM_HTML = ` - - - - - - - - -
-
-
-
-
-
-
-
- - -
-
- - - -` - export const TerminalWebView = forwardRef(function TerminalWebView( { style, terminalTheme, + textScale = 1, onWebReady, onSelectionMode, onSelectionCopy, @@ -1916,7 +99,8 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, - onTerminalTap + onTerminalTap, + onTextScaleChange }, ref ) { @@ -2081,6 +265,11 @@ export const TerminalWebView = forwardRef(function ) { onHaptic?.(kind) } + } else if (msg.type === 'font-scale-changed') { + const scale = typeof msg.fontScale === 'number' ? msg.fontScale : 0 + if (scale > 0) { + onTextScaleChange?.(scale) + } } else if (msg.type === 'mobile-clip-cancel-by-pinch') { // eslint-disable-next-line no-console console.warn('[mobile-clip] selection cancelled by pinch') @@ -2096,7 +285,8 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, - onTerminalTap + onTerminalTap, + onTextScaleChange ] ) @@ -2111,6 +301,12 @@ export const TerminalWebView = forwardRef(function postMessage({ type: 'set-theme', terminalTheme }) }, [postMessage, terminalThemeKey, terminalTheme]) + // Why: live-apply text-size changes to an already-mounted terminal (the pane + // stays alive while the user visits Settings), so no terminal reload is needed. + useEffect(() => { + postMessage({ type: 'set-font-scale', fontScale: textScale }) + }, [postMessage, textScale]) + useImperativeHandle( ref, () => ({ @@ -2134,7 +330,7 @@ export const TerminalWebView = forwardRef(function readyPromiseRef.current = new Promise((resolve) => { readyResolveRef.current = resolve }) - postMessage({ type: 'init', cols, rows, initialData, terminalTheme }) + postMessage({ type: 'init', cols, rows, initialData, terminalTheme, fontScale: textScale }) }, resize(cols: number, rows: number) { postMessage({ type: 'resize', cols, rows }) @@ -2206,7 +402,7 @@ export const TerminalWebView = forwardRef(function }) } }), - [postMessage, sendToWebView, terminalTheme] + [postMessage, sendToWebView, terminalTheme, textScale] ) return ( diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index af6cb9f42a5..e7a6d00f328 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -35,9 +35,22 @@ describe('terminal viewport refit', () => { expect(tabEffect).toContain('scheduleViewportRefit()') }) + it('refits the PTY when terminal text scale changes', () => { + // Why: mobile text size must change the real PTY grid, not just scale pixels + // in the WebView, or wrapped CLI output diverges from what the shell sees. + const start = hookSource.indexOf('const prevTextScaleRef = useRef(textScale)') + expect(start).toBeGreaterThanOrEqual(0) + const textScaleEffect = hookSource.slice(start, start + 600) + expect(textScaleEffect).toContain('prevTextScaleRef.current === textScale') + expect(textScaleEffect).toContain('viewportMeasuredRef.current = false') + expect(textScaleEffect).toContain('scheduleViewportRefit()') + expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]') + }) + it('is wired into the session screen', () => { expect(sessionSource).toContain('useTerminalViewportRefit({') expect(sessionSource).toContain('tabStripVisible: terminals.length > 1') + expect(sessionSource).toContain('textScale: terminalTextScale') }) it('prefers the in-place updateViewport RPC over resubscribe', () => { diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index 6186b879522..ce69a056953 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -19,6 +19,9 @@ type TerminalViewportRefitOptions = { deviceTokenRef: RefObject initializedHandlesRef: RefObject> tabStripVisible: boolean + // Why: terminal text size (font scale) — changing it changes the cell size, so + // the PTY must be re-fitted to a new column count and reflowed. + textScale: number unsubscribeTerminal: (handle: string) => void subscribeToTerminal: (handle: string) => void } @@ -40,6 +43,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): deviceTokenRef, initializedHandlesRef, tabStripVisible, + textScale, unsubscribeTerminal, subscribeToTerminal } = options @@ -164,6 +168,20 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): scheduleViewportRefit() }, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit]) + // Why: the text size changed, so the WebView is re-rendering at a new font/cell + // size. Re-measure and resize the PTY so the server reflows to the new column + // count. The refit's own 150ms debounce gives the WebView a frame to apply the + // new fontSize before we measure the resulting cell metrics. + const prevTextScaleRef = useRef(textScale) + useEffect(() => { + if (prevTextScaleRef.current === textScale) { + return + } + prevTextScaleRef.current = textScale + viewportMeasuredRef.current = false + scheduleViewportRefit() + }, [textScale, viewportMeasuredRef, scheduleViewportRefit]) + useEffect(() => { disposedRef.current = false return () => { diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts new file mode 100644 index 00000000000..e909ceb136e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -0,0 +1,1902 @@ +// xterm.js WebView document + default Tokyonight theme. Extracted from +// TerminalWebView.tsx to keep that file within the max-lines budget. +import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import { colors } from '../theme/mobile-theme' +import { TERMINAL_TEXT_SCALES } from '../storage/preferences' + +const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { + background: colors.terminalBg, + foreground: '#c0caf5', + cursor: '#c0caf5', + cursorAccent: colors.terminalBg, + selectionBackground: '#33467c', + selectionForeground: '#c0caf5', + black: '#15161e', + red: '#f7768e', + green: '#9ece6a', + yellow: '#e0af68', + blue: '#7aa2f7', + magenta: '#bb9af7', + cyan: '#7dcfff', + white: '#a9b1d6', + brightBlack: '#414868', + brightRed: '#f7768e', + brightGreen: '#9ece6a', + brightYellow: '#e0af68', + brightBlue: '#7aa2f7', + brightMagenta: '#bb9af7', + brightCyan: '#7dcfff', + brightWhite: '#c0caf5' +} + +// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor +// positioning designed for the desktop's terminal dimensions (~150+ cols). +// We initialize xterm at the desktop's exact cols/rows so those escape codes +// render correctly, then use a measured CSS transform: scale() to fit the +// canvas into the phone viewport. The scale is computed after xterm opens +// by measuring the rendered surface width, not hardcoded, so it adapts to +// any terminal column count (80, 150, 200+). All touch gestures (scroll, +// pinch-to-zoom, pan) are handled by custom JS rather than native WebView +// behavior, so they work correctly with the CSS scale transform. +export const XTERM_HTML = ` + + + + + + + + +
+
+
+
+
+
+
+
+ + +
+
+ + + +` diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index b5e51a3baeb..8a5a0732879 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -1,7 +1,11 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in +// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file. +const source = + readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') + + readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') const sessionSource = readFileSync( new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), 'utf8'