File size: 14,872 Bytes
09aec41 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 | /** React-free browser terminal state and reconnecting Remote-stream ownership. */
import { preferredShell, rememberShell } from './shell-preference.ts'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client'
import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
WebTerminalId, WebTerminalInfo,
} from '../types.ts'
/** The generated terminal namespace's browser-facing operations. */
export type TerminalRemote = ClientRemote['terminal']
/** Product error identifiers translated by the terminal UI. */
export type TerminalViewIssue = 'missingTerminal' | 'inputFull' | 'attachmentEnded' | 'invalidOutput' | 'terminalLimit'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/** Client terminal failure preserved through the Remote stream supervisor. */
'terminal/view': { readonly issue: TerminalViewIssue }
}
}
class TerminalViewError extends RemoteError<'terminal/view'> {
constructor(issue: TerminalViewIssue, message: string = issue) { super('terminal/view', message, { issue }) }
}
/** One screen write awaiting the DOM emulator's callback. */
export interface TerminalRenderFrame {
readonly revision: number
readonly frame: Extract<TerminalFrame, { type: 'snapshot' | 'output' }>
}
/** Observable state of one sidebar occurrence. */
export interface TerminalViewState {
readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
readonly environment?: TerminalEnvironment | undefined
readonly title?: string | undefined
readonly info?: WebTerminalInfo | undefined
readonly writable: boolean
readonly render?: TerminalRenderFrame | undefined
readonly error?: string | undefined
readonly issue?: TerminalViewIssue | undefined
}
/** A view survives DOM unmount; its process only ends on explicit close. */
export class TerminalView {
/** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
readonly state: SnapshotStore<TerminalViewState> = createSnapshotStore<TerminalViewState>({ phase: 'idle', writable: false })
private readonly lifetime = new AbortController()
private stream: RemoteStream<TerminalFrame> | undefined
private mounted = false
private attachmentId: TerminalAttachmentId | undefined
private pendingRender: { revision: number; resolve: () => void } | undefined
private revision = 0
private creation: Promise<void> | undefined
private loading: Promise<void> | undefined
private closing: Promise<void> | undefined
private writes: Promise<void> = Promise.resolve()
private queuedInput = 0
private readonly detaching = new Set<Promise<void>>()
/**
* @param sessionId - Session owning the terminal.
* @param remote - typed terminal Remote operations.
* @param gateway - reconnecting stream factory.
* @param id - Host terminal identity, reused when recovering an item from its Session list.
* @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
* @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
*/
constructor(
private readonly sessionId: SessionId,
private readonly remote: TerminalRemote,
private readonly gateway: Pick<ClientRemote, '$stream'>,
readonly id: WebTerminalId,
private readonly createWhenMissing = true,
private readonly shellPath?: string,
) {}
/**
* Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process.
* @returns a detach callback that leaves the terminal process alive.
*/
mount(): () => void {
this.mounted = true
if (this.state.getSnapshot().info === undefined) void this.refresh()
else this.connect()
return () => {
this.mounted = false
this.detach()
}
}
/**
* Start or recover this tab, deduplicating mounts and retries during allocation.
* Only a new tab may allocate a shell; listed terminals cannot be silently replaced.
* @returns after environment lookup and creation or recovery settle.
*/
refresh(): Promise<void> {
if (this.creation !== undefined) return this.creation
if (this.loading !== undefined) return this.loading
if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
this.patch({ phase: 'loading', error: undefined, issue: undefined })
this.loading = (async () => {
const [environment, available] = await Promise.all([
this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId),
])
if (this.stopped()) return
this.patch({ environment: valueOf(environment) })
const info = valueOf(available).find(item => item.id === this.id)
if (info !== undefined) this.adopt(info)
else if (this.createWhenMissing) {
let path = this.shellPath
if (path === undefined) {
const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal))
const previous = preferredShell()
path = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path
}
if (this.stopped()) return
if (path !== undefined) rememberShell(path)
await this.create(valueOf(environment), path)
}
else throw new TerminalViewError('missingTerminal')
})().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined })
return this.loading
}
private stopped(): boolean { return this.lifetime.signal.aborted || this.closing !== undefined }
private async create(environment: TerminalEnvironment, shellPath?: string): Promise<void> {
this.patch({ phase: 'creating', error: undefined, issue: undefined })
this.creation = (async () => {
const info = valueOf(await this.remote.create(this.sessionId, {
id: this.id, ...shellPath === undefined ? {} : { shellPath },
cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
}, this.lifetime.signal))
if (!this.lifetime.signal.aborted) {
this.adopt(info)
}
})().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined })
await this.creation
}
private adopt(info: WebTerminalInfo): void {
this.patch({ info, title: info.title })
if (this.mounted && this.closing === undefined) this.connect()
}
/** Reattach with a fresh screen and regain input control. */
connect(): void {
const info = this.state.getSnapshot().info
if (info === undefined || !this.mounted || this.closing !== undefined || this.lifetime.signal.aborted) return
this.detach()
const stream = this.gateway.$stream<TerminalFrame>({
name: 'Browser terminal output',
open: (signal) => {
const attachmentId = randomUUID() as TerminalAttachmentId
this.attachmentId = attachmentId
return this.remote.follow(this.sessionId, info.id, attachmentId, signal)
},
ended: () => new TerminalViewError('attachmentEnded'),
carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
})
this.stream = stream
this.patch({ phase: 'connecting', writable: false, error: undefined, issue: undefined, render: undefined })
void this.consume(stream)
}
/**
* Release the next stream item after xterm has parsed this frame.
* @param revision - locally delivered render revision.
*/
acknowledge(revision: number): void {
if (this.pendingRender?.revision !== revision) return
this.pendingRender.resolve()
this.pendingRender = undefined
}
/**
* Serialize raw input so concurrent RPC requests cannot reorder keystrokes.
* @param data - input from the terminal emulator.
*/
write(data: string): void {
const state = this.state.getSnapshot()
const attachmentId = this.attachmentId
if (!state.writable || state.info === undefined || attachmentId === undefined) return
const bytes = new TextEncoder().encode(data).byteLength
if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return }
this.queuedInput += bytes
const id = state.info.id
this.writes = this.writes.then(async () => {
if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return
valueOf(await this.remote.write(this.sessionId, id, attachmentId, data))
}).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes })
}
/**
* Resize only from the currently writable view.
* @param cols - measured column count.
* @param rows - measured row count.
*/
resize(cols: number, rows: number): void {
const state = this.state.getSnapshot()
const attachmentId = this.attachmentId
if (!state.writable || state.info === undefined || attachmentId === undefined) return
if (state.info.cols === cols && state.info.rows === rows) return
const id = state.info.id
cols = Math.min(cols, state.environment?.maxCols ?? cols)
rows = Math.min(rows, state.environment?.maxRows ?? rows)
this.writes = this.writes.then(async () => {
if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return
valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows))
}).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) })
}
/**
* Update the Host terminal's display name.
* @param title - user-entered terminal title.
* @returns after the rename settles and its result is reflected in view state.
*/
async rename(title: string): Promise<void> {
if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return
try {
valueOf(await this.remote.rename(this.sessionId, this.id, title))
const current = this.state.getSnapshot().info
this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() })
} catch (error) { this.fail(error) }
}
/**
* Explicitly terminate this view's process independently of its DOM lifetime.
* @returns after Host process cleanup succeeds; failures remain retryable by the owner.
*/
close(): Promise<void> {
if (this.closing !== undefined) return this.closing
this.patch({ phase: 'closing', writable: false, error: undefined, issue: undefined })
this.detach()
this.closing = (async () => {
// Even a refused or lost creation response may leave an allocation to close.
await this.creation
valueOf(await this.remote.close(this.sessionId, this.id))
this.detach()
this.patch({ phase: 'closed', writable: false })
})().catch((error: unknown) => { this.closing = undefined; this.fail(error); throw error })
return this.closing
}
/**
* Stop Client work on plugin unload without closing Host terminals.
* @returns after active and previously detached stream iterators have closed.
*/
async dispose(): Promise<void> {
this.mounted = false
this.lifetime.abort()
this.detach()
await Promise.all(this.detaching)
}
private detach(): void {
const previous = this.stream
this.stream = undefined
this.attachmentId = undefined
this.pendingRender?.resolve()
this.pendingRender = undefined
if (previous !== undefined) {
const cleanup = previous.dispose().finally(() => { this.detaching.delete(cleanup) })
this.detaching.add(cleanup)
}
}
private async consume(stream: RemoteStream<TerminalFrame>): Promise<void> {
let generation = 0
let sequence = 0
try {
for await (const item of stream) {
if (this.stream !== stream) return
const frame = item.value
if (generation !== item.generation) {
if (frame.type !== 'snapshot') throw new TerminalViewError('invalidOutput', 'Terminal output generation is missing its screen snapshot')
generation = item.generation
sequence = frame.sequence
item.accept()
} else if (frame.type === 'output') {
if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap')
sequence = frame.sequence
} else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot')
if (frame.type !== 'output') {
this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId })
}
if (frame.type !== 'state') {
const revision = ++this.revision
await new Promise<void>((resolve) => {
this.pendingRender = { revision, resolve }
const aborted = (): void => { this.acknowledge(revision) }
item.signal.addEventListener('abort', aborted, { once: true })
this.pendingRender.resolve = () => { item.signal.removeEventListener('abort', aborted); resolve() }
this.patch({ render: { revision, frame } })
if (item.signal.aborted) aborted()
})
}
}
} catch (error) {
if (this.stream === stream) {
if (this.state.getSnapshot().info?.state === 'exited') this.patch({ phase: 'closed', writable: false })
else this.fail(error)
}
}
}
private patch(patch: Partial<TerminalViewState>): void {
if (this.lifetime.signal.aborted) return
this.state.set({ ...this.state.getSnapshot(), ...patch })
}
private fail(error: unknown): void {
const failure = remoteErrorOf(error)
if (failure?.code === 'terminal/control-unavailable') {
this.patch({ writable: false, error: undefined, issue: undefined })
return
}
const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined
this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) })
}
}
function valueOf<T>(result: RemoteResult<T>): T {
if (!result.ok) throw result.error
return result.value
}
|