370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
/**
|
|
* 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<TuiOverlayOutcome>
|
|
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<TuiOverlayCloseReason, 'error'>): 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<TuiFocusable>,
|
|
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<void> | 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<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
|
|
} {
|
|
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<TuiOverlayOutcome>()
|
|
const session: TuiOverlaySession & {
|
|
closeWith(reason: Exclude<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
|
|
} = {
|
|
get state(): TuiOverlayState {
|
|
return entry.state
|
|
},
|
|
closed: deferred.promise,
|
|
close: () => this.close(entry, outcome('closed')),
|
|
closeWith: (reason: Exclude<TuiOverlayCloseReason, 'error'>) =>
|
|
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<void> {
|
|
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<TuiFocusable>
|
|
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<TuiOverlayOutcome> {
|
|
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<TuiOverlayManager['open']> | 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
|
|
}
|
|
}
|