feat(web): add interactive sidebar terminals

This commit is contained in:
Yichen Jiang committed 2026-09-09 16:40:58 +08:00
1 parent 6d99a8a53a
commit e15a9b1bec
132 files changed
+5985 -101

No files matched your search

@@ -0,0 +1,73 @@
/** Unfinished close requests survive reload independently of the removed sidebar tabs. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WebTerminalId } from '../types.ts'
/** An explicit cleanup request; no process or open-tab metadata is mirrored here. */
export interface TerminalCloseRequest {
readonly sessionId: SessionId
readonly id: WebTerminalId
readonly title: string
}
const PREFIX = 'dsh.terminal.close.v1.'
/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */
export class TerminalCloseRequests {
private readonly requests = new Map<WebTerminalId, TerminalCloseRequest>()
constructor() {
try {
if (typeof localStorage === 'undefined') return
for (let index = 0; index < localStorage.length; index++) {
const key = localStorage.key(index)
if (key?.startsWith(PREFIX)) this.load(key)
}
} catch (error) { console.error('Terminal cleanup recovery failed:', error) }
}
/**
* Read cleanup work still awaiting Host confirmation.
* @returns unfinished requests owned by this browser instance.
*/
pending(): readonly TerminalCloseRequest[] { return [...this.requests.values()] }
/**
* Retain cleanup across reload before removing a tab.
* @param request - close intent to save before removing its tab.
*/
save(request: TerminalCloseRequest): void {
this.requests.set(request.id, request)
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(PREFIX + request.id, JSON.stringify(request))
} catch (error) { console.error('Terminal cleanup persistence failed:', error) }
}
/**
* Forget confirmed cleanup in memory and browser storage.
* @param id - terminal whose Host cleanup succeeded.
*/
remove(id: WebTerminalId): void {
this.requests.delete(id)
try {
if (typeof localStorage !== 'undefined') localStorage.removeItem(PREFIX + id)
} catch (error) { console.error('Terminal cleanup persistence failed:', error) }
}
private load(key: string): void {
try {
const raw = localStorage.getItem(key)
if (raw === null) return
const parsed: unknown = JSON.parse(raw)
if (!isRequest(parsed) || key !== PREFIX + parsed.id) throw new Error('Invalid terminal cleanup request')
this.requests.set(parsed.id, parsed)
} catch (error) { console.error('Terminal cleanup recovery failed:', error) }
}
}
function isRequest(value: unknown): value is TerminalCloseRequest {
if (typeof value !== 'object' || value === null) return false
const request = value as Record<string, unknown>
return typeof request.sessionId === 'string' && request.sessionId.length > 0
&& typeof request.id === 'string' && /^[\w-]{1,128}$/u.test(request.id)
&& typeof request.title === 'string'
}
@@ -0,0 +1,145 @@
/** Client terminal model service; views are keyed independently from Host terminal identities. */
import { Service, type Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import { TerminalView, type TerminalRemote } from './model.ts'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import type { WebTerminalId, WebTerminalInfo } from '../types.ts'
import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts'
export type { TerminalView, TerminalViewState, TerminalRenderFrame, TerminalRemote } from './model.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** React-free browser terminal views and explicit process cleanup. */
webTerminals: ClientTerminals
}
}
/** A failed background close that can be retried without restoring its tab. */
export interface TerminalCloseFailure {
readonly id: WebTerminalId
readonly title: string
readonly message: string
}
/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */
export class ClientTerminals extends Service {
/** Failed cleanup tasks; successful and in-progress closes have no visible notification. */
readonly closeFailures: SnapshotStore<readonly TerminalCloseFailure[]> = createSnapshotStore<readonly TerminalCloseFailure[]>([])
private readonly requests = new TerminalCloseRequests()
private readonly closing = new Map<WebTerminalId, Promise<void>>()
private readonly closed = new Set<WebTerminalId>(this.requests.pending().map(request => request.id))
private disposed = false
private readonly views = new Map<SessionId, Map<string, TerminalView>>()
/**
* @param ctx - Client root Context with Gateway and terminal Remote namespace.
* @param remote - generated terminal namespace.
*/
constructor(ctx: Context, private readonly remote: TerminalRemote) {
super(ctx, 'webTerminals')
ctx.effect(() => async () => {
this.disposed = true
for (const views of this.views.values()) for (const view of views.values()) view.dispose()
this.views.clear()
await Promise.all(this.closing.values())
}, 'terminal-controller.client.views')
for (const request of this.requests.pending()) this.cleanup(request)
}
/**
* Return the stable model for one sidebar occurrence.
* @param sessionId - owning Session.
* @param key - sidebar occurrence key.
* @param terminalId - existing Host identity when restoring a listed terminal.
* @returns its observable state and terminal commands.
*/
view(sessionId: SessionId, key: string, terminalId?: WebTerminalId): TerminalView {
let views = this.views.get(sessionId)
if (views === undefined) { views = new Map(); this.views.set(sessionId, views) }
let view = views.get(key)
if (view === undefined) {
const id = terminalId ?? randomUUID() as WebTerminalId
view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined)
views.set(key, view)
void view.refresh()
}
return view
}
/**
* Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
* @param sessionId - owning Session.
* @param key - sidebar occurrence key, including an inactive restored tab.
* @param terminalId - restored identity if the tab has no model yet.
*/
close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void {
const views = this.views.get(sessionId)
const view = views?.get(key)
const id = view?.id ?? terminalId
if (id === undefined) return
const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key }
this.closed.add(id)
this.requests.save(request)
views?.delete(key)
if (views?.size === 0) this.views.delete(sessionId)
this.cleanup(request, view)
}
/**
* Query Host terminals that have neither a tab in this page nor an unfinished close.
* @param sessionId - Session being displayed.
* @returns terminals available for opening as recovered tabs.
*/
async recover(sessionId: SessionId): Promise<WebTerminalInfo[]> {
const result = await this.remote.list(sessionId)
if (!result.ok) throw new Error(result.error.message)
const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id))
const closing = new Set(this.requests.pending().map(request => request.id))
return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id))
}
/**
* Retry a saved close request without reopening its tab.
* @param id - failed terminal identity.
*/
retryClose(id: WebTerminalId): void {
const record = this.requests.pending().find(item => item.id === id)
if (record !== undefined) this.cleanup(record)
}
private cleanup(record: TerminalCloseRequest, view?: TerminalView): void {
if (this.closing.has(record.id) || this.disposed) return
this.closeFailures.set(this.closeFailures.getSnapshot().filter(failure => failure.id !== record.id))
const pending = (async () => {
if (view !== undefined) await view.close()
else {
const result = await this.remote.close(record.sessionId, record.id)
if (!result.ok) throw new Error(result.error.message)
}
this.requests.remove(record.id)
})().catch((error: unknown) => {
if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), {
id: record.id, title: record.title,
message: error instanceof Error ? error.message : String(error),
}])
}).finally(() => {
view?.dispose()
this.closing.delete(record.id)
})
this.closing.set(record.id, pending)
}
}
/** Required Client transport and terminal namespace. */
export const inject = ['remote', 'remote.terminal']
/**
* Install the Client terminal models.
* @param ctx - Client root Context.
*/
export function apply(ctx: Context): void {
new ClientTerminals(ctx, ctx.remote.terminal)
}
@@ -0,0 +1,284 @@
/** React-free browser terminal state and reconnecting Remote-stream ownership. */
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 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']
/** 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
}
/** 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
/**
* @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.
*/
constructor(
private readonly sessionId: SessionId,
private readonly remote: TerminalRemote,
private readonly gateway: Pick<ClientRemote, '$stream'>,
readonly id: WebTerminalId,
private readonly createWhenMissing = true,
) {}
/**
* Attach the DOM lifetime, starting the default 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.loading !== undefined) return this.loading
if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
this.patch({ phase: 'loading', error: 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.lifetime.signal.aborted || this.closing !== undefined) 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) await this.create(valueOf(environment))
else throw new Error('This terminal no longer exists on the Host. Open a new terminal.')
})().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined })
return this.loading
}
private async create(environment: TerminalEnvironment): Promise<void> {
this.patch({ phase: 'creating', error: undefined })
this.creation = (async () => {
const info = valueOf(await this.remote.create(this.sessionId, {
id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
}, this.lifetime.signal))
if (!this.lifetime.signal.aborted) this.adopt(info)
})().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 Error('Terminal attachment ended; reconnect to recover its state'),
carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
})
this.stream = stream
this.patch({ phase: 'connecting', writable: false, error: 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 Error('Terminal input buffer is full')); return }
this.queuedInput += bytes
const id = state.info.id
this.writes = this.writes.then(async () => {
if (this.attachmentId !== attachmentId) 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) 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 })
this.detach()
this.closing = (async () => {
// Even a refused or lost creation response may leave an allocation to close.
await this.creation?.catch(() => {})
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. */
dispose(): void {
this.mounted = false
this.lifetime.abort()
this.detach()
}
private detach(): void {
const previous = this.stream
this.stream = undefined
this.attachmentId = undefined
this.pendingRender?.resolve()
this.pendingRender = undefined
if (previous !== undefined) void previous.dispose()
}
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 Error('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 Error('Terminal output sequence has a gap')
sequence = frame.sequence
} else if (frame.type === 'snapshot') throw new Error('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 {
this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, error: error instanceof Error ? error.message : String(error) })
}
}
function valueOf<T>(result: RemoteResult<T>): T {
if (!result.ok) throw new Error(result.error.message)
return result.value
}