mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
refactor(mobile): extract the terminal init-and-write slice into a module
The slice opens with the already-extracted webgl-recovery group, so its text is two emissions joined. init() resets almost every field the document shares, which makes this the densest qualifier site in the script. The caret options were interpolated from the theme module, so they join `document-constants.ts` as four exports: a substitution is keyed by name, not by property path. One local the document declares and never reads keeps a line-scoped `no-unused-vars` disable. Removing it would be a different program, which is the one thing this branch does not do. Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18, unbound catches 7, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import { MOBILE_TERMINAL_CARET_OPTIONS } from '../terminal-webview-html/theme'
|
||||
import {
|
||||
TERMINAL_FILE_URL_REGEX_SOURCE,
|
||||
TERMINAL_HTTP_URL_MAX_LENGTH,
|
||||
@@ -27,3 +28,9 @@ export const terminalFileUrlRegexSource = TERMINAL_FILE_URL_REGEX_SOURCE
|
||||
|
||||
/** The longest candidate a tap will open, matching desktop. */
|
||||
export const terminalHttpUrlMaxLength = TERMINAL_HTTP_URL_MAX_LENGTH
|
||||
|
||||
/** The caret options, one export each because a substitution is keyed by name. */
|
||||
export const terminalCursorBlink = MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink
|
||||
export const terminalCursorStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle
|
||||
export const terminalShowCursorImmediately = MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately
|
||||
export const terminalCursorInactiveStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle
|
||||
|
||||
@@ -83,3 +83,6 @@ export declare function updateTransform(): void
|
||||
|
||||
/** `runtime-state-and-text-scaling`: the scale that fits the grid to the viewport. */
|
||||
export declare function computeFitScale(): number
|
||||
|
||||
/** `runtime-state-and-text-scaling`: the font size in pixels for a text-scale preset. */
|
||||
export declare function fontPxForScale(scale: number): number
|
||||
|
||||
@@ -85,6 +85,16 @@ export type TerminalDocumentTerminal = {
|
||||
readonly buffer: { readonly active: TerminalDocumentBuffer }
|
||||
readonly options: TerminalDocumentTerminalOptions
|
||||
write: (data: string, callback?: () => void) => void
|
||||
open: (element: HTMLElement) => void
|
||||
scrollToLine: (line: number) => void
|
||||
readonly unicode: { activeVersion: string }
|
||||
attachCustomKeyEventHandler: (handler: () => boolean) => void
|
||||
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
|
||||
readonly textarea?: {
|
||||
readOnly: boolean
|
||||
tabIndex: number
|
||||
setAttribute: (name: string, value: string) => void
|
||||
}
|
||||
readonly element?: HTMLElement
|
||||
readonly _core?: TerminalDocumentCore
|
||||
readonly modes?: { bracketedPasteMode?: boolean }
|
||||
@@ -145,6 +155,14 @@ export type TerminalDocumentScope = {
|
||||
sgrMouseMode: boolean
|
||||
/** `runtime-state`: whether the TUI asked for SGR pixel (1016) mouse reports. */
|
||||
sgrMousePixelsMode: boolean
|
||||
/** `runtime-state`: the text scale the user picked, as a preset index. */
|
||||
currentTextScale: number
|
||||
/** `runtime-state`: the font stack xterm renders with. */
|
||||
terminalFontFamily: string
|
||||
/** `terminal-init-and-write`: whether the first live chunk since init is still pending. */
|
||||
firstDataPending: boolean
|
||||
/** `terminal-init-and-write`: whether the replayed snapshot was an alternate screen. */
|
||||
activeAltScreenSnapshot: boolean
|
||||
/** `terminal-fit-scale`: the fit scale the document committed. */
|
||||
currentScale: number
|
||||
/** `runtime-state`: the pinch zoom the user applied on top of the fit scale. */
|
||||
@@ -304,6 +322,10 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
|
||||
trackedMouseTrackingMode: 'none',
|
||||
sgrMouseMode: false,
|
||||
sgrMousePixelsMode: false,
|
||||
currentTextScale: 1,
|
||||
terminalFontFamily: '',
|
||||
firstDataPending: true,
|
||||
activeAltScreenSnapshot: false,
|
||||
currentScale: 1,
|
||||
userScale: 1,
|
||||
CLAUDE_STATUS_DOT: statusDot,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { scope, type TerminalDocumentTerminal } from './document-scope'
|
||||
/** The surfaces and terminal a swap is replacing, handed back to whoever commits it. */
|
||||
export type TerminalSurfaceSwap = {
|
||||
oldTerm: TerminalDocumentTerminal | null
|
||||
oldSurface: HTMLElement
|
||||
oldSurface: HTMLElement | null
|
||||
nextSurface: HTMLElement
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function commitTerminalSurfaceSwap(
|
||||
swap.nextSurface.style.position = ''
|
||||
swap.nextSurface.style.left = ''
|
||||
swap.nextSurface.style.top = ''
|
||||
swap.oldSurface.remove()
|
||||
swap.oldSurface!.remove()
|
||||
if (swap.oldTerm) {
|
||||
swap.oldTerm.dispose()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
|
||||
import { TERMINAL_HTML_INIT_AND_WRITE } from '../terminal-webview-html/terminal-init-and-write'
|
||||
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
|
||||
|
||||
/** The slice opens with the already-extracted webgl-recovery group. */
|
||||
const modulePaths = ['./webgl-recovery.ts', './terminal-init.ts'].map((relative) =>
|
||||
fileURLToPath(new URL(relative, import.meta.url))
|
||||
)
|
||||
|
||||
describe('the terminal init-and-write slice', () => {
|
||||
it('emits the script the document carries, modulo the six normalisations', async () => {
|
||||
const emitted = (await Promise.all(modulePaths.map(emitTerminalDocumentModule))).join('\n')
|
||||
expect(compareTerminalDocumentScripts(TERMINAL_HTML_INIT_AND_WRITE, emitted, 'scope')).toEqual({
|
||||
equivalent: true,
|
||||
normalisations: {
|
||||
// init() resets almost every field the document shares, so this is the densest
|
||||
// qualifier site in the script.
|
||||
qualifiedReferences: 83,
|
||||
scopeFieldDeclarations: 0,
|
||||
rebindings: 11,
|
||||
bracedBodies: 18,
|
||||
unboundCatches: 7,
|
||||
numberProperties: 0,
|
||||
shorthandProperties: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
|
||||
import {
|
||||
terminalCursorBlink,
|
||||
terminalCursorInactiveStyle,
|
||||
terminalCursorStyle,
|
||||
terminalShowCursorImmediately
|
||||
} from './document-constants'
|
||||
import { fontPxForScale, notify } from './document-externals'
|
||||
import {
|
||||
scope,
|
||||
type TerminalDocumentTerminal,
|
||||
type TerminalDocumentWebglAddon
|
||||
} from './document-scope'
|
||||
import { applyFitScale } from './fit-scale'
|
||||
import {
|
||||
isAltScreenActive,
|
||||
normalizeInitialData,
|
||||
updateMouseModeFromData
|
||||
} from './mouse-mode-decset-scan'
|
||||
import { captureInitialOscLinkTexts } from './osc-link-tap'
|
||||
import { attachTerminalQueryReplyBridge, resetTerminalDataReplyAuthority } from './query-reply'
|
||||
import { cancelSelect } from './document-externals'
|
||||
import { resetEvictionCounter } from './selection-state-and-eviction'
|
||||
import { beginTerminalSurfaceSwap, commitTerminalSurfaceSwap } from './surface-swap'
|
||||
import { attachTermObservers } from './term-observers'
|
||||
import { applyTerminalTheme } from './terminal-theme'
|
||||
import { attachWebglAddon, cancelWebglContextRecovery } from './webgl-recovery'
|
||||
import { afterWritesDrained, enqueueWrite, pumpWrites, resetWriteQueue } from './write-queue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Unicode11Addon?: { Unicode11Addon: new () => TerminalDocumentWebglAddon }
|
||||
}
|
||||
const Terminal: new (options: Record<string, unknown>) => TerminalDocumentTerminal
|
||||
}
|
||||
|
||||
export function init(
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialData: unknown,
|
||||
nextTheme: Parameters<typeof applyTerminalTheme>[0],
|
||||
nextFontScale: unknown,
|
||||
preserveScroll: boolean,
|
||||
nextOscLinks: unknown
|
||||
) {
|
||||
if (typeof nextFontScale === 'number' && nextFontScale > 0) {
|
||||
scope.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.
|
||||
const prevB =
|
||||
preserveScroll && scope.term && scope.term.buffer && scope.term.buffer.active
|
||||
? scope.term.buffer.active
|
||||
: null
|
||||
const scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1
|
||||
scope.terminalGeneration++
|
||||
const gen = scope.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()
|
||||
scope.webglAddon = null
|
||||
scope.ready = false
|
||||
resetWriteQueue()
|
||||
scope.statusDotPendingSelector = false
|
||||
scope.writesDraining = false
|
||||
scope.afterDrainCallbacks = []
|
||||
scope.initRows = rows || 24
|
||||
scope.firstDataPending = true
|
||||
scope.smoothScrollOffsetY = 0
|
||||
scope.wheelAccumDeltaY = 0
|
||||
scope.mouseModeScanTail = ''
|
||||
scope.trackedMouseTrackingMode = 'none'
|
||||
scope.sgrMouseMode = false
|
||||
scope.sgrMousePixelsMode = false
|
||||
scope.lastEmittedModes = {
|
||||
bracketedPasteMode: false,
|
||||
altScreen: false,
|
||||
mouseTrackingMode: 'none',
|
||||
sgrMouseMode: false,
|
||||
sgrMousePixelsMode: false
|
||||
}
|
||||
const 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)
|
||||
scope.activeAltScreenSnapshot = isAltScreenActive(replayData)
|
||||
scope.initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []
|
||||
scope.initialOscLinkRowOffset = 0
|
||||
scope.initialOscLinkEvictionReady = false
|
||||
const surfaceSwap = beginTerminalSurfaceSwap()
|
||||
// oxlint-disable-next-line no-unused-vars -- the document declares it here; removing it is a different program.
|
||||
const nextSurface = surfaceSwap.nextSurface
|
||||
|
||||
applyTerminalTheme(nextTheme)
|
||||
scope.term = new Terminal({
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
theme: scope.terminalTheme,
|
||||
minimumContrastRatio: scope.terminalMinimumContrastRatio,
|
||||
fontFamily: scope.terminalFontFamily,
|
||||
fontSize: fontPxForScale(scope.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: terminalCursorBlink,
|
||||
cursorStyle: terminalCursorStyle,
|
||||
// Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret.
|
||||
showCursorImmediately: terminalShowCursorImmediately,
|
||||
// A full inactive cell remains visible under the terminal's phone-fit scale.
|
||||
cursorInactiveStyle: terminalCursorInactiveStyle,
|
||||
convertEol: false,
|
||||
allowProposedApi: true
|
||||
})
|
||||
const nextTerm = scope.term
|
||||
scope.pendingTerm = nextTerm
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: the surface swap just mounted one.
|
||||
scope.term.open(scope.surface!)
|
||||
attachWebglAddon(true)
|
||||
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {
|
||||
try {
|
||||
scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon())
|
||||
scope.term.unicode.activeVersion = '11'
|
||||
} catch {}
|
||||
}
|
||||
if (typeof replayData === 'string' && replayData.length > 0) {
|
||||
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
|
||||
enqueueWrite(scope.ESC + '[0m' + replayData)
|
||||
}
|
||||
|
||||
// Why: reset eviction tracking + attach observers for the new term.
|
||||
resetEvictionCounter()
|
||||
cancelSelect()
|
||||
attachTermObservers()
|
||||
attachTerminalQueryReplyBridge(scope.term, gen)
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
if (gen !== scope.terminalGeneration) {
|
||||
return
|
||||
}
|
||||
scope.ready = true
|
||||
scope.everReady = true
|
||||
afterWritesDrained(function () {
|
||||
if (gen !== scope.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 && scope.term && scope.term.buffer && scope.term.buffer.active) {
|
||||
try {
|
||||
scope.term.scrollToLine(
|
||||
Math.max(0, (scope.term.buffer.active.baseY || 0) - scrollAnchorRows)
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
captureInitialOscLinkTexts()
|
||||
scope.initialOscLinkRowOffset = 0
|
||||
scope.initialOscLinkEvictionReady = true
|
||||
applyFitScale('init-replay')
|
||||
notify({ type: 'ready', cols: cols, rows: rows })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function write(data: string) {
|
||||
updateMouseModeFromData(data)
|
||||
enqueueWrite(data)
|
||||
pumpWrites(scope.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 (scope.firstDataPending) {
|
||||
scope.firstDataPending = false
|
||||
const gen = scope.terminalGeneration
|
||||
afterWritesDrained(function () {
|
||||
if (gen !== scope.terminalGeneration) {
|
||||
return
|
||||
}
|
||||
applyFitScale('first-data')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function resize(cols: number, rows: number) {
|
||||
if (!scope.term) {
|
||||
return
|
||||
}
|
||||
scope.initRows = rows || scope.initRows
|
||||
scope.term.resize(cols || scope.term.cols, rows || scope.term.rows)
|
||||
emitKeyboardAvoidanceMetrics()
|
||||
applyFitScale('resize-msg')
|
||||
notify({ type: 'ready', cols: cols, rows: rows })
|
||||
}
|
||||
|
||||
// reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines).
|
||||
Reference in New Issue
Block a user