/** * Private bridge between the public TUI extension contract and pi-tui. * * The manager serializes modal ownership, guards extension callbacks, and * settles every queued or active operation before terminal teardown. * @module @deepseek-ai/dsh-tui/overlay-manager */ import { Service, type Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TuiExtensionService } from './index.ts' import type { Component, Focusable, OverlayHandle, } from '@earendil-works/pi-tui' import type { TuiComponent, TuiFocusable, TuiOverlayCloseReason, TuiOverlayHost, TuiOverlayOutcome, TuiOverlayOptions, TuiOverlayRequest, TuiOverlaySession, TuiOverlayState, TuiTheme, TuiViewport, } from './extension.ts' /** pi-tui operations retained by the front door instead of exposed to plugins. */ export interface TuiOverlayDriver { /** Current terminal viewport. */ viewport(): TuiViewport /** Current semantic theme facade. */ theme(): TuiTheme /** Escape text at the terminal display boundary. */ display(value: string): string /** Mount one guarded component and return its private pi-tui handle. */ show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle /** Invalidate the mounted UI and request a render. */ invalidate(): void /** Report a contained extension failure. */ reportError(error: unknown): void } interface OverlayEntry { readonly request: TuiOverlayRequest readonly controller: AbortController readonly signal: AbortSignal readonly closed: Promise readonly resolveClosed: (outcome: TuiOverlayOutcome) => void readonly session: TuiOverlaySession state: TuiOverlayState component?: GuardedOverlayComponent handle?: OverlayHandle removeRequestAbort?: () => void outcome?: TuiOverlayOutcome failing?: boolean } /** Turn a close reason into its immutable public outcome. */ function outcome(reason: Exclude): TuiOverlayOutcome { return Object.freeze({ reason }) } /** Retain only supported layout fields before a queued request returns to its caller. */ function retainOptions(options: TuiOverlayOptions): TuiOverlayOptions { return Object.freeze({ ...options.width === undefined ? {} : { width: options.width }, ...options.minWidth === undefined ? {} : { minWidth: options.minWidth }, ...options.maxHeight === undefined ? {} : { maxHeight: options.maxHeight }, ...options.anchor === undefined ? {} : { anchor: options.anchor }, ...options.margin === undefined ? {} : { margin: typeof options.margin === 'object' ? Object.freeze({ ...options.margin }) : options.margin, }, }) } /** Guard plugin component methods while preserving focus and key-release state. */ class GuardedOverlayComponent implements Component, Focusable { constructor( private readonly component: TuiComponent & Partial, private readonly fail: (error: unknown) => void, ) {} get focused(): boolean { try { return this.component.focused ?? false } catch (error) { this.fail(error) return false } } set focused(value: boolean) { try { if ('focused' in this.component) this.component.focused = value } catch (error) { this.fail(error) } } get wantsKeyRelease(): boolean { try { return this.component.wantsKeyRelease ?? false } catch (error) { this.fail(error) return false } } render(width: number): string[] { try { return this.component.render(width) } catch (error) { this.fail(error) return [] } } handleInput(data: string): void { try { this.component.handleInput?.(data) } catch (error) { this.fail(error) } } invalidate(): boolean { try { this.component.invalidate() return true } catch (error) { this.fail(error) return false } } } /** FIFO modal owner for one mounted TUI. */ export class TuiOverlayManager { private readonly queue: OverlayEntry[] = [] private active: OverlayEntry | undefined private accepting = true private disposeTask: Promise | undefined constructor(private readonly driver: TuiOverlayDriver) {} /** * Whether one extension or built-in overlay currently owns terminal focus. * @returns `true` while an overlay is active. */ hasActiveOverlay(): boolean { return this.active !== undefined } /** Reject new work while the TUI unloads dependent extension fibers. */ beginShutdown(): void { this.accepting = false } /** * Queue one overlay without assigning Cordis ownership. * @param request - component factory, constraints, and request signal. * @returns an internal session that can close with an ownership reason. */ open(request: TuiOverlayRequest): TuiOverlaySession & { closeWith(reason: Exclude): Promise } { if (!this.accepting) throw new Error('TUI is shutting down') const requestSignal = request.signal const retainedRequest: TuiOverlayRequest = Object.freeze({ create: request.create, ...request.options === undefined ? {} : { options: retainOptions(request.options) }, ...requestSignal === undefined ? {} : { signal: requestSignal }, }) const controller = new AbortController() const signal = requestSignal === undefined ? controller.signal : AbortSignal.any([requestSignal, controller.signal]) const deferred = Promise.withResolvers() const session: TuiOverlaySession & { closeWith(reason: Exclude): Promise } = { get state(): TuiOverlayState { return entry.state }, closed: deferred.promise, close: () => this.close(entry, outcome('closed')), closeWith: (reason: Exclude) => this.close(entry, outcome(reason)), } const entry: OverlayEntry = { request: retainedRequest, controller, signal, closed: deferred.promise, resolveClosed: deferred.resolve, session, state: 'queued', } if (requestSignal?.aborted === true) { void this.close(entry, outcome('aborted')) return session } if (requestSignal !== undefined) { const onAbort = (): void => { void this.close(entry, outcome('aborted')) } requestSignal.addEventListener('abort', onAbort, { once: true }) entry.removeRequestAbort = () => { requestSignal.removeEventListener('abort', onAbort) } } this.queue.push(entry) this.activateNext() return session } /** Stop accepting work and settle every active or queued overlay. */ dispose(): Promise { if (this.disposeTask !== undefined) return this.disposeTask this.beginShutdown() const entries = [ ...this.active === undefined ? [] : [this.active], ...this.queue, ] return this.disposeTask = Promise.all( entries.map(entry => this.close(entry, outcome('tui-disposed'))), ).then(() => {}) } private activateNext(): void { if (!this.accepting || this.active !== undefined) return const entry = this.queue.shift() if (entry === undefined) return this.active = entry entry.state = 'active' const host = this.host(entry) let component: TuiComponent & Partial try { component = entry.request.create(host) } catch (error) { this.fail(entry, error) return } if (this.active !== entry) return const guarded = new GuardedOverlayComponent(component, (error) => { this.fail(entry, error) }) entry.component = guarded try { const handle = this.driver.show(guarded, entry.request.options) if (this.active !== entry) { this.hide(handle) return } entry.handle = handle this.driver.invalidate() } catch (error) { this.fail(entry, error) } } private host(entry: OverlayEntry): TuiOverlayHost { const driver = this.driver return Object.freeze({ get signal(): AbortSignal { return entry.signal }, get viewport(): TuiViewport { return Object.freeze({ ...driver.viewport() }) }, get theme(): TuiTheme { return driver.theme() }, display: (value: string) => this.driver.display(value), invalidate: () => { if (this.active !== entry || entry.component === undefined || entry.failing === true) return if (!entry.component.invalidate() || this.active !== entry) return try { this.driver.invalidate() } catch (error) { this.fail(entry, error) } }, close: () => { void this.close(entry, outcome('closed')) }, }) } private fail(entry: OverlayEntry, error: unknown): void { if (entry.state === 'closed' || entry.failing === true) return entry.failing = true this.report(error) queueMicrotask(() => { void this.close(entry, Object.freeze({ reason: 'error', error })) }) } private report(error: unknown): void { try { this.driver.reportError(error) } catch { // Error reporting is a containment boundary, never a second failure path. } } private hide(handle: OverlayHandle): void { try { handle.hide() } catch (error) { this.report(error) } } private close(entry: OverlayEntry, result: TuiOverlayOutcome): Promise { if (entry.outcome !== undefined) return entry.closed entry.outcome = result entry.state = 'closed' entry.removeRequestAbort?.() delete entry.removeRequestAbort if (!entry.controller.signal.aborted) entry.controller.abort(result) const queuedIndex = this.queue.indexOf(entry) if (queuedIndex >= 0) this.queue.splice(queuedIndex, 1) if (this.active === entry) { this.active = undefined if (entry.handle !== undefined) this.hide(entry.handle) delete entry.handle } delete entry.component entry.resolveClosed(result) try { this.driver.invalidate() } catch (error) { this.report(error) } queueMicrotask(() => { this.activateNext() }) return entry.closed } } /** Cordis service whose method effects bind to the calling plugin fiber. */ export class TuiExtensionServiceImpl extends Service implements TuiExtensionService { constructor( ctx: Context, readonly agent: Agent, private readonly overlays: TuiOverlayManager, ) { super(ctx, 'tui') } /** @inheritdoc */ openOverlay(request: TuiOverlayRequest): TuiOverlaySession { let operation: ReturnType | undefined const disposeOwner = this.ctx.effect( () => () => operation?.closeWith('owner-disposed'), 'tui.openOverlay()', ) try { operation = this.overlays.open(request) } catch (error) { void disposeOwner() throw error } void operation.closed.then(() => { void disposeOwner() }) return operation } }