feat(tui): add interactive extension service

This commit is contained in:
ZiyaZhang
2026-07-22 21:30:08 -07:00
parent 3e3ea47296
commit 2a1b3139f9
19 changed files with 1546 additions and 62 deletions

View File

@@ -758,6 +758,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'tui',
summary: 'Optional terminal-local interaction service provided by one mounted TUI.',
methods: [
{
signature: 'abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession',
jsDoc: '/**\n * Queue an interactive overlay owned by the calling plugin fiber.\n *\n * The TUI displays one overlay at a time in FIFO order. Disposing the caller\n * removes a queued overlay or closes an active one before plugin teardown\n * settles. This live presentation is neither logged nor replayed.\n *\n * @param request - component factory, layout constraints, and cancellation.\n * @returns the effect-owned overlay session.\n * @throws when the TUI has begun shutting down.\n */',
},
],
},
{
key: 'userInteraction',
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
@@ -2031,6 +2041,58 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ToolSchema',
declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',
},
{
name: 'TuiComponent',
declaration: 'export interface TuiComponent {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}',
},
{
name: 'TuiFocusable',
declaration: 'export interface TuiFocusable {\n focused: boolean;\n}',
},
{
name: 'TuiOverlayAnchor',
declaration: 'export type TuiOverlayAnchor = \'center\' | \'top-left\' | \'top-right\' | \'bottom-left\' | \'bottom-right\' | \'top-center\' | \'bottom-center\' | \'left-center\' | \'right-center\';',
},
{
name: 'TuiOverlayCloseReason',
declaration: 'export type TuiOverlayCloseReason = \'closed\' | \'aborted\' | \'owner-disposed\' | \'tui-disposed\' | \'error\';',
},
{
name: 'TuiOverlayHost',
declaration: 'export interface TuiOverlayHost {\n readonly signal: AbortSignal;\n readonly viewport: TuiViewport;\n readonly theme: TuiTheme;\n display(value: string): string;\n invalidate(): void;\n close(): void;\n}',
},
{
name: 'TuiOverlayMargin',
declaration: 'export interface TuiOverlayMargin {\n readonly top?: number;\n readonly right?: number;\n readonly bottom?: number;\n readonly left?: number;\n}',
},
{
name: 'TuiOverlayOptions',
declaration: 'export interface TuiOverlayOptions {\n readonly width?: number | `${number}%`;\n readonly minWidth?: number;\n readonly maxHeight?: number | `${number}%`;\n readonly anchor?: TuiOverlayAnchor;\n readonly margin?: number | TuiOverlayMargin;\n}',
},
{
name: 'TuiOverlayOutcome',
declaration: 'export type TuiOverlayOutcome = {\n readonly reason: Exclude<TuiOverlayCloseReason, \'error\'>;\n} | {\n readonly reason: \'error\';\n readonly error: unknown;\n};',
},
{
name: 'TuiOverlayRequest',
declaration: 'export interface TuiOverlayRequest {\n readonly create: (host: TuiOverlayHost) => TuiComponent & Partial<TuiFocusable>;\n readonly options?: TuiOverlayOptions;\n readonly signal?: AbortSignal;\n}',
},
{
name: 'TuiOverlaySession',
declaration: 'export interface TuiOverlaySession {\n readonly state: TuiOverlayState;\n readonly closed: Promise<TuiOverlayOutcome>;\n close(): Promise<TuiOverlayOutcome>;\n}',
},
{
name: 'TuiOverlayState',
declaration: 'export type TuiOverlayState = \'queued\' | \'active\' | \'closed\';',
},
{
name: 'TuiTheme',
declaration: 'export interface TuiTheme {\n readonly text: (value: string) => string;\n readonly muted: (value: string) => string;\n readonly dim: (value: string) => string;\n readonly accent: (value: string) => string;\n readonly success: (value: string) => string;\n readonly warning: (value: string) => string;\n readonly error: (value: string) => string;\n readonly bold: (value: string) => string;\n}',
},
{
name: 'TuiViewport',
declaration: 'export interface TuiViewport {\n readonly columns: number;\n readonly rows: number;\n}',
},
{
name: 'TurnEndReason',
declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];',

View File

@@ -10,11 +10,11 @@ Integrations that expose the agent to an external editor or client. These are **
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, answers `ctx.userInteraction`, and hosts effect-owned plugin overlays | `ctx.tui` (drives `ctx.agents`) |
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.

View File

