mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
refactor(runtime): restore split command surfaces
This commit is contained in:
@@ -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,
|
||||
|
||||
+59
-44543
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),
|
||||
|
||||
Reference in New Issue
Block a user