Merge branch 'master' into fix/web-zstd-session-logs
This commit is contained in:
@@ -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];',
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
128
packages/ui/app-boot/tests/config-reload.spec.ts
Normal file
128
packages/ui/app-boot/tests/config-reload.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Config hot-reload resilience of the booted include tree. `dsh-app-boot`
|
||||
* installs a fail-loud unhandled-rejection handler, so a `refresh()` that
|
||||
* rethrows a config-file parse error would kill a live app on one bad
|
||||
* `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
|
||||
* callback nobody else catches). These tests pin the vendored
|
||||
* `@cordisjs/plugin-include` contract that boot relies on: an invalid file
|
||||
* keeps the last good tree, and a valid re-read re-applies overlay patches
|
||||
* exactly like the initial load.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Include } from '@cordisjs/plugin-include'
|
||||
import { boot } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
|
||||
|
||||
interface TreeFixture {
|
||||
ctx: Context
|
||||
dir: string
|
||||
include: Include
|
||||
}
|
||||
|
||||
async function bootTree(configBody: string): Promise<TreeFixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'cordis.yml'), configBody)
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
|
||||
if (entry?.subtree === undefined) throw new Error('booted tree has no include entry')
|
||||
return { ctx, dir, include: entry.subtree as Include }
|
||||
}
|
||||
|
||||
function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
describe('include refresh with an invalid file', () => {
|
||||
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
// An empty file parses to `undefined` without a YAML error; it must be
|
||||
// treated exactly like a parse failure, not crash the entry walk.
|
||||
writeFileSync(join(dir, 'cordis.yml'), '')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include refresh with overlay patches', () => {
|
||||
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: base',
|
||||
" name: 'cordis:include'",
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
' - id: noop',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: patched',
|
||||
' - insert:',
|
||||
' - id: extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base')
|
||||
if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry')
|
||||
const include = entry.subtree as Include
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
|
||||
expect(entryConfig(ctx, 'extra')).toBeUndefined()
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
|
||||
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
|
||||
|
||||
// Hot-update of the include entry's own config (the `internal/update`
|
||||
// path): the new patches must apply now AND stick for later re-reads —
|
||||
// the listener vetoes the fiber restart, so it must persist the new
|
||||
// config itself or the next refresh() re-applies the old overlay.
|
||||
await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false)
|
||||
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
|
||||
// Removing every patch must revert to the file's own values: patching
|
||||
// may not bake earlier patch results into the cached parse.
|
||||
await entry.update({ config: { path: './base.yml', patches: [] } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
165
packages/ui/tui/src/extension.ts
Normal file
165
packages/ui/tui/src/extension.ts
Normal 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>
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
369
packages/ui/tui/src/overlay-manager.ts
Normal file
369
packages/ui/tui/src/overlay-manager.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
587
packages/ui/tui/tests/extension.spec.ts
Normal file
587
packages/ui/tui/tests/extension.spec.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
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
|
||||
onShow?: (component: Component) => void
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
fixture.onShow?.(component)
|
||||
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(firstComponent.invalidated).toBe(1)
|
||||
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(2)
|
||||
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('does not mount entries closed or aborted during component construction', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const closed = manager.open({
|
||||
create(host) {
|
||||
host.invalidate()
|
||||
host.close()
|
||||
return component(['closed during construction'])
|
||||
},
|
||||
})
|
||||
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborted = manager.open({
|
||||
signal: controller.signal,
|
||||
create() {
|
||||
controller.abort()
|
||||
return component(['aborted during construction'])
|
||||
},
|
||||
})
|
||||
await expect(aborted.closed).resolves.toEqual({ reason: 'aborted' })
|
||||
|
||||
const after = manager.open({ create: () => component(['after construction closes']) })
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(fixture.shown[0]?.component.render(40)).toEqual(['after construction closes'])
|
||||
await after.close()
|
||||
})
|
||||
|
||||
it('hides a handle returned after reentrant closure during mounting', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
fixture.onShow = (shown) => {
|
||||
;(shown as Component & { focused: boolean }).focused = true
|
||||
}
|
||||
const closed = manager.open({
|
||||
create(host) {
|
||||
return {
|
||||
get focused(): boolean {
|
||||
return false
|
||||
},
|
||||
set focused(_value: boolean) {
|
||||
host.close()
|
||||
},
|
||||
render: () => ['closed during mount'],
|
||||
invalidate() {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
|
||||
expect(fixture.shown[0]?.hidden).toBe(true)
|
||||
expect(manager.hasActiveOverlay()).toBe(false)
|
||||
|
||||
delete fixture.onShow
|
||||
const after = manager.open({ create: () => component(['after mount close']) })
|
||||
expect(fixture.shown[1]?.hidden).toBe(false)
|
||||
expect(fixture.shown[1]?.component.render(40)).toEqual(['after mount close'])
|
||||
await after.close()
|
||||
})
|
||||
|
||||
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')
|
||||
let invalidatingHost: TuiOverlayHost | undefined
|
||||
const invalidating = manager.open({
|
||||
create(host) {
|
||||
invalidatingHost = host
|
||||
return {
|
||||
render: () => ['invalidate'],
|
||||
invalidate() {
|
||||
throw invalidateError
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
const invalidationsBeforeFailure = fixture.invalidations
|
||||
invalidatingHost?.invalidate()
|
||||
invalidatingHost?.invalidate()
|
||||
expect(fixture.invalidations).toBe(invalidationsBeforeFailure)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user