The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.
- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
+ a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
(QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
exists, hasn't exited, produced no output during settle, and is idle — three
guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
(⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.
Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
635 lines
23 KiB
TypeScript
635 lines
23 KiB
TypeScript
/**
|
|
* public/terminal-session.ts — one independent terminal session.
|
|
*
|
|
* Encapsulates a single xterm Terminal + its own WebSocket + reconnect state.
|
|
* One per tab (1 WS ↔ 1 server session; the backend manager already supports
|
|
* many concurrent sessions, so tabs need no protocol/backend change).
|
|
*
|
|
* No console.log — status is written as ANSI to the terminal.
|
|
*/
|
|
|
|
import { Terminal } from '@xterm/xterm'
|
|
import type { ITheme, ILink, ILinkProvider, IDisposable } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { SearchAddon } from '@xterm/addon-search'
|
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
import type {
|
|
ApprovalPreview,
|
|
ClaudeStatus,
|
|
ClientMessage,
|
|
PermissionGate,
|
|
PermissionMode,
|
|
ServerMessage,
|
|
StatusTelemetry,
|
|
} from '../src/types.js'
|
|
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
|
|
import { findPathMatches, type PathMatch } from './link-paths.js'
|
|
|
|
// Delay after the shell is ready before typing a session's initial command
|
|
// (e.g. `claude --resume …`), giving the shell time to finish its prompt.
|
|
const INITIAL_INPUT_DELAY_MS = 700
|
|
|
|
const RESET = '\x1b[0m'
|
|
const BOLD = '\x1b[1m'
|
|
const DIM = '\x1b[2m'
|
|
const RED = '\x1b[31m'
|
|
const CYAN = '\x1b[36m'
|
|
|
|
function statusLine(msg: string): string {
|
|
return `\r\n${DIM}${CYAN}[terminal] ${RESET}${msg}${RESET}\r\n`
|
|
}
|
|
|
|
function buildMessage(msg: ClientMessage): string {
|
|
return JSON.stringify(msg)
|
|
}
|
|
|
|
/** M6: scheme follows page protocol so HTTPS/Tailscale deploys use wss. */
|
|
function buildWsUrl(): string {
|
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
|
|
return `${scheme}://${location.host}/term`
|
|
}
|
|
|
|
/** W1: only these URL schemes may be opened from a clicked terminal link. */
|
|
const WEB_LINK_SCHEMES: ReadonlySet<string> = new Set(['http:', 'https:', 'mailto:'])
|
|
|
|
/**
|
|
* Open a URL clicked in the terminal (W1 hardening). Allowlists the scheme —
|
|
* blocking `javascript:` / `data:` / `file:` URIs — and opens with
|
|
* `noopener,noreferrer` to prevent reverse-tabnabbing. Exported for unit tests.
|
|
*/
|
|
export function openWebLink(uri: string): void {
|
|
let protocol: string
|
|
try {
|
|
protocol = new URL(uri).protocol
|
|
} catch {
|
|
return // not a parseable absolute URL → ignore
|
|
}
|
|
if (!WEB_LINK_SCHEMES.has(protocol)) return
|
|
window.open(uri, '_blank', 'noopener,noreferrer')
|
|
}
|
|
|
|
/** POSIX absolute path check (host is macOS/Linux — see out-of-scope notes). */
|
|
function isAbsolutePath(p: string): boolean {
|
|
return p.startsWith('/')
|
|
}
|
|
|
|
/**
|
|
* Resolve a path emitted in terminal output to an absolute path. Absolute paths
|
|
* pass through; relative paths are joined onto `cwd` (from OSC-7) and normalized
|
|
* (`.`/`..` collapsed). Returns null when the path is relative but no cwd is
|
|
* known yet — the caller then declines to open and shows a status line.
|
|
*/
|
|
function resolvePath(cwd: string | null, rawPath: string): string | null {
|
|
if (isAbsolutePath(rawPath)) return rawPath
|
|
if (cwd === null) return null
|
|
const base = cwd.endsWith('/') ? cwd.slice(0, -1) : cwd
|
|
const segments = base.split('/')
|
|
for (const seg of rawPath.split('/')) {
|
|
if (seg === '' || seg === '.') continue
|
|
if (seg === '..') {
|
|
if (segments.length > 1) segments.pop()
|
|
continue
|
|
}
|
|
segments.push(seg)
|
|
}
|
|
const joined = segments.join('/')
|
|
return joined.startsWith('/') ? joined : `/${joined}`
|
|
}
|
|
|
|
/** Connection state, surfaced as a colored status dot on the tab. */
|
|
export type SessionStatus = 'connecting' | 'connected' | 'reconnecting' | 'exited'
|
|
|
|
export interface TerminalSessionOpts {
|
|
/** Existing session to re-attach to, or null to spawn a fresh one. */
|
|
sessionId: string | null
|
|
/** Called whenever the server assigns/confirms this session's id (persist it). */
|
|
onSessionId: (id: string) => void
|
|
/** Optional: fired on output to an inactive tab (for an unread indicator). */
|
|
onActivity?: () => void
|
|
/** Optional: fired when the terminal title changes (OSC 0/2 — cwd/process). */
|
|
onTitle?: (title: string) => void
|
|
/** Optional: fired when connection status changes (for the tab status dot). */
|
|
onStatus?: (status: SessionStatus) => void
|
|
/** Optional: fired when Claude Code activity changes (from a hook, H2). */
|
|
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
|
|
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
|
|
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
|
|
onTelemetry?: (telemetry: StatusTelemetry) => void
|
|
/** Optional: fired when the pending inject-queue depth changes (W2), so the tab
|
|
* can render an "N queued" badge. Broadcast to every mirrored device. */
|
|
onQueue?: (length: number) => void
|
|
/** Optional: fired on a pointerdown anywhere in this pane (split-grid focus).
|
|
* Lets TabApp move the focused-pane (activeIndex) to the clicked quadrant. */
|
|
onFocus?: () => void
|
|
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
|
|
cwd?: string
|
|
/** Optional: type this once the new session's shell is ready (O2 resume). */
|
|
initialInput?: string
|
|
}
|
|
|
|
export class TerminalSession {
|
|
readonly el: HTMLDivElement
|
|
|
|
private readonly term: Terminal
|
|
private readonly fitAddon: FitAddon
|
|
private readonly searchAddon: SearchAddon
|
|
private readonly resizeObserver: ResizeObserver
|
|
private readonly onSessionId: (id: string) => void
|
|
private readonly onActivity: (() => void) | undefined
|
|
private readonly onTitle: ((title: string) => void) | undefined
|
|
private readonly onStatus: ((status: SessionStatus) => void) | undefined
|
|
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
|
|
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
|
|
private readonly onQueue: ((length: number) => void) | undefined
|
|
private readonly onFocus: (() => void) | undefined
|
|
private readonly spawnCwd: string | undefined
|
|
private readonly initialInput: string | undefined
|
|
private initialSent = false
|
|
private statusValue: SessionStatus = 'connecting'
|
|
private claudeStatusValue: ClaudeStatus = 'unknown'
|
|
private cwdValue: string | null = null
|
|
private pendingApprovalValue = false
|
|
private pendingToolValue: string | undefined = undefined
|
|
private telemetryValue: StatusTelemetry | null = null
|
|
private queueLengthValue = 0
|
|
private pendingGateValue: PermissionGate | null = null
|
|
// W1: bounded command/diff preview of the held tool, from the last pending
|
|
// status frame. Null when no approval is held or the tool wasn't previewable.
|
|
private pendingPreviewValue: ApprovalPreview | null = null
|
|
// VC: nonce that increments on every false→true pendingApproval flip — lets a
|
|
// caller (e.g. a voice command captured at PTT-start) detect a stale gate: a
|
|
// slow transcript must not resolve a NEWER held permission than the one it
|
|
// was spoken against (PLAN_VOICE_COMMANDS.md §5, safeguard 5).
|
|
private pendingEpochValue = 0
|
|
|
|
private ws: WebSocket | null = null
|
|
private sessionId: string | null
|
|
private reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
private resizeTimer: ReturnType<typeof setTimeout> | null = null
|
|
private initialInputTimer: ReturnType<typeof setTimeout> | null = null
|
|
private exitListener: { dispose(): void } | null = null
|
|
private linkProviderDisposable: IDisposable | null = null
|
|
private openPathInFlight = false
|
|
private isConnecting = false
|
|
private disposed = false
|
|
private lastCols = 0
|
|
private lastRows = 0
|
|
|
|
constructor(opts: TerminalSessionOpts) {
|
|
this.sessionId = opts.sessionId
|
|
this.onSessionId = opts.onSessionId
|
|
this.onActivity = opts.onActivity
|
|
this.onTitle = opts.onTitle
|
|
this.onStatus = opts.onStatus
|
|
this.onClaudeStatus = opts.onClaudeStatus
|
|
this.onTelemetry = opts.onTelemetry
|
|
this.onQueue = opts.onQueue
|
|
this.onFocus = opts.onFocus
|
|
this.spawnCwd = opts.cwd
|
|
this.initialInput = opts.initialInput
|
|
|
|
this.el = document.createElement('div')
|
|
this.el.className = 'term-pane'
|
|
this.el.style.display = 'none'
|
|
// Split-grid: a pointerdown anywhere in the pane makes it the focused quadrant.
|
|
// Capture phase so it fires before xterm's own handling; never blocks input.
|
|
this.el.addEventListener('pointerdown', () => this.onFocus?.(), { capture: true })
|
|
|
|
this.term = new Terminal({
|
|
scrollback: 5000,
|
|
fontFamily: 'Menlo, Consolas, monospace',
|
|
theme: { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#7c8cff' },
|
|
})
|
|
this.fitAddon = new FitAddon()
|
|
this.term.loadAddon(this.fitAddon)
|
|
this.searchAddon = new SearchAddon()
|
|
this.term.loadAddon(this.searchAddon)
|
|
// M2/W1: tap a URL Claude prints to open it — hardened handler (scheme
|
|
// allowlist + noopener,noreferrer) instead of the addon's default activation.
|
|
this.term.loadAddon(new WebLinksAddon((_event, uri) => openWebLink(uri)))
|
|
this.term.open(this.el)
|
|
// W1: clickable file paths (`src/app.ts:42`) → jump to file:line in the host
|
|
// editor. Registered after open() so the linkifier service is available.
|
|
this.linkProviderDisposable = this.term.registerLinkProvider(this.makePathLinkProvider())
|
|
|
|
this.term.onData((data) => this.send(data))
|
|
// Tab title = current folder, derived from the shell's OSC title.
|
|
this.term.onTitleChange((title) => this.onTitle?.(folderFromTitle(title) ?? title))
|
|
// OSC 7 reports the full cwd (M6) — used for "new tab here".
|
|
this.term.parser.registerOscHandler(7, (payload) => {
|
|
const c = cwdFromOsc7(payload)
|
|
if (c !== null) this.cwdValue = c
|
|
return true
|
|
})
|
|
|
|
this.resizeObserver = new ResizeObserver(() => this.scheduleResize())
|
|
this.resizeObserver.observe(this.el)
|
|
}
|
|
|
|
/** Current session id (null until the server assigns one). */
|
|
get id(): string | null {
|
|
return this.sessionId
|
|
}
|
|
|
|
/** Current connection status (for the tab status dot). */
|
|
get status(): SessionStatus {
|
|
return this.statusValue
|
|
}
|
|
|
|
/** Current Claude Code activity (from hooks, H2). */
|
|
get claudeStatus(): ClaudeStatus {
|
|
return this.claudeStatusValue
|
|
}
|
|
|
|
/** Current working directory reported by the shell via OSC 7 (M6), or null. */
|
|
get cwd(): string | null {
|
|
return this.cwdValue
|
|
}
|
|
|
|
/** Whether a tool approval is held server-side and can be resolved (H3). */
|
|
get pendingApproval(): boolean {
|
|
return this.pendingApprovalValue
|
|
}
|
|
get pendingTool(): string | undefined {
|
|
return this.pendingToolValue
|
|
}
|
|
|
|
/** Latest statusLine telemetry (B2). Single source of truth (review #15):
|
|
* T-tabs reads this getter; TabEntry does NOT cache its own copy. */
|
|
get telemetry(): StatusTelemetry | null {
|
|
return this.telemetryValue
|
|
}
|
|
|
|
/** W2: current pending inject-queue depth (0 when empty), from the last
|
|
* `queue` frame. Every mirrored device sees the same value (broadcast). */
|
|
get queueLength(): number {
|
|
return this.queueLengthValue
|
|
}
|
|
|
|
/** The gate kind from the last pending status frame (B4): 'plan' or 'tool'.
|
|
* Null when no approval is held or the last status had no gate. */
|
|
get pendingGate(): PermissionGate | null {
|
|
return this.pendingGateValue
|
|
}
|
|
|
|
/** W1: bounded command/diff preview of the held tool (from the last pending
|
|
* status frame), or null when nothing is held / the tool wasn't previewable. */
|
|
get pendingPreview(): ApprovalPreview | null {
|
|
return this.pendingPreviewValue
|
|
}
|
|
|
|
/** Nonce counting false→true pendingApproval flips (VC stale-gate guard, §5). */
|
|
get pendingEpoch(): number {
|
|
return this.pendingEpochValue
|
|
}
|
|
|
|
private setStatus(s: SessionStatus): void {
|
|
this.statusValue = s
|
|
this.onStatus?.(s)
|
|
}
|
|
|
|
/** fit() only when the pane has real dimensions (display:none → NaN, §9). */
|
|
private safefit(): { cols: number; rows: number } | null {
|
|
try {
|
|
this.fitAddon.fit()
|
|
const { cols, rows } = this.term
|
|
if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) {
|
|
return null
|
|
}
|
|
return { cols, rows }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
private sendResize(cols: number, rows: number): void {
|
|
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
|
|
if (cols === this.lastCols && rows === this.lastRows) return
|
|
this.lastCols = cols
|
|
this.lastRows = rows
|
|
this.ws.send(buildMessage({ type: 'resize', cols, rows }))
|
|
}
|
|
|
|
private scheduleResize(): void {
|
|
if (this.el.style.display === 'none') return // hidden tab: skip (fit→NaN)
|
|
if (this.resizeTimer !== null) clearTimeout(this.resizeTimer)
|
|
this.resizeTimer = setTimeout(() => {
|
|
this.resizeTimer = null
|
|
const dims = this.safefit()
|
|
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
|
}, 100)
|
|
}
|
|
|
|
connect(): void {
|
|
if (this.disposed || this.isConnecting) return
|
|
this.isConnecting = true
|
|
this.setStatus('connecting') // connection state shows on the tab dot, not in the terminal
|
|
|
|
const socket = new WebSocket(buildWsUrl())
|
|
this.ws = socket
|
|
|
|
socket.addEventListener('open', () => {
|
|
this.reconnectDelay = 1000
|
|
this.setStatus('connected') // tab dot turns green; no in-terminal "Connected" line
|
|
// Send cwd only on a fresh session (M6 "new tab here"); on reconnect the
|
|
// sessionId is already set and the server ignores cwd.
|
|
const attachMsg: ClientMessage =
|
|
this.sessionId === null && this.spawnCwd !== undefined
|
|
? { type: 'attach', sessionId: null, cwd: this.spawnCwd }
|
|
: { type: 'attach', sessionId: this.sessionId }
|
|
socket.send(buildMessage(attachMsg))
|
|
})
|
|
|
|
socket.addEventListener('message', (event: MessageEvent<string>) => {
|
|
let msg: ServerMessage
|
|
try {
|
|
msg = JSON.parse(event.data) as ServerMessage
|
|
} catch {
|
|
return
|
|
}
|
|
this.handle(msg, socket)
|
|
})
|
|
|
|
socket.addEventListener('close', () => {
|
|
this.isConnecting = false
|
|
this.ws = null
|
|
if (!this.disposed) this.scheduleReconnect()
|
|
})
|
|
|
|
socket.addEventListener('error', () => {
|
|
// Do NOT reset isConnecting here — the 'close' event always follows 'error'
|
|
// and owns the cleanup (resetting it twice could race a reconnect).
|
|
})
|
|
}
|
|
|
|
private handle(msg: ServerMessage, socket: WebSocket): void {
|
|
switch (msg.type) {
|
|
case 'attached': {
|
|
this.sessionId = msg.sessionId
|
|
this.onSessionId(msg.sessionId)
|
|
const dims = this.safefit()
|
|
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
|
// O2: once the shell is up, type the resume command (only on first attach).
|
|
if (this.initialInput !== undefined && !this.initialSent) {
|
|
this.initialSent = true
|
|
const cmd = this.initialInput
|
|
this.initialInputTimer = setTimeout(() => {
|
|
this.initialInputTimer = null
|
|
if (this.disposed) return
|
|
this.send(cmd)
|
|
}, INITIAL_INPUT_DELAY_MS)
|
|
}
|
|
break
|
|
}
|
|
case 'output': {
|
|
this.term.write(msg.data)
|
|
if (this.el.style.display === 'none') this.onActivity?.()
|
|
break
|
|
}
|
|
case 'status': {
|
|
const nextPending = msg.pending === true
|
|
if (nextPending && !this.pendingApprovalValue) this.pendingEpochValue++
|
|
this.claudeStatusValue = msg.status
|
|
this.pendingApprovalValue = nextPending
|
|
this.pendingToolValue = nextPending ? msg.detail : undefined
|
|
this.pendingGateValue = msg.gate ?? null
|
|
// W1: keep the preview only while an approval is held; a non-pending
|
|
// status (approve/reject resolved) clears it so the bar hides cleanly.
|
|
this.pendingPreviewValue = nextPending ? (msg.preview ?? null) : null
|
|
this.onClaudeStatus?.(msg.status, msg.detail)
|
|
break
|
|
}
|
|
case 'telemetry': {
|
|
this.telemetryValue = msg.telemetry
|
|
this.onTelemetry?.(msg.telemetry)
|
|
break
|
|
}
|
|
case 'queue': {
|
|
// W2: pending inject-queue depth changed — update the badge on every
|
|
// mirrored device (shared session).
|
|
this.queueLengthValue = msg.length
|
|
this.onQueue?.(msg.length)
|
|
break
|
|
}
|
|
case 'exit': {
|
|
this.setStatus('exited')
|
|
const reason = msg.reason ? ` (${msg.reason})` : ''
|
|
this.term.write(
|
|
statusLine(
|
|
`${RED}${BOLD}Process exited${RESET} code=${msg.code}${reason}` +
|
|
`\r\n${DIM}Press Enter to reconnect…${RESET}`,
|
|
),
|
|
)
|
|
if (this.exitListener) this.exitListener.dispose()
|
|
this.exitListener = this.term.onData((data) => {
|
|
if (data === '\r') {
|
|
this.exitListener?.dispose()
|
|
this.exitListener = null
|
|
this.sessionId = null // spawn a fresh session on reconnect
|
|
socket.close()
|
|
this.connect()
|
|
}
|
|
})
|
|
break
|
|
}
|
|
default: {
|
|
// Compile-time exhaustiveness: TypeScript narrows msg to 'never' here
|
|
// once all ServerMessage variants are handled. Silently ignored at runtime
|
|
// (future server additions won't crash older clients).
|
|
const _exhaustive: never = msg
|
|
void _exhaustive
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
if (this.reconnectTimer !== null) return
|
|
this.setStatus('reconnecting') // amber tab dot signals it; no in-terminal line
|
|
const delay = this.reconnectDelay
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000)
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null
|
|
this.connect()
|
|
}, delay)
|
|
}
|
|
|
|
private sendMsg(msg: ClientMessage): void {
|
|
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
|
|
this.ws.send(buildMessage(msg))
|
|
}
|
|
|
|
/** Send raw bytes as input (used by term.onData and the key bar). */
|
|
send(data: string): void {
|
|
this.sendMsg({ type: 'input', data })
|
|
}
|
|
|
|
/**
|
|
* W1: a link provider that turns file paths in terminal output into clickable
|
|
* links. xterm passes a 1-based buffer row; getLine wants a 0-based index, and
|
|
* the range's y is the same 1-based row.
|
|
*/
|
|
private makePathLinkProvider(): ILinkProvider {
|
|
return {
|
|
provideLinks: (bufferLineNumber, callback) => {
|
|
const line = this.term.buffer.active.getLine(bufferLineNumber - 1)
|
|
if (!line) {
|
|
callback(undefined)
|
|
return
|
|
}
|
|
const matches = findPathMatches(line.translateToString(true))
|
|
if (matches.length === 0) {
|
|
callback(undefined)
|
|
return
|
|
}
|
|
const links: ILink[] = matches.map((match) => ({
|
|
text: match.text,
|
|
range: {
|
|
start: { x: match.startX, y: bufferLineNumber },
|
|
end: { x: match.endX, y: bufferLineNumber },
|
|
},
|
|
activate: () => this.openPath(match),
|
|
}))
|
|
callback(links)
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a clicked file path in the host editor (W1). Relative paths are resolved
|
|
* against the OSC-7 cwd; if no cwd is known the click is declined with a status
|
|
* line. An in-flight guard stops rapid clicks from spawning many editor
|
|
* processes. Failures are shown as a non-blocking status line, never thrown.
|
|
*/
|
|
private openPath(match: PathMatch): void {
|
|
const abs = resolvePath(this.cwdValue, match.path)
|
|
if (abs === null) {
|
|
this.term.write(statusLine(`cannot open ${match.path}: working dir unknown`))
|
|
return
|
|
}
|
|
if (this.openPathInFlight) return
|
|
this.openPathInFlight = true
|
|
const body: { file: string; line?: number } =
|
|
match.line !== undefined ? { file: abs, line: match.line } : { file: abs }
|
|
fetch('/open-in-editor', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok) this.term.write(statusLine(`cannot open ${match.path} (${res.status})`))
|
|
})
|
|
.catch(() => {
|
|
this.term.write(statusLine(`cannot open ${match.path}: request failed`))
|
|
})
|
|
.finally(() => {
|
|
this.openPathInFlight = false
|
|
})
|
|
}
|
|
|
|
/** Resolve a held PermissionRequest (H3). Optionally relay a permission-mode
|
|
* change (B4): mode is included only when explicitly provided so the server
|
|
* can distinguish "approve with default" from "approve, keep existing mode". */
|
|
approve(mode?: PermissionMode): void {
|
|
this.pendingApprovalValue = false
|
|
this.pendingPreviewValue = null // W1: resolved → drop the stale preview
|
|
this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) })
|
|
}
|
|
reject(): void {
|
|
this.pendingApprovalValue = false
|
|
this.pendingPreviewValue = null // W1: resolved → drop the stale preview
|
|
this.sendMsg({ type: 'reject' })
|
|
}
|
|
|
|
/** Scrollback search (M1). */
|
|
findNext(query: string): void {
|
|
this.searchAddon.findNext(query)
|
|
}
|
|
findPrevious(query: string): void {
|
|
this.searchAddon.findPrevious(query)
|
|
}
|
|
clearSearch(): void {
|
|
this.searchAddon.clearDecorations()
|
|
}
|
|
|
|
/** Apply a theme + font size (M3) and re-fit if visible. */
|
|
applyTheme(theme: ITheme, fontSize: number): void {
|
|
this.term.options.theme = theme
|
|
this.term.options.fontSize = fontSize
|
|
if (this.el.style.display !== 'none') {
|
|
const dims = this.safefit()
|
|
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make this pane visible and fit it. Focuses the terminal unless `focus: false`
|
|
* — in a split grid several panes are shown at once, so only the focused
|
|
* quadrant should grab keyboard focus (four panes calling focus() would fight).
|
|
*/
|
|
show(opts?: { focus?: boolean }): void {
|
|
this.el.style.display = 'block'
|
|
const shouldFocus = opts?.focus !== false
|
|
requestAnimationFrame(() => {
|
|
const dims = this.safefit()
|
|
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
|
if (shouldFocus) this.term.focus()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Re-fit and FORCE-resend dims (latest-writer-wins): when this device regains
|
|
* focus, re-assert its size so the shared PTY snaps to this screen even if
|
|
* another device had resized it. No-op while hidden.
|
|
*/
|
|
refit(): void {
|
|
if (this.el.style.display === 'none') return
|
|
this.lastCols = -1 // force sendResize to fire even if our dims are unchanged
|
|
this.lastRows = -1
|
|
const dims = this.safefit()
|
|
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
|
}
|
|
|
|
hide(): void {
|
|
this.el.style.display = 'none'
|
|
// v0.4 (latest-writer-wins): hiding does NOT resize the shared PTY — the
|
|
// device still actively viewing keeps its size. Output still streams
|
|
// (background mirror). Force show()/refit() to re-cast our dims on return.
|
|
this.lastCols = -1
|
|
this.lastRows = -1
|
|
}
|
|
|
|
/** Tear down: closing the WS detaches — the server PTY keeps running. */
|
|
dispose(): void {
|
|
this.disposed = true
|
|
if (this.reconnectTimer !== null) {
|
|
clearTimeout(this.reconnectTimer)
|
|
this.reconnectTimer = null
|
|
}
|
|
if (this.resizeTimer !== null) {
|
|
clearTimeout(this.resizeTimer)
|
|
this.resizeTimer = null
|
|
}
|
|
if (this.initialInputTimer !== null) {
|
|
clearTimeout(this.initialInputTimer)
|
|
this.initialInputTimer = null
|
|
}
|
|
this.resizeObserver.disconnect()
|
|
if (this.linkProviderDisposable !== null) {
|
|
this.linkProviderDisposable.dispose()
|
|
this.linkProviderDisposable = null
|
|
}
|
|
if (this.ws !== null) {
|
|
try {
|
|
this.ws.close()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.ws = null
|
|
}
|
|
this.term.dispose()
|
|
this.el.remove()
|
|
}
|
|
}
|