@@ -8,6 +8,8 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
@@ -57,7 +59,7 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
maxToolOutputLines: 6
```
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
## Color

View File

@@ -0,0 +1,165 @@
/**
* Public interactive-extension contract for one mounted TUI front door.
*
* Plugins receive terminal-specific rendering primitives without access to
* the live pi-tui tree, focus controller, overlay handles, or terminal
* lifecycle. Registrations and open overlays remain owned by the calling
* Cordis fiber.
* @module @deepseek-ai/dsh-tui/extension
*/
/** Terminal component shape accepted from a trusted TUI extension. */
export interface TuiComponent {
/**
* Render this component for the supplied viewport width.
* @param width - Available terminal columns.
* @returns terminal lines owned by this component.
*/
render(width: number): string[]
/**
* Handle one terminal input sequence while this component owns focus.
* @param data - Raw terminal input sequence.
*/
handleInput?(data: string): void
/** Receive key-release events instead of having them filtered by the host. */
wantsKeyRelease?: boolean
/** Drop cached rendering derived from theme, size, or component state. */
invalidate(): void
}
/** Optional focus state forwarded by the host to a component. */
export interface TuiFocusable {
/** Whether the component currently owns terminal focus. */
focused: boolean
}
/** Read-only semantic color roles supplied by the mounted TUI. */
export interface TuiTheme {
/** Render ordinary foreground text. */
readonly text: (value: string) => string
/** Render secondary information. */
readonly muted: (value: string) => string
/** Render low-emphasis hints. */
readonly dim: (value: string) => string
/** Render the active accent role. */
readonly accent: (value: string) => string
/** Render a successful outcome. */
readonly success: (value: string) => string
/** Render a warning. */
readonly warning: (value: string) => string
/** Render an error. */
readonly error: (value: string) => string
/** Apply the host's bold role. */
readonly bold: (value: string) => string
}
/** Current terminal viewport exposed without the mutable Terminal object. */
export interface TuiViewport {
/** Terminal columns. */
readonly columns: number
/** Terminal rows. */
readonly rows: number
}
/** Supported overlay anchor points. */
export type TuiOverlayAnchor =
| 'center'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
| 'top-center'
| 'bottom-center'
| 'left-center'
| 'right-center'
/** Terminal-edge spacing for an overlay. */
export interface TuiOverlayMargin {
/** Rows reserved above the overlay. */
readonly top?: number
/** Columns reserved to the right of the overlay. */
readonly right?: number
/** Rows reserved below the overlay. */
readonly bottom?: number
/** Columns reserved to the left of the overlay. */
readonly left?: number
}
/** Position and size constraints retained under TUI host ownership. */
export interface TuiOverlayOptions {
/** Width in columns or as a percentage of terminal width. */
readonly width?: number | `${number}%`
/** Minimum width in columns. */
readonly minWidth?: number
/** Maximum height in rows or as a percentage of terminal height. */
readonly maxHeight?: number | `${number}%`
/** Overlay anchor; defaults to the terminal center. */
readonly anchor?: TuiOverlayAnchor
/** Terminal-edge spacing. */
readonly margin?: number | TuiOverlayMargin
}
/** Capabilities available while an overlay component is queued or visible. */
export interface TuiOverlayHost {
/**
* Aborts when the request, caller fiber, overlay session, or TUI closes.
* Extension work started for the overlay must cooperate with this signal.
*/
readonly signal: AbortSignal
/** Current viewport; a fresh immutable value is returned on every read. */
readonly viewport: TuiViewport
/** Semantic styles that follow terminal color-scheme changes. */
readonly theme: TuiTheme
/**
* Escape control characters in untrusted display text.
* @param value - text crossing into terminal presentation.
* @returns a printable representation that cannot emit terminal controls.
*/
display(value: string): string
/** Invalidate the component and schedule one contained terminal redraw. */
invalidate(): void
/** Close this overlay normally; repeated calls are no-ops. */
close(): void
}
/** One effect-owned request to create an interactive overlay. */
export interface TuiOverlayRequest {
/**
* Construct the component when this request reaches the front of the modal
* queue. A throw closes the session with `reason: "error"`.
*/
readonly create: (host: TuiOverlayHost) => TuiComponent & Partial<TuiFocusable>
/** Host-owned position and size constraints. */
readonly options?: TuiOverlayOptions
/** Optional request cancellation in addition to caller and TUI ownership. */
readonly signal?: AbortSignal
}
/** Stable reason an overlay stopped being queued or visible. */
export type TuiOverlayCloseReason =
| 'closed'
| 'aborted'
| 'owner-disposed'
| 'tui-disposed'
| 'error'
/** Settled overlay outcome; component failures retain their original value. */
export type TuiOverlayOutcome =
| { readonly reason: Exclude<TuiOverlayCloseReason, 'error'> }
| { readonly reason: 'error'; readonly error: unknown }
/** Live state of an overlay operation. */
export type TuiOverlayState = 'queued' | 'active' | 'closed'
/** Handle returned to the extension that opened an overlay. */
export interface TuiOverlaySession {
/** Current queue/display state. */
readonly state: TuiOverlayState
/** Settles exactly once after the overlay leaves the queue or display. */
readonly closed: Promise<TuiOverlayOutcome>
/**
* Close the overlay normally and await its settled outcome.
* @returns the same immutable value exposed through {@link closed}.
*/
close(): Promise<TuiOverlayOutcome>
}

View File

@@ -31,13 +31,12 @@ import {
type EditorTheme,
type Focusable,
type MarkdownTheme,
type OverlayHandle,
type SelectListTheme,
type SlashCommand,
type Terminal,
type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import { Service, type Context, type Fiber } from 'cordis'
import z from 'schemastery'
import {
installAgentLlmTarget,
@@ -90,6 +89,62 @@ import {
type AskUserQuestionItem,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import {
TuiExtensionServiceImpl,
TuiOverlayManager,
} from './overlay-manager.ts'
import type {
TuiOverlayRequest,
TuiOverlaySession,
TuiTheme,
} from './extension.ts'
export type {
TuiComponent,
TuiFocusable,
TuiOverlayAnchor,
TuiOverlayCloseReason,
TuiOverlayHost,
TuiOverlayMargin,
TuiOverlayOptions,
TuiOverlayOutcome,
TuiOverlayRequest,
TuiOverlaySession,
TuiOverlayState,
TuiTheme,
TuiViewport,
} from './extension.ts'
declare module 'cordis' {
interface Context {
/** Terminal-only interaction service, available only while a TUI is mounted. */
tui: TuiExtensionService
}
}
/**
* Optional terminal-local interaction service provided by one mounted TUI.
*
* The concrete provider retains pi-tui, focus, and terminal lifecycle state.
* Plugins receive only effect-owned overlay sessions.
*/
export abstract class TuiExtensionService extends Service {
/** Exact agent driven by this terminal instance. */
abstract readonly agent: Agent
/**
* Queue an interactive overlay owned by the calling plugin fiber.
*
* The TUI displays one overlay at a time in FIFO order. Disposing the caller
* removes a queued overlay or closes an active one before plugin teardown
* settles. This live presentation is neither logged nor replayed.
*
* @param request - component factory, layout constraints, and cancellation.
* @returns the effect-owned overlay session.
* @throws when the TUI has begun shutting down.
*/
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
}
export const name = 'ui-tui'
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
@@ -1290,7 +1345,7 @@ interface PendingQuestion {
resolve(answer: AskUserQuestionAnswer): void
reject(error: unknown): void
onAbort: () => void
overlay: OverlayHandle | undefined
overlay: TuiOverlaySession | undefined
}
/** Add session candidates to pi-tui's existing command/file provider. */
@@ -1511,7 +1566,8 @@ export function createTuiChat(
const commandControllers = new Set<AbortController>()
const referenceControllers = new Set<AbortController>()
let activeQuestion: PendingQuestion | undefined
let modelOverlay: OverlayHandle | undefined
let modelOverlay: TuiOverlaySession | undefined
let tuiServiceFiber: Fiber | undefined
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
let contextWindow: number | undefined
let contextResolution: Promise<
@@ -1569,6 +1625,41 @@ export function createTuiChat(
requestRender()
}
const extensionTheme: TuiTheme = Object.freeze({
text: (value: string) => palette.text(value),
muted: (value: string) => palette.muted(value),
dim: (value: string) => palette.dim(value),
accent: (value: string) => palette.accent(value),
success: (value: string) => palette.success(value),
warning: (value: string) => palette.warning(value),
error: (value: string) => palette.error(value),
bold: (value: string) => palette.bold(value),
})
const overlayManager = new TuiOverlayManager({
viewport: () => Object.freeze({
columns: runtime.terminal.columns,
rows: runtime.terminal.rows,
}),
theme: () => extensionTheme,
display: displayText,
show: (component, options) => ui.showOverlay(component, options === undefined
? undefined
: {
...options,
...typeof options.margin === 'object'
? { margin: { ...options.margin } }
: {},
}),
invalidate: requestRender,
reportError: (error) => {
const message = errorChain(error)
ctx.logger.warn(`ui-tui: overlay failed: ${message}`)
/* v8 ignore next -- shutdown removes overlays before the terminal stops */
if (disposed) return
appendNotice(`TUI overlay failed: ${message}`, 'error')
},
})
const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
@@ -1608,29 +1699,29 @@ export function createTuiChat(
appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
return
}
modelOverlay?.hide()
modelOverlay = undefined
const close = (): void => {
modelOverlay?.hide()
modelOverlay = undefined
requestRender()
}
const dialog = new ModelDialog(
choices,
target.current,
resolved.maxModelOptions,
palette,
(selected) => {
close()
selectModel(selected)
void modelOverlay?.close()
const session = overlayManager.open({
create: () => new ModelDialog(
choices,
target.current,
resolved.maxModelOptions,
palette,
(selected) => {
void session.close()
selectModel(selected)
},
() => { void session.close() },
),
options: {
width: resolved.modelDialogWidth,
maxHeight: resolved.modelDialogMaxHeight,
anchor: 'center',
margin: 1,
},
close,
)
modelOverlay = ui.showOverlay(dialog, {
width: resolved.modelDialogWidth,
maxHeight: resolved.modelDialogMaxHeight,
anchor: 'center',
margin: 1,
})
modelOverlay = session
void session.closed.then(() => {
if (modelOverlay === session) modelOverlay = undefined
})
requestRender()
}
@@ -1933,7 +2024,7 @@ export function createTuiChat(
}
const rejectQuestion = (pending: PendingQuestion): void => {
pending.overlay?.hide()
void pending.overlay?.close()
pending.overlay = undefined
removeAbortListener(pending)
pending.reject(new UserInteractionError(
@@ -1956,31 +2047,48 @@ export function createTuiChat(
startNextQuestion()
return
}
const dialog = new QuestionDialog(
question,
pending.index + 1,
pending.request.questions.length,
pending.request.questions.length - pending.answers.length,
resolved.maxQuestionOptions,
palette,
(selection) => {
pending.overlay?.hide()
pending.overlay = undefined
pending.answers.push({ id: question.id, ...selection })
pending.index += 1
show()
const session = overlayManager.open({
...pending.request.signal === undefined ? {} : { signal: pending.request.signal },
create: () => new QuestionDialog(
question,
pending.index + 1,
pending.request.questions.length,
pending.request.questions.length - pending.answers.length,
resolved.maxQuestionOptions,
palette,
(selection) => {
pending.overlay = undefined
void session.close()
pending.answers.push({ id: question.id, ...selection })
pending.index += 1
show()
},
() => {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
},
),
options: {
width: resolved.questionDialogWidth,
maxHeight: resolved.questionDialogMaxHeight,
anchor: 'bottom-left',
margin: { bottom: 1 },
},
() => {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
},
)
pending.overlay = ui.showOverlay(dialog, {
width: resolved.questionDialogWidth,
maxHeight: resolved.questionDialogMaxHeight,
anchor: 'bottom-left',
margin: { bottom: 1 },
})
pending.overlay = session
void session.closed.then((result) => {
if (pending.overlay !== session) return
pending.overlay = undefined
/* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */
if (result.reason !== 'error') return
activeQuestion = undefined
removeAbortListener(pending)
pending.reject(new UserInteractionError(
`ask_user_question TUI failed: ${errorChain(result.error)}`,
'ASK_ABORTED',
))
startNextQuestion()
})
requestRender()
}
@@ -2051,20 +2159,23 @@ export function createTuiChat(
const shutdown = (exitProcess: boolean): Promise<void> => {
shuttingDown ??= (async () => {
disposed = true
overlayManager.beginShutdown()
contextResolution = undefined
clearStatus()
modelOverlay?.hide()
modelOverlay = undefined
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
commandControllers.clear()
for (const controller of referenceControllers) controller.abort(new Error('TUI disposed'))
referenceControllers.clear()
await tuiServiceFiber?.dispose()
tuiServiceFiber = undefined
if (activeQuestion !== undefined) {
const pending = activeQuestion
activeQuestion = undefined
rejectQuestion(pending)
}
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
await overlayManager.dispose()
modelOverlay = undefined
disposeUserInteraction()
await runtime.terminal.drainInput(100, 20)
ui.stop()
@@ -2510,7 +2621,7 @@ export function createTuiChat(
}
const removeInputListener = ui.addInputListener((data) => {
if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined
if (overlayManager.hasActiveOverlay()) return undefined
if (matchesKey(data, Key.ctrl('o'))) {
toggleTools()
return { consume: true }
@@ -2654,6 +2765,9 @@ export function createTuiChat(
ui.stop()
throw error
}
tuiServiceFiber = ctx.inject([], (serviceCtx) => {
new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager)
})
startBannerReveal()
return {

View File

@@ -0,0 +1,353 @@
/**
* 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
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(): void {
try {
this.component.invalidate()
} catch (error) {
this.fail(error)
}
}
}
/** 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
}
const guarded = new GuardedOverlayComponent(component, (error) => {
this.fail(entry, error)
})
try {
entry.handle = this.driver.show(guarded, entry.request.options)
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 (entry.state !== 'active') 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 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
try {
entry.handle?.hide()
} catch (error) {
this.report(error)
}
delete entry.handle
}
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
}
}

View File

@@ -0,0 +1,518 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
Component,
OverlayHandle,
} from '@earendil-works/pi-tui'
import type {
TuiComponent,
TuiOverlayHost,
TuiOverlayOptions,
TuiOverlaySession,
TuiTheme,
} from '../src/extension.ts'
import {
TuiExtensionServiceImpl,
TuiOverlayManager,
type TuiOverlayDriver,
} from '../src/overlay-manager.ts'
const theme: TuiTheme = Object.freeze({
text: (value: string) => `text:${value}`,
muted: (value: string) => `muted:${value}`,
dim: (value: string) => `dim:${value}`,
accent: (value: string) => `accent:${value}`,
success: (value: string) => `success:${value}`,
warning: (value: string) => `warning:${value}`,
error: (value: string) => `error:${value}`,
bold: (value: string) => `bold:${value}`,
})
interface ShownOverlay {
component: Component
options: TuiOverlayOptions | undefined
hidden: boolean
focused: boolean
}
interface DriverFixture {
driver: TuiOverlayDriver
shown: ShownOverlay[]
errors: unknown[]
invalidations: number
showError?: unknown
}
function driverFixture(): DriverFixture {
const fixture: DriverFixture = {
shown: [],
errors: [],
invalidations: 0,
driver: undefined as never,
}
fixture.driver = {
viewport: () => ({ columns: 96, rows: 32 }),
theme: () => theme,
display: value => `safe:${value}`,
show(component, options) {
if (fixture.showError !== undefined) throw fixture.showError
const shown: ShownOverlay = {
component,
options,
hidden: false,
focused: true,
}
fixture.shown.push(shown)
const handle: OverlayHandle = {
hide() {
shown.hidden = true
shown.focused = false
},
setHidden(hidden) {
shown.hidden = hidden
},
isHidden: () => shown.hidden,
focus() {
shown.focused = true
},
unfocus() {
shown.focused = false
},
isFocused: () => shown.focused,
}
return handle
},
invalidate() {
fixture.invalidations += 1
},
reportError(error) {
fixture.errors.push(error)
},
}
return fixture
}
function component(lines = ['overlay']): TuiComponent {
return {
render: () => lines,
invalidate() {},
}
}
async function microtask(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('TuiOverlayManager', () => {
it('serializes overlays, exposes the constrained host, and settles normal close once', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
let firstHost: TuiOverlayHost | undefined
const firstComponent = {
focused: false,
wantsKeyRelease: true,
inputs: [] as string[],
invalidated: 0,
render: (width: number) => [`first:${String(width)}`],
handleInput(data: string) {
this.inputs.push(data)
},
invalidate() {
this.invalidated += 1
},
}
const first = manager.open({
create(host) {
firstHost = host
return firstComponent
},
options: { width: '75%', minWidth: 24, maxHeight: 20, anchor: 'center', margin: { bottom: 1 } },
})
const secondOptions: TuiOverlayOptions = { width: 40, margin: { bottom: 2 } }
const second = manager.open({
create: () => component(['second']),
options: secondOptions,
})
;(secondOptions as { width: number }).width = 80
;(secondOptions.margin as { bottom: number }).bottom = 4
expect(manager.hasActiveOverlay()).toBe(true)
expect(first.state).toBe('active')
expect(second.state).toBe('queued')
expect(fixture.shown).toHaveLength(1)
expect(fixture.shown[0]?.options).toEqual({
width: '75%',
minWidth: 24,
maxHeight: 20,
anchor: 'center',
margin: { bottom: 1 },
})
expect(firstHost?.viewport).toEqual({ columns: 96, rows: 32 })
expect(Object.isFrozen(firstHost?.viewport)).toBe(true)
expect(firstHost?.theme.accent('x')).toBe('accent:x')
expect(firstHost?.display('\u001b')).toBe('safe:\u001b')
firstHost?.invalidate()
expect(fixture.shown[0]?.component.render(40)).toEqual(['first:40'])
fixture.shown[0]!.component.handleInput?.('x')
fixture.shown[0]!.component.invalidate()
expect(firstComponent.inputs).toEqual(['x'])
expect(firstComponent.invalidated).toBe(1)
expect(fixture.shown[0]?.component.wantsKeyRelease).toBe(true)
;(fixture.shown[0]?.component as Component & { focused: boolean }).focused = true
expect(firstComponent.focused).toBe(true)
expect((fixture.shown[0]?.component as Component & { focused: boolean }).focused).toBe(true)
const firstOutcome = await first.close()
expect(firstOutcome).toEqual({ reason: 'closed' })
expect(await first.close()).toBe(firstOutcome)
expect(firstHost?.signal.aborted).toBe(true)
const beforeClosedInvalidation = fixture.invalidations
firstHost?.invalidate()
expect(fixture.invalidations).toBe(beforeClosedInvalidation)
await microtask()
expect(first.state).toBe('closed')
expect(second.state).toBe('active')
expect(fixture.shown[0]?.hidden).toBe(true)
expect(fixture.shown[1]?.options).toEqual({ width: 40, margin: { bottom: 2 } })
expect(Object.isFrozen(fixture.shown[1]?.options)).toBe(true)
expect(Object.isFrozen(fixture.shown[1]?.options?.margin)).toBe(true)
expect(fixture.shown[1]?.component.wantsKeyRelease).toBe(false)
expect((fixture.shown[1]?.component as Component & { focused: boolean }).focused).toBe(false)
;(fixture.shown[1]?.component as Component & { focused: boolean }).focused = true
fixture.shown[1]!.component.handleInput?.('ignored')
await second.close()
await microtask()
const numericMargin = manager.open({
create: () => component(['numeric margin']),
options: { margin: 1 },
})
expect(fixture.shown[2]?.options).toEqual({ margin: 1 })
await numericMargin.close()
await microtask()
const emptyOptions = manager.open({
create: () => component(['empty options']),
options: {},
})
expect(fixture.shown[3]?.options).toEqual({})
await emptyOptions.close()
await microtask()
expect(manager.hasActiveOverlay()).toBe(false)
await manager.dispose()
await manager.dispose()
})
it('removes pre-aborted, active, and queued requests without activating cancelled work', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const preAborted = new AbortController()
preAborted.abort()
const pre = manager.open({
signal: preAborted.signal,
create: () => component(['never']),
})
expect(await pre.closed).toEqual({ reason: 'aborted' })
expect(fixture.shown).toHaveLength(0)
const activeAbort = new AbortController()
let activeHost: TuiOverlayHost | undefined
const active = manager.open({
signal: activeAbort.signal,
create(host) {
activeHost = host
return component(['active'])
},
})
const queuedAbort = new AbortController()
const queued = manager.open({
signal: queuedAbort.signal,
create: () => component(['queued']),
})
queuedAbort.abort()
expect(await queued.closed).toEqual({ reason: 'aborted' })
expect(queued.state).toBe('closed')
activeAbort.abort()
expect(await active.closed).toEqual({ reason: 'aborted' })
expect(activeHost?.signal.aborted).toBe(true)
await microtask()
expect(fixture.shown).toHaveLength(1)
expect(manager.hasActiveOverlay()).toBe(false)
})
it('stops admission and disposes active and queued overlays with the TUI', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const active = manager.open({ create: () => component(['active']) })
const queued = manager.open({ create: () => component(['queued']) })
manager.beginShutdown()
expect(() => manager.open({ create: () => component() })).toThrow('TUI is shutting down')
await manager.dispose()
expect(await active.closed).toEqual({ reason: 'tui-disposed' })
expect(await queued.closed).toEqual({ reason: 'tui-disposed' })
expect(fixture.shown).toHaveLength(1)
expect(fixture.shown[0]?.hidden).toBe(true)
await manager.dispose()
})
it('contains factory, mount, render, input, and invalidation failures', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const factoryError = new Error('factory failed')
const factory = manager.open({
create() {
throw factoryError
},
})
const afterFactory = manager.open({ create: () => component(['after factory']) })
expect(await factory.closed).toEqual({ reason: 'error', error: factoryError })
await microtask()
expect(afterFactory.state).toBe('active')
await afterFactory.close()
await microtask()
const showError = new Error('show failed')
fixture.showError = showError
const show = manager.open({ create: () => component(['show']) })
expect(await show.closed).toEqual({ reason: 'error', error: showError })
delete fixture.showError
await microtask()
const renderError = new Error('render failed')
const rendering = manager.open({
create: () => ({
render() {
throw renderError
},
invalidate() {
throw new Error('must be suppressed after the first failure')
},
}),
})
const renderComponent = fixture.shown.at(-1)!.component
expect(renderComponent.render(20)).toEqual([])
renderComponent.invalidate()
expect(fixture.errors.filter(error => error === renderError)).toHaveLength(1)
expect(await rendering.closed).toEqual({ reason: 'error', error: renderError })
await microtask()
const inputError = new Error('input failed')
const input = manager.open({
create: () => ({
render: () => ['input'],
handleInput() {
throw inputError
},
invalidate() {},
}),
})
fixture.shown.at(-1)!.component.handleInput?.('x')
expect(await input.closed).toEqual({ reason: 'error', error: inputError })
await microtask()
const invalidateError = new Error('invalidate failed')
const invalidating = manager.open({
create: () => ({
render: () => ['invalidate'],
invalidate() {
throw invalidateError
},
}),
})
fixture.shown.at(-1)!.component.invalidate()
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidateError })
await microtask()
const focusError = new Error('focus failed')
const focus = manager.open({
create: () => ({
get focused(): boolean {
throw focusError
},
set focused(_value: boolean) {
throw new Error('focus assignment failed')
},
get wantsKeyRelease(): boolean {
throw new Error('key-release query failed')
},
render: () => ['focus'],
invalidate() {},
}),
})
const guarded = fixture.shown.at(-1)!.component as Component & { focused: boolean }
expect(guarded.focused).toBe(false)
guarded.focused = true
expect(guarded.wantsKeyRelease).toBe(false)
expect(await focus.closed).toEqual({ reason: 'error', error: focusError })
expect(fixture.errors).toEqual([
factoryError,
showError,
renderError,
inputError,
invalidateError,
focusError,
])
})
it('contains host redraw, overlay removal, and error-reporter failures', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
let host: TuiOverlayHost | undefined
const invalidationError = new Error('redraw failed')
let redrawFails = false
fixture.driver.invalidate = () => {
if (redrawFails) throw invalidationError
}
fixture.driver.reportError = () => { throw new Error('report failed') }
const invalidating = manager.open({
create(value) {
host = value
return component()
},
})
redrawFails = true
host?.invalidate()
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidationError })
await microtask()
redrawFails = false
fixture.driver.invalidate = () => {}
const hideError = new Error('hide failed')
fixture.driver.show = () => ({
hide() { throw hideError },
setHidden() {},
isHidden: () => false,
focus() {},
unfocus() {},
isFocused: () => true,
})
const hiding = manager.open({
create(value) {
host = value
return component()
},
})
host?.close()
expect(await hiding.closed).toEqual({ reason: 'closed' })
})
})
describe('TuiExtensionService', () => {
it('binds an open overlay to the calling plugin fiber', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const agent = {} as Agent
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, manager)
})
await provider
let session: TuiOverlaySession | undefined
let host: TuiOverlayHost | undefined
const consumer = ctx.inject(['tui'], (consumerCtx) => {
expect(consumerCtx.tui.agent).toBe(agent)
session = consumerCtx.tui.openOverlay({
create(value) {
host = value
return component(['plugin'])
},
})
})
await consumer
expect(session?.state).toBe('active')
await consumer.dispose()
expect(await session?.closed).toEqual({ reason: 'owner-disposed' })
expect(host?.signal.aborted).toBe(true)
await provider.dispose()
await manager.dispose()
await ctx.fiber.dispose()
})
it('unloads and reloads dependent plugins with the mounted TUI service', async () => {
const ctx = new Context()
const agent = {} as Agent
const sessions: TuiOverlaySession[] = []
let starts = 0
const consumer = ctx.inject(['tui'], (consumerCtx) => {
starts += 1
sessions.push(consumerCtx.tui.openOverlay({ create: () => component([`start:${String(starts)}`]) }))
})
const firstFixture = driverFixture()
const firstManager = new TuiOverlayManager(firstFixture.driver)
const firstProvider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, firstManager)
})
await firstProvider
await consumer
expect(starts).toBe(1)
await firstProvider.dispose()
expect(await sessions[0]?.closed).toEqual({ reason: 'owner-disposed' })
const secondFixture = driverFixture()
const secondManager = new TuiOverlayManager(secondFixture.driver)
const secondProvider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, secondManager)
})
await secondProvider
await vi.waitFor(() => { expect(starts).toBe(2) })
await sessions[1]?.close()
await consumer.dispose()
await secondProvider.dispose()
await firstManager.dispose()
await secondManager.dispose()
await ctx.fiber.dispose()
})
it('rejects new service work after terminal shutdown begins', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
})
await provider
manager.beginShutdown()
const consumer = ctx.inject(['tui'], (consumerCtx) => {
expect(() => consumerCtx.tui.openOverlay({ create: () => component() }))
.toThrow('TUI is shutting down')
})
await consumer
await consumer.dispose()
await provider.dispose()
await manager.dispose()
await ctx.fiber.dispose()
})
it('does not admit an overlay when called from an unloading plugin', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
})
await provider
let error: unknown
const consumer = ctx.inject(['tui'], (consumerCtx) => {
consumerCtx.effect(() => () => {
try {
consumerCtx.tui.openOverlay({ create: () => component() })
} catch (value) {
error = value
}
})
})
await consumer
await consumer.dispose()
expect(error).toMatchObject({ code: 'INACTIVE_EFFECT' })
expect(fixture.shown).toHaveLength(0)
await provider.dispose()
await manager.dispose()
await ctx.fiber.dispose()
})
})

View File

@@ -19,6 +19,8 @@ import {
mountTui,
renderSkillInvocation,
resolveTuiConfig,
type TuiOverlayHost,
type TuiOverlaySession,
type TuiRuntime,
} from '../src/index.ts'
import {
@@ -1379,6 +1381,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('advertised by multiple providers')
expect(result.terminal.output).toContain('already alpha/a1')
result.terminal.send('/model')
result.terminal.send('\r')
result.terminal.send('/model')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Select model')
result.terminal.send('\x1b')
await tick()
result.agent.status = 'running'
result.terminal.send('/model')
result.terminal.send('\r')
@@ -2193,6 +2204,141 @@ describe('TUI user-interaction dialogs', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
await result.ctx.fiber.dispose()
})
it('rejects malformed questions when a dialog cannot be constructed', async () => {
const result = await setup()
const broken = {
id: 'broken',
question: 'Broken question',
get options(): never {
throw new Error('question setup failed')
},
}
const answer = result.ctx.userInteraction.ask({ questions: [broken] })
await expect(answer).rejects.toThrow('ask_user_question TUI failed: question setup failed')
await tick()
expect(result.terminal.output).toContain('TUI overlay failed: question setup failed')
await dispose(result)
})
})
describe('TUI extension service', () => {
it('renders effect-owned plugin overlays in the shared FIFO and restores editor input', async () => {
const result = await setup()
const sessions: TuiOverlaySession[] = []
const hosts: TuiOverlayHost[] = []
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
expect(pluginCtx.tui.agent).toBe(result.agent)
for (const label of ['first', 'second']) {
sessions.push(pluginCtx.tui.openOverlay({
create(host) {
hosts.push(host)
return {
focused: false,
render: width => [
host.theme.accent(`${label} plugin overlay`),
[
host.theme.text('text'),
host.theme.muted('muted'),
host.theme.dim('dim'),
host.theme.success('success'),
host.theme.warning('warning'),
host.theme.error('error'),
host.theme.bold('bold'),
].join(' '),
`${String(host.viewport.columns)}x${String(host.viewport.rows)} · ${String(width)}`,
],
handleInput(data) {
host.invalidate()
if (data === label[0]) host.close()
},
invalidate() {},
}
},
options: { width: 50, maxHeight: 8, anchor: 'center', margin: 1 },
}))
}
})
await plugin
await vi.waitFor(() => {
expect(result.terminal.output).toContain('first plugin overlay')
})
expect(sessions.map(session => session.state)).toEqual(['active', 'queued'])
expect(hosts).toHaveLength(1)
const question = result.ctx.userInteraction.ask({
questions: [{ id: 'after-plugin', question: 'Question after plugins?', options: [{ label: 'Yes' }] }],
})
result.terminal.send('f')
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'closed' })
await vi.waitFor(() => {
expect(result.terminal.output).toContain('second plugin overlay')
})
expect(hosts).toHaveLength(2)
expect(sessions[1]?.state).toBe('active')
result.terminal.send('s')
await expect(sessions[1]!.closed).resolves.toEqual({ reason: 'closed' })
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Question after plugins?')
})
result.terminal.send('\r')
await expect(question).resolves.toEqual({
answers: [{ id: 'after-plugin', selected: ['Yes'] }],
})
result.terminal.send('editor works again')
result.terminal.send('\r')
expect(result.agent.sent.at(-1)).toEqual([{ type: 'text', text: 'editor works again' }])
await plugin.dispose()
await dispose(result)
})
it('unloads and reloads dependent plugins with the mounted TUI', async () => {
const result = await setup()
const sessions: TuiOverlaySession[] = []
const signals: AbortSignal[] = []
let starts = 0
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
starts += 1
sessions.push(pluginCtx.tui.openOverlay({
create(host) {
signals.push(host.signal)
return {
render: () => [`plugin mount ${String(starts)}`],
invalidate() {},
}
},
}))
})
await plugin
await vi.waitFor(() => {
expect(result.terminal.output).toContain('plugin mount 1')
})
await result.controller.dispose()
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'owner-disposed' })
expect(signals[0]?.aborted).toBe(true)
expect(result.ctx.get('tui')).toBeUndefined()
const secondTerminal = new FakeTerminal()
const secondController = createTuiChat(result.ctx, {
sessionId: result.agent.id,
color: false,
welcome: 'Mounted again.',
}, {
terminal: secondTerminal,
exit: vi.fn(),
})
await vi.waitFor(() => {
expect(starts).toBe(2)
expect(secondTerminal.output).toContain('plugin mount 2')
})
await sessions[1]?.close()
await secondController.dispose()
await plugin.dispose()
await result.ctx.fiber.dispose()
})
})
describe('terminal mounting', () => {
@@ -2355,6 +2501,7 @@ describe('terminal mounting', () => {
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
expect(terminal.stopped).toBe(1)
expect(terminal.progress).toEqual([false, true, false])
expect(ctx.get('tui')).toBeUndefined()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
session.append('assistant/chunk', {