refactor(runtime): restore split command surfaces

This commit is contained in:
Jinwoo-H
2026-09-01 15:27:48 -04:00
parent e74ac45057
commit 7b40cf68fc
34 changed files with 2289 additions and 51783 deletions
-18
View File
@@ -15,30 +15,12 @@
"unicorn/prefer-node-protocol": "off"
},
"overrides": [
{
"files": ["app/h/*/tasks.tsx"],
"rules": {
"max-lines": ["error", { "max": 14682, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/session/*.tsx"],
"rules": {
"max-lines": ["error", { "max": 5015, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/terminal/TerminalWebView.tsx"],
"rules": {
"max-lines": ["error", { "max": 379, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/terminal/terminal-webview-html.ts"],
"rules": {
"max-lines": ["error", { "max": 1784, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/source-control/*.tsx"],
"rules": {
@@ -2,8 +2,6 @@ const NATIVE_BASELINE_FLAG = '1'
const NATIVE_ARCHITECTURE = 'native'
const HYBRID_ARCHITECTURE = 'hybrid'
export type MobileArchitecture = 'native' | 'hybrid'
export function mobileNativeBaselineMode(args: {
developmentBuild: boolean
requested: string | undefined
@@ -30,7 +28,3 @@ export const MOBILE_NATIVE_BASELINE_MODE = mobileNativeBaselineMode({
requested: process.env.EXPO_PUBLIC_ORCA_E2E_MOBILE_NATIVE_BASELINE,
architecture: process.env.EXPO_PUBLIC_ORCA_MOBILE_ARCHITECTURE
})
export const MOBILE_ARCHITECTURE: MobileArchitecture = MOBILE_NATIVE_BASELINE_MODE
? 'native'
: 'hybrid'
@@ -1,25 +1,22 @@
import { readFileSync } from 'node:fs'
import type { RefObject } from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { ConnectionState } from '../transport/types'
import type { TerminalWebViewHandle } from './TerminalWebView'
import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support'
import {
TERMINAL_FOREGROUND_RECOVERY_DELAY_MS,
recoverActiveTerminalAfterForeground,
shouldRecoverTerminalOnAppStateChange
} from './terminal-foreground-recovery'
const sessionSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const lifecycleSource = readMobileSessionRouteSource('../session/use-mobile-session-lifecycle.ts')
function sliceSessionSource(startPattern: string, endPattern: string): string {
const start = sessionSource.indexOf(startPattern)
const start = lifecycleSource.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = sessionSource.indexOf(endPattern, start)
const end = lifecycleSource.indexOf(endPattern, start)
expect(end).toBeGreaterThan(start)
return sessionSource.slice(start, end)
return lifecycleSource.slice(start, end)
}
type RecoveryHarness = {
@@ -149,14 +146,14 @@ describe('terminal foreground recovery', () => {
'previousAppState = nextAppState'
)
expect(sessionSource).toContain('shouldRecoverTerminalOnAppStateChange')
expect(lifecycleSource).toContain('shouldRecoverTerminalOnAppStateChange')
expect(foregroundPredicate).toContain('Platform.OS')
expect(sessionSource).toContain('recoverActiveTerminalAfterForeground({')
expect(sessionSource).toContain("AppState.addEventListener('change'")
const readinessInvalidation = sessionSource.indexOf(
expect(lifecycleSource).toContain('recoverActiveTerminalAfterForeground({')
expect(lifecycleSource).toContain("AppState.addEventListener('change'")
const readinessInvalidation = lifecycleSource.indexOf(
'terminalRef.prepareForForegroundRecovery()'
)
const replay = sessionSource.indexOf('recoverActiveTerminalAfterForeground({')
const replay = lifecycleSource.indexOf('recoverActiveTerminalAfterForeground({')
expect(readinessInvalidation).toBeGreaterThanOrEqual(0)
expect(replay).toBeGreaterThan(readinessInvalidation)
})
@@ -165,7 +162,9 @@ describe('terminal foreground recovery', () => {
// Why: resume usually lands mid-reconnect; the session screen must retry
// recovery on the connState→connected transition or blanked panes stay
// stale until a manual tab switch.
expect(sessionSource).toContain("pendingForegroundRecoveryRef.current = outcome === 'deferred'")
expect(lifecycleSource).toContain(
"pendingForegroundRecoveryRef.current = outcome === 'deferred'"
)
const reconnectRetry = sliceSessionSource(
"if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)",
'recoverActiveTerminalAfterForeground({'
@@ -1,29 +1,27 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate'
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
const runtimeSource = readMobileSessionRouteSource(
'../session/use-mobile-session-terminal-runtime.ts'
)
const nativeTerminalOperationsSource = readFileSync(
new URL('../session/native-host-session-terminal-operations.ts', import.meta.url),
'utf8'
const sendActionsSource = readMobileSessionRouteSource(
'../session/use-mobile-session-terminal-send-actions.ts'
)
const accessoryRawSendSource = readFileSync(
new URL('./terminal-live-accessory-raw-send.ts', import.meta.url),
'utf8'
const terminalInputSource = readMobileSessionRouteSource(
'../session/use-mobile-session-terminal-input.ts'
)
const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx')
function routeSlice(anchorStart: string, anchorEnd: string): string {
const start = sessionRouteSource.indexOf(anchorStart)
function sourceSlice(source: string, anchorStart: string, anchorEnd: string): string {
const start = source.indexOf(anchorStart)
expect(start).toBeGreaterThanOrEqual(0)
// Why: a duplicated start anchor would silently slice the wrong region.
expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1)
const end = sessionRouteSource.indexOf(anchorEnd, start)
expect(source.indexOf(anchorStart, start + 1)).toBe(-1)
const end = source.indexOf(anchorEnd, start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end + anchorEnd.length)
return source.slice(start, end + anchorEnd.length)
}
describe('terminal input connection gate', () => {
@@ -87,32 +85,46 @@ describe('terminal input connection gate', () => {
describe('session route offline-compose wiring', () => {
it('derives both gates from the shared resolver', () => {
expect(sessionRouteSource).toContain('resolveMobileTerminalInputGate({')
expect(runtimeSource).toContain('resolveMobileTerminalInputGate({')
})
it('keeps the buffered command box editable offline while the live capture stays send-gated', () => {
const bufferedInput = routeSlice(
const bufferedInput = sourceSlice(
commandDockSource,
'ref={commandInputRef}',
'onSubmitEditing={() => void handleSend()}'
)
expect(bufferedInput).toContain('editable={canCompose}')
const liveCapture = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"')
const liveCapture = sourceSlice(
commandDockSource,
'ref={liveInputRef}',
'importantForAutofill="no"'
)
expect(liveCapture).toContain('editable={canSend}')
})
it('keeps the send button connection-gated so held text cannot fire into a dead link', () => {
const sendButton = routeSlice('styles.sendButton,', 'accessibilityLabel="Send command"')
const sendButton = sourceSlice(
commandDockSource,
'styles.sendButton,',
'accessibilityLabel="Send command"'
)
expect(sendButton).toContain('disabled={!canSend}')
})
it('holds composed text when the return key submits offline', () => {
const handleSend = routeSlice('async function handleSend()', 'sendingRef.current = true')
const handleSend = sourceSlice(
sendActionsSource,
'async function handleSend()',
'sendingRef.current = true'
)
expect(handleSend).toContain('!canSend')
})
it('keeps the live/buffered mode toggle reachable offline', () => {
const modeToggle = routeSlice(
const modeToggle = sourceSlice(
commandDockSource,
'liveInputEnabled && styles.accessoryKeyActive',
'onPress={toggleLiveInput}'
)
@@ -120,13 +132,25 @@ describe('session route offline-compose wiring', () => {
})
it('tells the live-input commit hook about connection loss so stale mirror state resets', () => {
const hookCall = routeSlice('useTerminalLiveInputCommit({', 'setLiveInputCapture')
const hookCall = sourceSlice(
runtimeSource,
'useTerminalLiveInputCommit({',
'setLiveInputCapture'
)
expect(hookCall).toContain("connected: connState === 'connected'")
})
it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => {
expect(nativeTerminalOperationsSource).toContain('TERMINAL_INPUT_SEND_OPTIONS')
expect(accessoryRawSendSource).toContain('TERMINAL_INPUT_SEND_OPTIONS')
// Live mirror, buffered send, and gesture arrows must all opt out of the
// connect wait — a parked send replays stale bytes into the PTY. Accessory
// keys get the same option inside terminal-live-accessory-raw-send.ts.
expect(sendActionsSource).toContain('TERMINAL_INPUT_SEND_OPTIONS')
expect(terminalInputSource).toContain('TERMINAL_INPUT_SEND_OPTIONS')
const optionUses = [sendActionsSource, terminalInputSource].flatMap(
(source) => source.match(/TERMINAL_INPUT_SEND_OPTIONS/g) ?? []
).length
// Two owner imports plus one buffered, one live, and one gesture send.
expect(optionUses).toBe(5)
expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true })
})
@@ -1,9 +1,9 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx')
const sendActionsSource = readMobileSessionRouteSource(
'../session/use-mobile-session-terminal-send-actions.ts'
)
// Why: iOS terminates an active keyboard-dictation (and IME) session whenever
@@ -13,13 +13,11 @@ const sessionRouteSource = readFileSync(
// apply dash normalization only on the send/mirror path. See stablyai/orca#7925.
describe('terminal iOS dictation write-back', () => {
it('does not write normalized text back into the buffered command input value', () => {
expect(sessionRouteSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}')
expect(sessionRouteSource).not.toContain(
'setInput((previousText) => normalizeTerminalTextInput'
)
expect(commandDockSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}')
expect(commandDockSource).not.toContain('setInput((previousText) => normalizeTerminalTextInput')
})
it('still normalizes the buffered command text at send time', () => {
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(draft)')
expect(sendActionsSource).toContain('normalizeTerminalTextInput(draft)')
})
})
@@ -1,26 +1,23 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx')
describe('terminal iOS IME keyboard', () => {
it('does not force terminal inputs onto the ASCII-only iOS keyboard', () => {
expect(sessionRouteSource).not.toContain("'ascii-capable'")
expect(sessionRouteSource).not.toContain('"ascii-capable"')
expect(commandDockSource).not.toContain("'ascii-capable'")
expect(commandDockSource).not.toContain('"ascii-capable"')
})
it('subscribes live capture to onChange so the marked-text report survives', () => {
// onChangeText hands over only a string, discarding the preedit report that
// decides whether the text may reach the PTY at all.
expect(sessionRouteSource).toContain('onChange={handleLiveInputChange}')
expect(sessionRouteSource).not.toContain('onChangeText={handleLiveInputChange}')
expect(commandDockSource).toContain('onChange={handleLiveInputChange}')
expect(commandDockSource).not.toContain('onChangeText={handleLiveInputChange}')
})
it('does not put terminal keyboard capture behind iOS textContentType semantics', () => {
expect(sessionRouteSource).not.toContain('textContentType="none"')
expect(sessionRouteSource).toContain('autoComplete="off"')
expect(commandDockSource).not.toContain('textContentType="none"')
expect(commandDockSource).toContain('autoComplete="off"')
})
})
@@ -4,11 +4,9 @@ import { Terminal } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
const terminalHtmlSource = readTerminalWebViewHtmlSource()
const reflowSource = readFileSync(
new URL('./terminal-webview-reflow-injected.ts', import.meta.url),
'utf8'
@@ -1,9 +1,13 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
const commandDockSource = readMobileSessionRouteSource('../session/MobileSessionCommandDock.tsx')
const terminalRuntimeSource = readMobileSessionRouteSource(
'../session/use-mobile-session-terminal-runtime.ts'
)
const nativeChatSource = readMobileSessionRouteSource(
'../session/use-mobile-session-native-chat-dictation.ts'
)
const liveInputStatusSource = readFileSync(
new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url),
@@ -23,11 +27,11 @@ const sendCompletionGenerationSource = readFileSync(
)
function liveInputBarBlock(): string {
const start = sessionRouteSource.indexOf('{liveInputEnabled ? (')
const start = commandDockSource.indexOf('{liveInputEnabled ? (')
expect(start).toBeGreaterThanOrEqual(0)
const end = sessionRouteSource.indexOf(') : (', start)
const end = commandDockSource.indexOf(') : (', start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end)
return commandDockSource.slice(start, end)
}
describe('terminal live input affordance', () => {
@@ -44,9 +48,9 @@ describe('terminal live input affordance', () => {
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
expect(block).toContain('showSoftInputOnFocus')
expect(block).toContain('liveInputText={liveInputCapture}')
expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({')
expect(sessionRouteSource).toContain('useMobileSendCompletionGeneration({')
expect(sessionRouteSource).toContain('onBlur: resetLiveInputFocus')
expect(terminalRuntimeSource).toContain('useTerminalLiveInputFocus({')
expect(nativeChatSource).toContain('useMobileSendCompletionGeneration({')
expect(nativeChatSource).toContain('onBlur: resetLiveInputFocus')
expect(sendCompletionGenerationSource).toContain('return () => {')
expect(sendCompletionGenerationSource).toContain('onBlur()')
expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current')
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import type { RpcResponse } from '../transport/types'
import { readMobileSessionRouteSource } from '../session/mobile-session-route-source-family.test-support'
import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
@@ -13,10 +14,10 @@ import {
} from './terminal-viewport-refit-state'
const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8')
const sessionSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const sessionSource = [
readMobileSessionRouteSource('../session/use-mobile-session-keyboard-state.ts'),
readMobileSessionRouteSource('../session/MobileSessionActiveContent.tsx')
].join('\n')
describe('terminal viewport refit', () => {
it('refits when the window dimensions change (fold/unfold, rotation)', () => {
@@ -1,15 +1,14 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it, vi } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_HTML } from './terminal-webview-html'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
// Assert against the assembled document so extracted fragments cannot silently
// disappear from the WebView while source-level checks still pass.
const terminalHtmlSource = readTerminalWebViewHtmlSource()
function createWebglRecoveryHarness(failSecondAttach = false) {
const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;')
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs'
const SOURCE_FILES = [
'./terminal-webview-html.ts',
...Array.from(
{ length: 10 },
(_, index) => `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`
)
] as const
/** Reads the TypeScript source that assembles the in-WebView document. */
export function readTerminalWebViewHtmlSource(): string {
return SOURCE_FILES.map((relativePath) =>
readFileSync(new URL(relativePath, import.meta.url), 'utf8')
).join('\n')
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
import { colors } from '../../theme/mobile-theme'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
export const TERMINAL_HTML_FRAGMENT_01 = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<script>
window.__engineErrors = [];
window.onerror = function(msg) {
// Why: a degraded engine can throw per frame; cap so the capture buffer
// and downstream reporting stay bounded for the document's lifetime.
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
};
</script>
<style>${XTERM_ENGINE_CSS}</style>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background: ${colors.terminalBg};
overflow: hidden;
width: 100%;
height: 100%;
}
#terminal-container {
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
}
#terminal-surface {
transform-origin: top left;
display: inline-block;
}
.xterm { -webkit-user-select: none; user-select: none; font-variant-emoji: text; }
.xterm .xterm-viewport {
overflow-y: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none;
}
.xterm .xterm-viewport::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
.xterm .xterm-scrollable-element > .xterm-scrollbar,
.xterm .xterm-scrollbar {
display: none !important;
width: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
#scroll-indicator {
position: fixed;
top: 4px;
right: 3px;
bottom: 4px;
width: 3px;
pointer-events: none;
opacity: 0;
transition: opacity 120ms linear;
z-index: 7;
}
#scroll-indicator.visible { opacity: 0.72; }
#scroll-thumb {
position: absolute;
top: 0;
right: 0;
width: 3px;
min-height: 24px;
border-radius: 999px;
background: ${colors.textSecondary};
will-change: transform, height;
}
/* Why: selection overlay sits in unscaled viewport coords, above the
transformed surface, so handle hit areas and Copy menu positions
don't depend on getTotalScale() for their on-screen size. */
#selection-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
z-index: 10;
display: none;
}
#selection-overlay.active { display: block; }
.sel-handle {
position: absolute;
width: 44px; height: 44px;
margin-left: -22px; margin-top: -22px;
pointer-events: auto;
background: transparent;
}
.sel-handle::before {
content: '';
position: absolute;
left: 50%; top: 22px;
transform: translateX(-50%);
width: 14px; height: 14px;
background: #7aa2f7;
border-radius: 50%;
border: 2px solid #c0caf5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.sel-handle.start::before { top: 8px; }
.sel-handle.start::after {
content: '';
position: absolute;
left: 50%; top: 22px;
transform: translateX(-50%);
width: 2px; height: 16px;
background: #7aa2f7;
}
.sel-handle.end::before { top: 22px; }
.sel-handle.end::after {
content: '';
position: absolute;
left: 50%; top: 6px;
transform: translateX(-50%);
width: 2px; height: 16px;
background: #7aa2f7;
}
#sel-menu {
position: absolute;
pointer-events: auto;
background: #2a2f4a;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
display: flex;
overflow: hidden;
transform: translateY(-100%);
margin-top: -12px;
user-select: none;
-webkit-user-select: none;
}
#sel-menu button {
background: transparent;
border: none;
color: #c0caf5;
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 10px 16px;
cursor: pointer;
}
#sel-menu button:active { background: #414868; }
#sel-menu button + button { border-left: 1px solid #414868; }
</style>
</head>
<body>
<div id="terminal-container">
<div id="terminal-surface"></div>
</div>
<div id="scroll-indicator"><div id="scroll-thumb"></div></div>
<div id="selection-overlay">
<div id="sel-handle-start" class="sel-handle start"></div>
<div id="sel-handle-end" class="sel-handle end"></div>
<div id="sel-menu">
<button id="sel-menu-copy">Copy</button>
<button id="sel-menu-all">Select All</button>
</div>
</div>
<script>${XTERM_ENGINE_JS}</script>
<script>
(function() {
var surface = document.getElementById('terminal-surface');
var ESC = String.fromCharCode(27);
var C1_CSI = String.fromCharCode(155);
var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa);
var TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e);
var EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f);
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
var statusDotPendingSelector = false;
`
@@ -0,0 +1,180 @@
import { TERMINAL_QUERY_REPLY_JS } from '../terminal-webview-query-reply-injected'
import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injected'
import { TERMINAL_TEXT_SCALES } from '../../storage/preferences'
import { DEFAULT_TERMINAL_THEME } from './theme'
export const TERMINAL_HTML_FRAGMENT_02 = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
${TERMINAL_SURFACE_SWAP_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
var writeQueue = [];
var writeQueueHead = 0;
var writesDraining = false;
var afterDrainCallbacks = [];
var termObserverDisposables = [];
var ready = false;
// Why: init() flips ready false on every re-init (live width reflow included)
// while the old surface stays visible; a document-scoped latch drives the
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
var everReady = false;
var currentScale = 1;
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
// gesture only; it resets to 1 on release. The persistent "text size" is the
// real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it
// reflows the grid: a bigger cell means fewer columns fit, and RN re-measures
// and resizes the PTY (terminal.updateViewport) so the shell rewraps to the
// new width. A finished pinch snaps to the nearest preset and reports it to RN.
var userScale = 1;
var BASE_FONT_PX = 13;
var MIN_FONT_PX = 6;
var MIN_FIT_COLS = 20;
var currentTextScale = 1;
var TEXT_SCALE_PRESETS = ${JSON.stringify([...TERMINAL_TEXT_SCALES])};
var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0];
var MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1];
function snapToTextScalePreset(value) {
var best = TEXT_SCALE_PRESETS[0], bestDelta = Infinity;
for (var i = 0; i < TEXT_SCALE_PRESETS.length; i++) {
var delta = Math.abs(TEXT_SCALE_PRESETS[i] - value);
if (delta < bestDelta) { bestDelta = delta; best = TEXT_SCALE_PRESETS[i]; }
}
return best;
}
function fontPxForScale(scale) {
return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale));
}
function isIOSWebView() {
if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true;
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
}
// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace';
var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS;
// Why: change the real font size, then resize the grid to fit the viewport at
// the new cell metrics so the text shows at its true size immediately. RN's
// refit (measure → updateViewport) then makes the server reflow the PTY to the
// same column count so the shell rewraps. cell metrics update on the frame
// after fontSize changes, so the resize/fit is deferred one rAF.
function applyTextScale(scale) {
currentTextScale = scale;
if (!term) return;
var px = fontPxForScale(scale);
if (term.options.fontSize === px) return;
term.options.fontSize = px;
requestAnimationFrame(function() {
if (!term) return;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW > 0 && cellH > 0) {
var cols = Math.floor(window.innerWidth / cellW);
if (cols < MIN_FIT_COLS) return;
var rows = Math.max(8, Math.floor(window.innerHeight / cellH));
term.resize(cols, rows);
emitKeyboardAvoidanceMetrics();
}
applyFitScale('text-scale');
});
}
var panX = 0, panY = 0;
var smoothScrollOffsetY = 0;
var pendingNormalScrollDeltaY = 0;
var normalScrollFrameId = null;
var initRows = 24;
var terminalGeneration = 0;
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
var terminalThemeInput = null;
var terminalTheme = defaultTheme;
var terminalMinimumContrastRatio = 3;
var webglAddon = null;
var webglRecoveryTimer = null;
var activeAltScreenSnapshot = false;
var trackedMouseTrackingMode = 'none';
var sgrMouseMode = false;
var sgrMousePixelsMode = false;
var initialOscLinks = [], initialOscLinkRowOffset = 0;
var initialOscLinkEvictionReady = false;
var mouseModeScanTail = '';
var handledMessageIds = [];
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
// once when the first live data chunk arrives so a wider line that pushes
// scrollWidth past the previously-measured value gets re-scaled to fit.
var firstDataPending = false;
// Diagnostic logger — bridges WebView console.log to RN via postMessage.
// Tag with [fit] so it's easy to filter in the Expo/Metro logs.
function flog(tag, payload) {
try {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'log', tag: '[fit]' + tag, payload: payload
}));
}
} catch (e) {}
}
function getCellWidth() {
if (!term || !term._core) return 0;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.width || 0;
}
return 0;
}
// Why: width measurement strategy.
// 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses
// to lay out and is independent of buffer content. It's the "logical
// width" of the terminal grid.
// 2. Fall back to term.element.scrollWidth — the actual rendered DOM
// width — only when cellWidth isn't available yet (renderer not
// initialized). This is content-dependent (reflects widest row),
// but better than nothing.
// 3. If both are 0, return 1 (no scale change). The retry loop in
// applyFitScale will keep trying until one is positive.
function computeFitScale() {
if (!term) return 1;
var cellW = getCellWidth();
var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0);
if (termWidth <= 0) return 1;
var vpWidth = window.innerWidth;
return Math.min(1, vpWidth / termWidth);
}
function getTotalScale() { return currentScale * userScale; }
function updateTransform() {
surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';
updateScrollIndicator(false);
if (selMode === 'select') repositionOverlay();
}
function updateScrollIndicator(reveal) {
if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return;
var buffer = term.buffer.active;
var maxViewportY = buffer.baseY || 0;
if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
scrollIndicator.classList.remove('visible');
return;
}
var trackHeight = Math.max(0, window.innerHeight - 8);
var totalRows = maxViewportY + (term.rows || 0);
if (trackHeight <= 0 || totalRows <= 0) return;
var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows);
var maxTop = Math.max(0, trackHeight - thumbHeight);
var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0;
scrollThumb.style.height = thumbHeight + 'px';
scrollThumb.style.transform = 'translateY(' + top + 'px)';
if (!reveal) return;
scrollIndicator.classList.add('visible');
if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer);
scrollIndicatorHideTimer = setTimeout(function() {
scrollIndicator.classList.remove('visible');
scrollIndicatorHideTimer = null;
}, 550);
}
`
@@ -0,0 +1,287 @@
import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected'
export const TERMINAL_HTML_FRAGMENT_03 = `${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15;
}
return 15;
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
function clampPan() {
if (!term || !term.element) return;
var ts = getTotalScale();
var cw = term.element.scrollWidth * ts;
var ch = term.element.scrollHeight * ts;
var vpW = window.innerWidth;
var vpH = window.innerHeight;
if (cw > vpW) {
panX = Math.min(0, Math.max(vpW - cw, panX));
} else {
panX = 0;
}
if (ch > vpH) {
panY = Math.min(0, Math.max(vpH - ch, panY));
} else {
panY = 0;
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale(reason) {
if (!term || !term.element) return;
var token = ++fitRetryToken;
var attempts = 0;
var lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
}
requestAnimationFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
});
}
repositionOverlay();
}
function isAltScreenActive(data) {
if (typeof data !== 'string') return false;
var on = data.lastIndexOf(ESC + '[?1049h');
var off = data.lastIndexOf(ESC + '[?1049l');
return on !== -1 && on > off;
}
function normalizeInitialData(data) {
if (!isAltScreenActive(data)) return data;
var on = data.lastIndexOf(ESC + '[?1049h');
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
return undefined;
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
writeQueue = writeQueue.slice(writeQueueHead);
writeQueueHead = 0;
}
return next;
}
function disposeTermObservers() {
var disposables = termObserverDisposables;
termObserverDisposables = [];
for (var i = 0; i < disposables.length; i++) {
try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
return;
}
writesDraining = true;
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
term.write(next, function() {
if (gen !== terminalGeneration) return;
writesDraining = false;
pumpWrites(gen);
});
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
`
@@ -0,0 +1,139 @@
import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected'
import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme'
export const TERMINAL_HTML_FRAGMENT_04 = `${TERMINAL_WEBGL_RECOVERY_JS}
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale;
// Why: a width-reflow re-stream rewraps the same content at new cols.
// Distance-from-bottom (rows) is the only stable anchor across reflow,
// since line counts and cell positions change. null = stay pinned to bottom.
var prevB = preserveScroll && term && term.buffer && term.buffer.active ? term.buffer.active : null;
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
terminalGeneration++;
var gen = terminalGeneration;
// Why: snapshot replay can contain old queries whose replies must never
// re-enter the live PTY. Each replacement terminal earns authority anew.
resetTerminalDataReplyAuthority();
cancelWebglContextRecovery();
webglAddon = null;
ready = false;
resetWriteQueue();
statusDotPendingSelector = false;
writesDraining = false;
afterDrainCallbacks = [];
initRows = rows || 24;
firstDataPending = true;
smoothScrollOffsetY = 0;
wheelAccumDeltaY = 0;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
var replayData = normalizeInitialData(initialData);
// Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the
// mirrored modes aligned with exactly what this mobile xterm replays.
updateMouseModeFromData(replayData);
activeAltScreenSnapshot = isAltScreenActive(replayData);
initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
var surfaceSwap = beginTerminalSurfaceSwap();
var nextSurface = surfaceSwap.nextSurface;
applyTerminalTheme(nextTheme);
term = new Terminal({
cols: cols || 80,
rows: rows || 24,
theme: terminalTheme,
minimumContrastRatio: terminalMinimumContrastRatio,
fontFamily: terminalFontFamily,
fontSize: fontPxForScale(currentTextScale),
fontWeight: '300',
fontWeightBold: '500',
scrollback: 5000,
// Why: xterm suppresses parser-generated query replies when disableStdin
// is true. Native accepts only validated reply grammars from onData.
disableStdin: false,
cursorBlink: ${MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink},
cursorStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle)},
// Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret.
showCursorImmediately: ${MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately},
// A full inactive cell remains visible under the terminal's phone-fit scale.
cursorInactiveStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle)},
convertEol: false,
allowProposedApi: true
});
var nextTerm = term;
pendingTerm = nextTerm;
term.open(surface);
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
if (typeof replayData === 'string' && replayData.length > 0) {
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
enqueueWrite(ESC + '[0m' + replayData);
}
// Why: reset eviction tracking + attach observers for the new term.
resetEvictionCounter();
cancelSelect();
attachTermObservers();
attachTerminalQueryReplyBridge(term, gen);
requestAnimationFrame(function() {
if (gen !== terminalGeneration) return;
ready = true;
everReady = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
commitTerminalSurfaceSwap(surfaceSwap, nextTerm);
// Why: restore the reader's place after the rewrapped buffer replays.
// Replay lands at bottom, so only act when they were scrolled up (rows>0).
if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) {
try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {}
}
captureInitialOscLinkTexts();
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = true;
applyFitScale('init-replay');
notify({ type: 'ready', cols: cols, rows: rows });
});
});
}
function write(data) {
updateMouseModeFromData(data);
enqueueWrite(data);
pumpWrites(terminalGeneration);
// Why: first live data chunk after init may widen the buffer past
// what the post-replay applyFitScale measured. Re-fit once after this
// chunk drains to catch the wider line. Subsequent chunks don't re-fit
// (the user's manual zoom is sticky after that).
if (firstDataPending) {
firstDataPending = false;
var gen = terminalGeneration;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
applyFitScale('first-data');
});
}
}
function resize(cols, rows) {
if (!term) return;
initRows = rows || initRows;
term.resize(cols || term.cols, rows || term.rows);
emitKeyboardAvoidanceMetrics();
applyFitScale('resize-msg');
notify({ type: 'ready', cols: cols, rows: rows });
}
// reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines).
`
@@ -0,0 +1,230 @@
import { TERMINAL_REFLOW_JS } from '../terminal-webview-reflow-injected'
export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS}
function notify(msg) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
}
function engineErrorText(err) {
if (!err) return '';
if (typeof err === 'string') return err;
if (err && typeof err.message === 'string') return err.message;
try { return String(err); } catch (e) { return ''; }
}
function chromeVersionText() {
var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\\/([0-9.]+)/);
return match ? 'Chrome ' + match[1] : 'Chrome version unknown';
}
var nonFatalErrorNotifies = 0;
function reportEngineError(context, err, fatal) {
var isFatal = fatal === undefined ? !everReady : !!fatal;
if (!isFatal) {
// Why: a constructed-but-degraded engine can throw per frame; cap
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
nonFatalErrorNotifies++;
if (nonFatalErrorNotifies > 5) return;
}
var parts = [context];
var errText = engineErrorText(err);
if (errText) parts.push(errText);
if (window.__engineErrors && window.__engineErrors.length) {
parts.push('captured: ' + window.__engineErrors.join(' | '));
}
parts.push(chromeVersionText());
notify({
type: 'error',
fatal: isFatal,
message: parts.join(' - ')
});
}
window.onerror = function(msg, source, line, column, err) {
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
reportEngineError('terminal runtime error', err || msg);
};
function measureFitDimensions(containerHeightPx, retriesLeft) {
if (typeof retriesLeft !== 'number') retriesLeft = 30;
// Why: init and measure are posted back-to-back from React, but
// init has an async rAF chain. A measure that runs synchronously
// after init can find term null, disposed, lacking element, or
// with cells size 0. Retry the whole gate for ~500ms.
var notReady = !term || !term.element;
var cellWidth = 0;
var cellHeight = 0;
if (!notReady) {
var core = term._core;
if (core && core._renderService && core._renderService.dimensions) {
cellWidth = core._renderService.dimensions.css.cell.width;
cellHeight = core._renderService.dimensions.css.cell.height;
}
}
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
requestAnimationFrame(function() {
measureFitDimensions(containerHeightPx, retriesLeft - 1);
});
return;
}
flog('measure-fail', {
notReady: notReady,
cellWidth: cellWidth,
cellHeight: cellHeight,
retriesLeft: retriesLeft
});
notify({ type: 'measure-result', cols: null, rows: null });
return;
}
var vpWidth = window.innerWidth;
// Why: prefer the container height passed from React Native over
// window.innerHeight. The RN layout system knows the exact pixel
// height of the terminal frame after the accessory/input bars are
// subtracted, whereas innerHeight can overstate the visible area
// due to layout timing or safe-area insets.
var vpHeight = (typeof containerHeightPx === 'number' && containerHeightPx > 0)
? containerHeightPx
: window.innerHeight;
var cols = Math.floor(vpWidth / cellWidth);
if (cols < MIN_FIT_COLS) {
flog('measure-skip-small-width', {
vpWidth: vpWidth,
cellWidth: cellWidth,
cols: cols
});
notify({ type: 'measure-result', cols: null, rows: null });
return;
}
// Why: the rows we report become the PTY's actual row count after the
// server fits to viewport, and xterm renders exactly that many lines
// anchored top-left of the WebView. Subtracting rows here would leave
// dead xterm-background space at the bottom of the container and make
// the last PTY rows visually appear above an "invisible line." Any
// safety margin between the prompt and the accessory bar must come
// from RN layout (terminalFrame's flex bounds), not from undersizing
// the PTY.
var rows = Math.max(8, Math.floor(vpHeight / cellHeight));
notify({ type: 'measure-result', cols: cols, rows: rows });
}
function handleMsg(msg) {
if (typeof msg.id === 'number') {
if (handledMessageIds.indexOf(msg.id) !== -1) return;
handledMessageIds.push(msg.id);
if (handledMessageIds.length > 256) handledMessageIds.shift();
}
if (msg.type === 'ping') {
notify({ type: 'pong', pingId: msg.id });
} else if (msg.type === 'init') {
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks);
} else if (msg.type === 'set-font-scale') {
// Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
// currentTextScale) so the post-pinch state isn't reset; only apply changes.
if (typeof msg.fontScale === 'number' && msg.fontScale > 0 && msg.fontScale !== currentTextScale) {
userScale = 1;
panX = 0;
panY = 0;
applyTextScale(msg.fontScale);
}
} else if (msg.type === 'resize') {
resize(msg.cols, msg.rows);
} else if (msg.type === 'reflow') { reflow(msg.cols, msg.rows);
} else if (msg.type === 'write') {
write(msg.data);
} else if (msg.type === 'clear') {
terminalGeneration++;
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
statusDotPendingSelector = false;
afterDrainCallbacks = [];
writesDraining = false;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
initialOscLinks = [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
if (term) { term.clear(); term.reset(); }
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
resetEvictionCounter();
if (selMode === 'select') {
notify({ type: 'selection-evicted' });
cancelSelect();
}
} else if (msg.type === 'measure') {
measureFitDimensions(msg.containerHeight);
} else if (msg.type === 'reset-zoom') {
applyFitScale('reset-zoom-msg');
} else if (msg.type === 'set-theme') {
applyTerminalTheme(msg.terminalTheme);
} else if (msg.type === 'cancel-select') {
if (selMode === 'select') cancelSelect();
} else if (msg.type === 'do-select-all') {
if (term) {
try {
term.selectAll();
var b = term.buffer.active;
if (selMode !== 'select') {
selMode = 'select';
selectionOverlay.classList.add('active');
notify({ type: 'set-select-mode', enabled: true });
}
sel = {
anchor: { col: 0, row: 0 },
focus: { col: term.cols - 1, row: b.length - 1 },
activeHandle: null
};
repositionOverlay();
} catch (e) {}
}
}
}
// ============================================================
// SELECTION MODE (long-press → handles → Copy)
// ============================================================
var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u;
var LONG_PRESS_MS = 500;
var LONG_PRESS_SLOP = 10;
// Why: a tap that opens a link/path must survive small finger jitter. The
// long-press slop (10px) only cancels the press-to-select timer; reusing it
// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale
// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap
// window so deliberate scrolls/pans still don't fire a tap.
var TAP_SLOP = 24;
var TAP_MAX_MS = 700;
var EDGE_SCROLL_PX = 40;
var EDGE_SCROLL_INTERVAL = 60;
var selectionOverlay = document.getElementById('selection-overlay');
var handleStart = document.getElementById('sel-handle-start');
var handleEnd = document.getElementById('sel-handle-end');
var selMenu = document.getElementById('sel-menu');
var btnCopy = document.getElementById('sel-menu-copy');
var btnSelAll = document.getElementById('sel-menu-all');
// mode: 'navigate' | 'select'
var selMode = 'navigate';
var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
var longPressTimer = null;
var longPressOrigin = null; // {x,y, identifier}
// Why: tap detection is tracked separately from the long-press timer so a
// small jitter that cancels the press-to-select timer does not also cancel
// the tap (which opens links/paths). {x,y,t,identifier} or null once the
// gesture is disqualified as a tap (moved too far or held too long).
var tapCandidate = null;
var edgeScrollTimer = null;
var edgeScrollDir = 0;
var edgeScrollClientX = 0;
var edgeScrollClientY = 0;
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
// Once buffer is full, every onLineFeed evicts the top row in xterm and
// we mirror that by decrementing stored absolute rows.
`
@@ -0,0 +1,204 @@
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected'
import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected'
export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0;
function resetEvictionCounter() { linesEverWritten = 0; }
function isBufferFull() {
if (!term) return false;
return linesEverWritten >= 5000 + (term.rows || 0);
}
function checkEviction() {
if (selMode !== 'select' || !sel) return;
var oldest = Math.min(sel.anchor.row, sel.focus.row);
if (oldest < 0) {
notify({ type: 'selection-evicted' });
cancelSelect();
}
}
function logFeedAndEvict() {
linesEverWritten++;
if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1;
if (selMode === 'select' && sel && isBufferFull()) {
sel.anchor.row -= 1;
sel.focus.row -= 1;
checkEviction();
repositionOverlay();
}
}
function emitModesIfChanged() {
if (!term) return;
var bp = !!(term.modes && term.modes.bracketedPasteMode);
var alt = false;
var mouseTrackingMode = getMouseTrackingMode();
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
if (
bp !== lastEmittedModes.bracketedPasteMode ||
alt !== lastEmittedModes.altScreen ||
mouseTrackingMode !== lastEmittedModes.mouseTrackingMode ||
sgrMouseMode !== lastEmittedModes.sgrMouseMode ||
sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode
) {
lastEmittedModes = {
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
};
notify({
type: 'modes',
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
});
}
}
var lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}
function attachTermObservers() {
if (!term) return;
disposeTermObservers();
try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {}
try {
termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); }));
} catch (e) {}
// Why: emit modes on every parsed write so RN's mirror stays current
// without round-trip; covers \\x1b[?2004h/l and alt-screen toggles.
try {
if (term.onWriteParsed) {
termObserverDisposables.push(term.onWriteParsed(function() {
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
}));
}
} catch (e) {}
// Initial emit once buffer settles.
afterWritesDrained(function() {
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
});
}
function viewportToCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW <= 0 || cellH <= 0) return null;
var total = getTotalScale();
if (total <= 0) total = 1;
var sx = (clientX - panX) / total;
var sy = (clientY - panY) / total;
var col = Math.floor(sx / cellW);
var viewportRow = Math.floor(sy / cellH);
if (col < 0) col = 0;
if (col > term.cols - 1) col = term.cols - 1;
if (viewportRow < 0) viewportRow = 0;
if (viewportRow > term.rows - 1) viewportRow = term.rows - 1;
var viewportY = term.buffer.active.viewportY;
return { col: col, row: viewportRow + viewportY };
}
${TERMINAL_MOUSE_REPORT_CELL_JS}
function isAlternateBufferActive() {
try {
return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate');
} catch (e) {
return false;
}
}
function getMouseTrackingMode() {
try {
if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') {
var mode = term.modes.mouseTrackingMode;
if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode;
return 'none';
}
} catch (e) {}
if (
trackedMouseTrackingMode === 'x10' ||
trackedMouseTrackingMode === 'vt200' ||
trackedMouseTrackingMode === 'drag' ||
trackedMouseTrackingMode === 'any'
) {
return trackedMouseTrackingMode;
}
return 'none';
}
function repeatSequence(sequence, count) {
var out = '';
for (var i = 0; i < count; i++) out += sequence;
return out;
}
function buildArrowScrollSequence(lines) {
var prefix = '[';
try {
if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O';
} catch (e) {}
return ESC + prefix + (lines < 0 ? 'A' : 'B');
}
function buildMouseWheelSequence(lines, clientX, clientY) {
var cell = viewportToMouseReportCell(clientX, clientY);
if (!cell) return '';
var eventCode = lines < 0 ? 64 : 65;
if (sgrMousePixelsMode) {
if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return '';
return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M';
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
var sgrCol = cell.col + 1;
var sgrRow = cell.row + 1;
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M';
}
// Why: xterm increments zero-based mouse cells before encoding reports.
var button = eventCode + 32;
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path. Fall back to keys for wide terminals.
if (button > 126 || col > 126 || row > 126) return '';
return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
}
function isSafeSgrMouseCoordinate(value) {
return Number.isInteger(value) && value >= 0 && value <= 9999;
}
function buildMouseClickInput(clientX, clientY) {
var mouseTrackingMode = getMouseTrackingMode();
if (!isClickMouseTrackingMode(mouseTrackingMode)) return '';
var cell = viewportToMouseReportCell(clientX, clientY);
if (!cell) return '';
if (sgrMousePixelsMode) {
// Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions.
var pixelX = cell.x;
var pixelY = cell.y;
if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return '';
var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M';
if (mouseTrackingMode === 'x10') return pixelPress;
return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm';
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
`
@@ -0,0 +1,188 @@
export const TERMINAL_HTML_FRAGMENT_07 = ` var sgrCol = cell.col + 1;
var sgrRow = cell.row + 1;
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M';
if (mouseTrackingMode === 'x10') return sgrPress;
return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm';
}
// Why: non-SGR click coordinates use printable ASCII bytes on the mobile
// bridge; unsafe wide-terminal cells must not turn into corrupted input.
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
if (col > 126 || row > 126) return '';
var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row);
if (mouseTrackingMode === 'x10') return press;
return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row);
}
function isClickMouseTrackingMode(mode) {
return mode !== 'none';
}
function isWheelMouseTrackingMode(mode) {
return mode !== 'none' && mode !== 'x10';
}
function shouldRouteScrollToTerminalInput() {
return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();
}
function buildMouseWheelScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var sequence = buildMouseWheelSequence(lines, clientX, clientY);
if (!sequence) return '';
return repeatSequence(sequence, count);
}
function buildTuiScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var mouseTrackingMode = getMouseTrackingMode();
var sequence = '';
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
sequence = buildMouseWheelSequence(lines, clientX, clientY);
}
if (!sequence) sequence = buildArrowScrollSequence(lines);
return repeatSequence(sequence, count);
}
function routeScrollLines(lines, clientX, clientY) {
if (!term || lines === 0) return;
var mouseTrackingMode = getMouseTrackingMode();
var alternateBufferActive = isAlternateBufferActive();
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY);
if (mouseInput) {
notify({ type: 'terminal-input', bytes: mouseInput });
return;
}
// Why: default mouse encoding can be unrepresentable in our ASCII-safe
// RPC path on wide terminals. Send bounded arrows instead of local
// scrollback/no-op while a mouse-aware app owns scroll gestures.
var fallbackInput = buildTuiScrollInput(lines, clientX, clientY);
if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput });
return;
}
if (alternateBufferActive) {
// Why: alternate-screen TUIs own their scroll state and xterm has no
// scrollback there, so mobile scroll gestures must become terminal input.
var input = buildTuiScrollInput(lines, clientX, clientY);
if (input) notify({ type: 'terminal-input', bytes: input });
return;
}
term.scrollLines(lines);
}
function clampNormalScrollLines(lines) {
if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0;
var buffer = term.buffer.active;
if (lines > 0) {
return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY));
}
return Math.max(lines, -buffer.viewportY);
}
function canScrollNormalBufferDelta(deltaY) {
if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false;
var buffer = term.buffer.active;
if (deltaY > 0) return buffer.viewportY < buffer.baseY;
return buffer.viewportY > 0;
}
function applyNormalBufferScrollDelta(deltaY) {
if (!term || deltaY === 0) return false;
var effectiveCellH = getCellHeight() * getTotalScale();
if (effectiveCellH <= 0) return false;
if (!canScrollNormalBufferDelta(deltaY)) {
resetSmoothScrollOffset();
return false;
}
smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
if (lines !== 0) {
var applied = clampNormalScrollLines(lines);
if (applied !== 0) {
term.scrollLines(applied);
// Why: xterm's renderer is row-based. Buffer touch pixels and only
// commit whole rows so TUI canvas layers do not shimmer between
// fractional transforms and xterm repaints.
smoothScrollOffsetY += applied * effectiveCellH;
}
if (applied !== lines) smoothScrollOffsetY = 0;
}
var limit = effectiveCellH - 1;
if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit;
if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit;
updateScrollIndicator(true);
return true;
}
function enqueueNormalBufferScrollDelta(deltaY) {
if (!term || deltaY === 0) return false;
if (!canScrollNormalBufferDelta(deltaY)) {
resetSmoothScrollOffset();
return false;
}
pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true;
// Why: dense terminal rows are expensive to repaint. Coalesce touchmove
// deltas into one xterm row-scroll per frame instead of repainting from
// the input event stream.
normalScrollFrameId = requestAnimationFrame(function() {
normalScrollFrameId = null;
var delta = pendingNormalScrollDeltaY;
pendingNormalScrollDeltaY = 0;
if (!applyNormalBufferScrollDelta(delta)) {
resetSmoothScrollOffset();
}
});
return true;
}
function resetSmoothScrollOffset() {
pendingNormalScrollDeltaY = 0;
if (normalScrollFrameId !== null) {
cancelAnimationFrame(normalScrollFrameId);
normalScrollFrameId = null;
}
if (smoothScrollOffsetY === 0) return;
smoothScrollOffsetY = 0;
updateScrollIndicator(false);
}
function cellToViewportPx(col, absRow) {
if (!term) return { x: 0, y: 0 };
var cellW = getCellWidth();
var cellH = getCellHeight();
var viewportRow = absRow - term.buffer.active.viewportY;
var sx = col * cellW;
var sy = viewportRow * cellH;
var total = getTotalScale();
return { x: sx * total + panX, y: sy * total + panY };
}
function getLineText(absRow) {
if (!term) return '';
var line = term.buffer.active.getLine(absRow);
if (!line) return '';
return line.translateToString(false);
}
// Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a
// tap's CELL column no longer equals the STRING index that url/path matchers use.
// Convert by measuring the string length up to the tapped cell (the count of
// string chars before it). Without this, taps on lines with a leading wide char
// (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss.
function cellColToStringIndex(absRow, col) {
if (!term) return col;
var line = term.buffer.active.getLine(absRow);
if (!line) return col;
return line.translateToString(false, 0, col).length;
}
// File-path-under-tap detection (matchFilePathAtColumn). See
// terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts.
`
@@ -0,0 +1,194 @@
import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected'
import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap'
export const TERMINAL_HTML_FRAGMENT_08 = ` ${TERMINAL_PATH_TAP_JS}
${URL_TAP_WEBVIEW_JS}
function seedWordSelection(col, absRow) {
var line = getLineText(absRow);
if (!line) {
sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null };
applyXtermSelection();
return;
}
var s = col;
var e = col;
if (col >= 0 && col < line.length && WORD_RE.test(line[col])) {
while (s > 0 && WORD_RE.test(line[s - 1])) s--;
while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++;
}
sel = {
anchor: { col: s, row: absRow },
focus: { col: e, row: absRow },
activeHandle: null
};
applyXtermSelection();
}
function isStartFirst(a, b) {
if (a.row !== b.row) return a.row < b.row;
return a.col <= b.col;
}
function selRange() {
if (!sel) return null;
if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus };
return { start: sel.focus, end: sel.anchor };
}
function applyXtermSelection() {
if (!term || !sel) return;
var r = selRange();
if (!r) return;
// Why: term.select(col, row, length) takes a buffer-absolute row,
// not a viewport-relative one. Subtracting viewportY here drifts the
// selection by the scrollback height — handles render where the user
// pressed (their math is independent), but xterm highlights an
// off-screen scrollback region and copies the wrong text.
var length;
if (r.start.row === r.end.row) {
length = Math.max(1, r.end.col - r.start.col + 1);
} else {
var first = term.cols - r.start.col;
var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols;
var last = r.end.col + 1;
length = first + middle + last;
}
try { term.select(r.start.col, r.start.row, length); } catch (e) {}
}
function cancelSelect() {
selMode = 'navigate';
sel = null;
stopEdgeScroll();
if (term) {
try { term.clearSelection(); } catch (e) {}
// Why: some xterm renderers cache cells and skip repaint on
// clearSelection alone, leaving the previously-highlighted cells
// visually selected. Force a full refresh so the selection layer
// actually clears on screen.
try { term.refresh(0, term.rows - 1); } catch (e) {}
}
selectionOverlay.classList.remove('active');
notify({ type: 'set-select-mode', enabled: false });
}
function enterSelect(col, absRow) {
selMode = 'select';
seedWordSelection(col, absRow);
selectionOverlay.classList.add('active');
notify({ type: 'set-select-mode', enabled: true });
notify({ type: 'haptic', kind: 'selection' });
repositionOverlay();
}
function repositionOverlay() {
if (selMode !== 'select' || !sel || !term) return;
var r = selRange();
var sPx = cellToViewportPx(r.start.col, r.start.row);
var ePx = cellToViewportPx(r.end.col + 1, r.end.row);
var cellH = getCellHeight() * getTotalScale();
// Why: native iOS pattern — start handle anchors at the TOP of the
// first selected cell (dot above, stem covers the cell going down);
// end handle anchors at the BOTTOM of the last selected cell (dot
// below, stem covers the cell going up).
handleStart.style.left = sPx.x + 'px';
handleStart.style.top = sPx.y + 'px';
handleEnd.style.left = ePx.x + 'px';
handleEnd.style.top = (ePx.y + cellH) + 'px';
var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight;
var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight;
handleStart.style.visibility = startVisible ? 'visible' : 'hidden';
handleEnd.style.visibility = endVisible ? 'visible' : 'hidden';
var menuCenterX, menuY, vTransform, marginTop;
if (startVisible && sPx.y > 56) {
menuCenterX = sPx.x; menuY = sPx.y;
vTransform = 'translateY(-100%)';
marginTop = '-12px';
} else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) {
menuCenterX = ePx.x; menuY = ePx.y + cellH;
vTransform = 'translateY(0)';
marginTop = '12px';
} else {
// selection covers full viewport — pin to visible center
menuCenterX = window.innerWidth / 2;
menuY = window.innerHeight / 2;
vTransform = 'translateY(-50%)';
marginTop = '0';
}
// Why: clamp horizontally so the pill stays fully visible when the
// selection sits near a screen edge. We position via plain left
// (no horizontal translate) so the clamp math is straightforward.
selMenu.style.transform = vTransform;
selMenu.style.marginTop = marginTop;
selMenu.style.top = menuY + 'px';
selMenu.style.left = '0px';
var EDGE_MARGIN = 8;
var menuW = selMenu.offsetWidth || 0;
var minLeft = EDGE_MARGIN;
var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN);
var desiredLeft = menuCenterX - menuW / 2;
var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft));
selMenu.style.left = clampedLeft + 'px';
}
function syncSelectionHandleToViewportPoint(handle, clientX, clientY) {
var c = viewportToCell(clientX, clientY);
if (!c || !sel) return false;
if (handle === 'start') sel.anchor = c;
else sel.focus = c;
applyXtermSelection();
return true;
}
function syncEdgeScrollSelectionEndpoint() {
if (!sel || !sel.activeHandle) return false;
// Why: WebView may not emit new touchmove events while a handle is held
// at the edge; resample the stored finger point after each viewport scroll.
return syncSelectionHandleToViewportPoint(
sel.activeHandle,
edgeScrollClientX,
edgeScrollClientY
);
}
function startEdgeScroll(dir) {
if (edgeScrollDir === dir) return;
stopEdgeScroll();
edgeScrollDir = dir;
edgeScrollTimer = setInterval(function() {
if (!term || edgeScrollDir === 0) return;
var beforeY = term.buffer.active.viewportY;
term.scrollLines(edgeScrollDir);
var afterY = term.buffer.active.viewportY;
if (beforeY === afterY) {
notify({ type: 'haptic', kind: 'edge-bump' });
stopEdgeScroll();
return;
}
syncEdgeScrollSelectionEndpoint();
repositionOverlay();
}, EDGE_SCROLL_INTERVAL);
}
function stopEdgeScroll() {
if (edgeScrollTimer) {
clearInterval(edgeScrollTimer);
edgeScrollTimer = null;
}
edgeScrollDir = 0;
}
function handleDragMove(handle, clientX, clientY) {
edgeScrollClientX = clientX;
edgeScrollClientY = clientY;
if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return;
repositionOverlay();
if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1);
else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1);
else stopEdgeScroll();
}
// Latching document-level touch dispatcher: see
// terminal-webview-tap-dispatch-injected.ts (extracted for max-lines).
`
@@ -0,0 +1,138 @@
import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injected'
import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected'
import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected'
export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS}
// External mouse / trackpad scroll: see
// terminal-webview-wheel-scroll-injected.ts (extracted for max-lines).
${TERMINAL_WHEEL_SCROLL_JS}
// External mouse click/drag: see
// terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines).
${TERMINAL_MOUSE_CLICK_DRAG_JS}
btnCopy.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (!term) return;
var text = term.getSelection ? term.getSelection() : '';
if (text && text.length > 0) {
notify({ type: 'selection', text: text });
} else {
cancelSelect();
}
});
btnSelAll.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (!term) return;
try {
term.selectAll();
var b = term.buffer.active;
sel = {
anchor: { col: 0, row: 0 },
focus: { col: term.cols - 1, row: b.length - 1 },
activeHandle: null
};
repositionOverlay();
} catch (err) {}
});
var ts = {
lastX: 0, lastY: 0, lastTime: 0, velY: 0,
accumDelta: 0, momentumId: null, isPinching: false,
pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0
};
function updateTouchVelocity(deltaY, dt) {
if (dt <= 0) return;
var instantVelocity = deltaY / dt;
if (!isFinite(instantVelocity)) return;
// Why: touchmove cadence is uneven in WebView. Blend recent samples so
// momentum launch doesn't inherit a one-frame spike or stall.
ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45;
}
function getDistance(a, b) {
var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function attachSurfaceEventHandlers(targetSurface) {
if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return;
targetSurface.__orcaSurfaceHandlersAttached = true;
// Why: init() swaps in a new hidden surface to avoid flicker; each
// replacement needs gesture handlers or tab-switch replays stop scrolling.
targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
attachSurfaceWheelHandler(targetSurface);
attachSurfaceMouseClickDragHandler(targetSurface);
targetSurface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId);
ts.momentumId = null;
}
if (e.touches.length === 2) {
ts.isPinching = true;
smoothScrollOffsetY = 0;
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
ts.pinchScale = userScale;
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var total = getTotalScale();
ts.pinchSurfX = (mx - panX) / total;
ts.pinchSurfY = (my - panY) / total;
} else if (e.touches.length === 1) {
ts.isPinching = false;
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
}, { capture: true, passive: true });
targetSurface.addEventListener('touchmove', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 2) {
ts.isPinching = true;
var dist = getDistance(e.touches[0], e.touches[1]);
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var ratio = dist / ts.pinchDist;
// Why: userScale is a CSS multiplier on the current font size; bound it so
// the resulting apparent size (currentTextScale × userScale) stays within
// the preset range, since release snaps to one of those presets.
var loScale = MIN_TEXT_SCALE / currentTextScale;
var hiScale = MAX_TEXT_SCALE / currentTextScale;
userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio));
var total = getTotalScale();
panX = mx - ts.pinchSurfX * total;
panY = my - ts.pinchSurfY * total;
clampPan();
updateTransform();
} else if (e.touches.length === 1 && !ts.isPinching) {
var x = e.touches[0].clientX, y = e.touches[0].clientY;
var now = Date.now(), dt = now - ts.lastTime;
// Why: pan horizontally only when content overflows the viewport (larger
// than fit) — same check clampPan() uses. Vertical always drives buffer
// scroll so scrollback stays reachable at any text size; calling the
// never-defined contentWiderThanViewport() here threw and killed all
// single-finger scrolling, scrollback included.
if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) {
panX += x - ts.lastX;
clampPan();
`
@@ -0,0 +1,132 @@
export const TERMINAL_HTML_FRAGMENT_10 = ` updateTransform();
}
var deltaY = ts.lastY - y;
ts.lastTime = now;
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt);
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt);
} else {
ts.velY = 0;
}
}
ts.lastX = x;
ts.lastY = y;
}
}, { capture: true, passive: false });
targetSurface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
// Why: a finished pinch snaps to the nearest preset and becomes the new
// font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
// to set the text size. The CSS pinch zoom (userScale) is reset; the real
// size change reflows columns and RN persists + resizes the PTY to match.
var target = snapToTextScalePreset(currentTextScale * userScale);
var changed = target !== currentTextScale;
userScale = 1;
panX = 0; panY = 0;
applyTextScale(target);
updateTransform();
notify({ type: 'font-scale-changed', fontScale: target });
if (changed) notify({ type: 'haptic', kind: 'selection' });
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
return;
}
if (e.touches.length === 0) {
var vel = ts.velY;
var FRICTION = 0.972;
var MIN_VEL = 0.012;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var delta = vel * 16;
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += delta;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null;
return;
}
}
ts.momentumId = requestAnimationFrame(momentumStep);
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
}
}
}, { capture: true, passive: true });
}
attachSurfaceEventHandlers(surface);
function handleIncomingMessage(e) {
var msg;
try {
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
} catch (ex) {
return;
}
try {
handleMsg(msg);
} catch(ex) {
reportEngineError(
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
ex,
msg && msg.type === 'init' && !everReady
);
}
}
window.addEventListener('message', handleIncomingMessage);
document.addEventListener('message', handleIncomingMessage);
window.addEventListener('resize', function() {
// Why: viewport changed (keyboard open/close, orientation, RN container
// size update). Re-fit so the scale matches the new vpWidth — without
// this, opening the keyboard leaves the terminal at the old scale even
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize');
adjustRowsForViewport();
repositionOverlay();
clampPan();
updateTransform();
});
if (window.Terminal) {
notify({ type: 'web-ready' });
} else {
reportEngineError('terminal engine missing', 'xterm failed to load', true);
}
})();
</script>
</body>
</html>`
@@ -0,0 +1,34 @@
import type { RuntimeMobileTerminalTheme } from '../../../../src/shared/runtime-types'
import { colors } from '../../theme/mobile-theme'
export 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'
}
export const MOBILE_TERMINAL_CARET_OPTIONS = {
cursorBlink: false,
cursorStyle: 'bar',
showCursorImmediately: true,
cursorInactiveStyle: 'block'
} as const
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
// The reflow logic lives as injected in-WebView JS; the message dispatch and
// handle wiring live in terminal-webview-html.ts / TerminalWebView.tsx. Assert
@@ -9,7 +10,8 @@ const reflowSource = readFileSync(
new URL('./terminal-webview-reflow-injected.ts', import.meta.url),
'utf8'
)
const htmlSource = readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8')
// Use the assembled document so the test covers the fragments that run in the WebView.
const htmlSource = readTerminalWebViewHtmlSource()
const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
function reflowFnBody(): string {
@@ -8,9 +8,18 @@ const source =
readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8')
readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') +
Array.from({ length: 10 }, (_, index) =>
readFileSync(
new URL(
`./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`,
import.meta.url
),
'utf8'
)
).join('')
const sessionSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url),
'utf8'
)
const sessionHelperSource = readFileSync(
@@ -1,15 +1,23 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
const terminalWebViewSource = readFileSync(
new URL('./TerminalWebView.tsx', import.meta.url),
'utf8'
)
const terminalHtmlSource = readFileSync(
const terminalHtmlModuleSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
const terminalHtmlFragmentSource = readFileSync(
new URL('./terminal-webview-html/fragment-01.ts', import.meta.url),
'utf8'
)
// Read behavior from the assembled document; the module source only contains
// fragment imports and cannot prove the injected code is present.
const terminalHtmlSource = readTerminalWebViewHtmlSource()
const terminalWebglRecoverySource = readFileSync(
new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url),
'utf8'
@@ -75,7 +83,9 @@ describe('TerminalWebView text zoom', () => {
const end = terminalWebViewSource.indexOf('/>', start)
expect(end).toBeGreaterThan(start)
const webViewProps = terminalWebViewSource.slice(start, end)
expect(terminalHtmlSource).toContain('export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
expect(terminalHtmlModuleSource).toContain(
'export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }'
)
expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}')
expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}')
})
@@ -140,7 +150,7 @@ describe('TerminalWebView text zoom', () => {
})
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
expect(terminalHtmlSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlFragmentSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ import type {
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types'
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { AgentSessionClaimSigner } from './agent-session-claim-identity'
import type { OrchestrationEnvironmentTransport } from './orchestration/environment-transport'
import type { RuntimeCommandSurfaceHost } from './orca-runtime-core'
@@ -34,7 +35,48 @@ import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-id
import { registerConptyDa1OverrideInstaller } from './terminal-model-query-authority'
import { registerTerminalViewAttributesApplier } from './terminal-view-attribute-store'
export type RuntimeNativeChatTranscriptBinding = {
worktreeId: string
connectionId: string | null
agent: string | null
providerSession: AgentProviderSessionMetadata | null
}
export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands {
resolveNativeChatTranscriptBinding(handle: string): RuntimeNativeChatTranscriptBinding | null {
const terminalContext = this.resolveTerminalContext(handle)
const snapshot = terminalContext
? this.mobileSessionTabsByWorktree.get(terminalContext.worktreeId)
: null
if (!terminalContext || !snapshot) {
return null
}
const terminal = this.toMobileSessionTabsResult(snapshot).tabs.find(
(tab) => tab.type === 'terminal' && tab.status === 'ready' && tab.terminal === handle
)
if (!terminal || terminal.type !== 'terminal') {
return null
}
return {
...terminalContext,
agent: terminal.agentStatus?.agentType ?? terminal.launchAgent ?? null,
providerSession: terminal.agentStatus?.providerSession ?? null
}
}
notifySshRelayUnavailable(targetId: string): void {
this.bumpSshRelayRecoveryGeneration(targetId)
this.invalidateSshWorktreeScanCache(targetId)
const worktreeIds = new Set(
[...this.ptysById.values()]
.filter((pty) => pty.connectionId === targetId)
.map((pty) => pty.worktreeId)
)
for (const worktreeId of worktreeIds) {
this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence)
}
}
constructor(
store: RuntimeStore | null = null,
stats?: StatsCollector,
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ type RuntimeFileCommandName =
| 'readMobileFile'
| 'resolveTerminalPath'
| 'readTerminalArtifactFile'
| 'readTerminalArtifactChunk'
| 'readTerminalArtifactPreview'
| 'writeTerminalArtifactFile'
| 'revokeTerminalFileGrantsForClient'
@@ -47,6 +48,7 @@ export function installRuntimeFileCommandSurface(
readMobileFile: commands.readMobileFile.bind(commands),
resolveTerminalPath: commands.resolveTerminalPath.bind(commands),
readTerminalArtifactFile: commands.readTerminalArtifactFile.bind(commands),
readTerminalArtifactChunk: commands.readTerminalArtifactChunk.bind(commands),
readTerminalArtifactPreview: commands.readTerminalArtifactPreview.bind(commands),
writeTerminalArtifactFile: commands.writeTerminalArtifactFile.bind(commands),
revokeTerminalFileGrantsForClient: commands.revokeTerminalFileGrantsForClient.bind(commands),
@@ -4,6 +4,7 @@ import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list
import {
LOCAL_PREVIEWABLE_BINARY_MAX_BYTES,
MOBILE_FILE_READ_MAX_BYTES,
RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES,
previewableBinaryByteLimit
} from './runtime-file-commands-mobile-file-list-limit'
import { isBinaryBuffer, isMobileBinaryPath } from './runtime-file-command-host'
@@ -17,9 +18,86 @@ import { chmod, constants, rename, rm, writeFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { RuntimeFilePreviewResult } from '../../shared/runtime-types'
import type {
RuntimeFilePreviewResult,
RuntimeFileReadChunkResult
} from '../../shared/runtime-types'
export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient {
async readTerminalArtifactChunk(
worktreeSelector: string,
grantId: string,
absolutePath: string,
offset: number,
length: number,
maxBytes: number,
clientId?: string
): Promise<RuntimeFileReadChunkResult> {
if (
!Number.isSafeInteger(offset) ||
offset < 0 ||
!Number.isSafeInteger(length) ||
length < 1 ||
length > 512 * 1024 ||
!Number.isSafeInteger(maxBytes) ||
maxBytes < 1 ||
maxBytes > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES
) {
throw new Error('invalid_terminal_artifact_chunk')
}
const { grant } = await this.requireTerminalFileGrant(
worktreeSelector,
grantId,
absolutePath,
clientId
)
let result: RuntimeFileReadChunkResult
if (grant.connectionId) {
const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant)
if (!provider.readTerminalArtifactChunk) {
throw new Error('terminal_file_grant_unavailable')
}
result = await provider.readTerminalArtifactChunk(
grant.absolutePath,
offset,
length,
this.terminalArtifactAccessOptions(grant, maxBytes)
)
} else {
const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY)
try {
const fileStats = await handle.stat()
if (fileStats.isDirectory()) {
throw new Error('Cannot read a directory')
}
if (fileStats.size > maxBytes) {
throw new Error('file_too_large')
}
assertTerminalFileGrantFresh(grant, fileStats)
const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset)))
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset)
result = {
contentBase64: buffer.subarray(0, bytesRead).toString('base64'),
bytesRead,
eof: offset + bytesRead >= fileStats.size
}
} finally {
await handle.close()
}
}
const decoded = Buffer.from(result.contentBase64, 'base64')
if (
decoded.toString('base64') !== result.contentBase64 ||
result.bytesRead !== decoded.byteLength ||
result.bytesRead > length ||
(result.bytesRead === 0 && !result.eof)
) {
throw new Error('invalid_terminal_artifact_chunk')
}
this.refreshTerminalFileGrant(grant)
return result
}
async writeTerminalArtifactFile(
worktreeSelector: string,
grantId: string,
@@ -13,6 +13,10 @@ import type {
PRRefreshOutcome
} from '../../shared/github/pull-request-refresh-types'
import type { Repo } from '../../shared/repo-types'
import type {
HostedReviewSubmissionInput,
HostedReviewSubmissionResult
} from '../../shared/hosted-review-submission'
import {
getPRForBranchOutcome,
getRepoSlug,
@@ -28,6 +32,7 @@ import {
} from '../source-control/hosted-review-creation'
import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options'
import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation'
import { submitHostedReview } from '../source-control/hosted-review-submission'
type HostedReviewTargetArgs = { repoSelector: string; worktreeSelector?: string }
@@ -176,6 +181,20 @@ export class RuntimeHostedReviewCommands {
return result
}
async submitHostedReview(
args: HostedReviewSubmissionInput & { repoSelector: string }
): Promise<HostedReviewSubmissionResult> {
const repo = await this.deps.resolveRepo(args.repoSelector)
const { repoSelector: _repoSelector, ...input } = args
return submitHostedReview(
repo.path,
input,
repo.issueSourcePreference,
repo.connectionId ?? null,
this.deps.getExecutionOptions(repo)
)
}
async createStackedHostedReview(
args: CreateStackedHostedReviewInput & HostedReviewTargetArgs
): Promise<CreateStackedHostedReviewResult> {
@@ -19,6 +19,7 @@ type HostedReviewCommandName =
| 'getHostedReviewForBranch'
| 'getHostedReviewCreationEligibility'
| 'createHostedReview'
| 'submitHostedReview'
| 'createStackedHostedReview'
type GitHubRepositoryQueryCommandName =
| 'listRepoWorkItems'
@@ -146,6 +147,7 @@ export function installRuntimeRepositoryCommandSurface(
getHostedReviewForBranch: reviews.getHostedReviewForBranch.bind(reviews),
getHostedReviewCreationEligibility: reviews.getHostedReviewCreationEligibility.bind(reviews),
createHostedReview: reviews.createHostedReview.bind(reviews),
submitHostedReview: reviews.submitHostedReview.bind(reviews),
createStackedHostedReview: reviews.createStackedHostedReview.bind(reviews),
listRepoWorkItems: queries.listRepoWorkItems.bind(queries),
listRepoIssues: queries.listRepoIssues.bind(queries),