Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Host-workspace discovery for TUI `@file` completion. The index contains
|
||||
* paths only: selected values remain ordinary prompt text and file contents
|
||||
* stay behind the model-facing `read` tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tui/file-autocomplete
|
||||
*/
|
||||
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
/** Default maximum file and directory candidates rendered for one query. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
|
||||
/** Default maximum entries retained in one workspace search index. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000
|
||||
/** Directory basenames omitted from traversal unless the deployment overrides them. */
|
||||
export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const
|
||||
|
||||
/** Resolved limits and exclusions for one TUI workspace index. */
|
||||
export interface FileSearchConfig {
|
||||
/** Maximum ranked candidates returned for one query. */
|
||||
maxResults: number
|
||||
/** Maximum indexed files and directories. */
|
||||
maxEntries: number
|
||||
/** Directory basenames never traversed or offered. */
|
||||
excludedDirectories: readonly string[]
|
||||
}
|
||||
|
||||
/** One path-only completion candidate inside the session cwd. */
|
||||
export interface FileSearchCandidate {
|
||||
/** User-facing path accepted by the normal prompt and filesystem tools. */
|
||||
path: string
|
||||
/** Directories keep completion open; files finish the mention. */
|
||||
kind: 'file' | 'directory'
|
||||
}
|
||||
|
||||
/** Active `@` token ending at the editor cursor. */
|
||||
export interface ActiveAtToken {
|
||||
/** Complete token replaced when the user accepts a completion. */
|
||||
prefix: string
|
||||
/** Path query after `@` or `@"`. */
|
||||
query: string
|
||||
/** Whether the user opened a quoted path. */
|
||||
quoted: boolean
|
||||
}
|
||||
|
||||
interface IndexedPath extends FileSearchCandidate {}
|
||||
|
||||
interface RankedPath {
|
||||
candidate: FileSearchCandidate
|
||||
score: number
|
||||
}
|
||||
|
||||
interface IndexGeneration {
|
||||
controller: AbortController
|
||||
promise: Promise<IndexedPath[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
|
||||
* inside another token, such as an email address, is not a completion trigger.
|
||||
* @param line - current editor line.
|
||||
* @param cursorCol - cursor column within that line.
|
||||
* @returns the active token, or `undefined` outside an `@` token.
|
||||
*/
|
||||
export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined {
|
||||
const beforeCursor = line.slice(0, cursorCol)
|
||||
const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor)
|
||||
if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
|
||||
return { prefix: quoted[1], query: quoted[2], quoted: true }
|
||||
}
|
||||
const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor)
|
||||
if (plain?.[1] === undefined || plain[2] === undefined) return undefined
|
||||
return { prefix: plain[1], query: plain[2], quoted: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a selected path as prompt text. Whitespace uses Pi's quoted
|
||||
* `@"path"` grammar; directories retain a trailing slash so completion can
|
||||
* descend another level.
|
||||
* @param candidate - selected file or directory.
|
||||
* @param preserveQuote - retain an explicitly opened quote even when unnecessary.
|
||||
* @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely.
|
||||
*/
|
||||
export function formatFileMention(
|
||||
candidate: FileSearchCandidate,
|
||||
preserveQuote: boolean,
|
||||
): string | undefined {
|
||||
const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path
|
||||
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
|
||||
const quoted = preserveQuote || /\s/u.test(path)
|
||||
if (!quoted) return `@${path}`
|
||||
return `@"${path}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable, reusable fuzzy index rooted at one agent working directory.
|
||||
* Directory-scoped queries list live state; bare fuzzy queries share one
|
||||
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
||||
*/
|
||||
export class WorkspaceFileSearch {
|
||||
private readonly excludedDirectories: ReadonlySet<string>
|
||||
private generation: IndexGeneration | undefined
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly config: FileSearchConfig,
|
||||
) {
|
||||
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
|
||||
throw new Error('file search maxResults must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
|
||||
throw new Error('file search maxEntries must be a positive safe integer')
|
||||
}
|
||||
if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
|
||||
throw new Error('file search excludedDirectories entries must be non-empty directory basenames')
|
||||
}
|
||||
this.excludedDirectories = new Set(config.excludedDirectories)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return ranked path candidates for the current token.
|
||||
* @param rawQuery - path text following `@` or `@"`.
|
||||
* @param signal - cancels this caller's wait without killing an index shared by a newer query.
|
||||
* @returns at most `maxResults` deterministic candidates.
|
||||
*/
|
||||
async list(rawQuery: string, signal: AbortSignal): Promise<FileSearchCandidate[]> {
|
||||
signal.throwIfAborted()
|
||||
if (this.disposed) return []
|
||||
const query = rawQuery.replaceAll('\\', '/')
|
||||
const slash = query.lastIndexOf('/')
|
||||
if (query === '' || slash >= 0) {
|
||||
const directory = slash < 0 ? '' : query.slice(0, slash + 1)
|
||||
const fragment = slash < 0 ? '' : query.slice(slash + 1)
|
||||
return this.listDirectory(directory, fragment, signal)
|
||||
}
|
||||
const indexed = await waitForPromise(this.ensureIndex(), signal)
|
||||
return rankCandidates(
|
||||
indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)),
|
||||
query,
|
||||
this.config.maxResults,
|
||||
)
|
||||
}
|
||||
|
||||
/** Discard the current index so the next bare query observes a fresh tree. */
|
||||
invalidate(): void {
|
||||
this.generation?.controller.abort(new Error('file search index invalidated'))
|
||||
this.generation = undefined
|
||||
}
|
||||
|
||||
/** Abort traversal and make later queries return no candidates. */
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private ensureIndex(): Promise<IndexedPath[]> {
|
||||
if (this.generation !== undefined) return this.generation.promise
|
||||
const controller = new AbortController()
|
||||
const generation = {
|
||||
controller,
|
||||
promise: Promise.resolve([] as IndexedPath[]),
|
||||
} satisfies IndexGeneration
|
||||
generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => {
|
||||
/* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
|
||||
if (this.generation === generation) this.generation = undefined
|
||||
throw error
|
||||
})
|
||||
this.generation = generation
|
||||
return generation.promise
|
||||
}
|
||||
|
||||
private async scanWorkspace(signal: AbortSignal): Promise<IndexedPath[]> {
|
||||
const indexed: IndexedPath[] = []
|
||||
const directories: { absolute: string; relative: string }[] = [{ absolute: this.root, relative: '' }]
|
||||
for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) {
|
||||
signal.throwIfAborted()
|
||||
const directory = directories[cursor]
|
||||
/* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
|
||||
if (directory === undefined) {
|
||||
throw new Error('file search selected a missing directory')
|
||||
}
|
||||
const entries = await readDirectory(directory.absolute, signal)
|
||||
for (const entry of entries) {
|
||||
signal.throwIfAborted()
|
||||
const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`
|
||||
if (entry.isDirectory()) {
|
||||
if (this.excludedDirectories.has(entry.name)) continue
|
||||
indexed.push({ path, kind: 'directory' })
|
||||
directories.push({ absolute: join(directory.absolute, entry.name), relative: path })
|
||||
} else if (entry.isFile()) {
|
||||
indexed.push({ path, kind: 'file' })
|
||||
}
|
||||
if (indexed.length >= this.config.maxEntries) break
|
||||
}
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
private async listDirectory(
|
||||
displayDirectory: string,
|
||||
fragment: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FileSearchCandidate[]> {
|
||||
if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
|
||||
const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal)
|
||||
if (absolute === undefined) return []
|
||||
const entries = await readDirectory(absolute, signal)
|
||||
const candidates: FileSearchCandidate[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue
|
||||
if (entry.isDirectory()) {
|
||||
if (this.excludedDirectories.has(entry.name)) continue
|
||||
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' })
|
||||
} else if (entry.isFile()) {
|
||||
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' })
|
||||
}
|
||||
}
|
||||
return rankCandidates(candidates, fragment, this.config.maxResults)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDisplayDirectory(
|
||||
root: string,
|
||||
displayDirectory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
const resolvedRoot = resolve(root)
|
||||
const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory)
|
||||
const fromRoot = relative(resolvedRoot, absolute)
|
||||
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined
|
||||
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
||||
if (isAbsolute(fromRoot)) return undefined
|
||||
let current = resolvedRoot
|
||||
for (const segment of fromRoot.split(sep).filter(Boolean)) {
|
||||
signal.throwIfAborted()
|
||||
current = join(current, segment)
|
||||
try {
|
||||
const status = await lstat(current)
|
||||
signal.throwIfAborted()
|
||||
if (status.isSymbolicLink() || !status.isDirectory()) return undefined
|
||||
} catch (_error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
async function readDirectory(absolute: string, signal: AbortSignal) {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const entries = await readdir(absolute, { withFileTypes: true })
|
||||
signal.throwIfAborted()
|
||||
return entries.sort((left, right) => compareText(left.name, right.name))
|
||||
} catch (_error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
// An unreadable/missing subtree contributes no candidates; other readable
|
||||
// branches remain useful and autocomplete is advisory.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function visibleForGlobalQuery(path: string, query: string): boolean {
|
||||
if (query.startsWith('.') || query.includes('/.')) return true
|
||||
return !path.split('/').some(segment => segment.startsWith('.'))
|
||||
}
|
||||
|
||||
function rankCandidates(
|
||||
candidates: readonly FileSearchCandidate[],
|
||||
query: string,
|
||||
limit: number,
|
||||
): FileSearchCandidate[] {
|
||||
const ranked: RankedPath[] = []
|
||||
for (const candidate of candidates) {
|
||||
const score = scoreCandidate(candidate, query)
|
||||
if (score !== undefined) ranked.push({ candidate, score })
|
||||
}
|
||||
ranked.sort((left, right) =>
|
||||
right.score - left.score
|
||||
|| kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
|
||||
|| (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
|
||||
|| compareText(left.candidate.path, right.candidate.path))
|
||||
return ranked.slice(0, limit).map(entry => entry.candidate)
|
||||
}
|
||||
|
||||
function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined {
|
||||
if (query === '') return 0
|
||||
const path = candidate.path.toLowerCase()
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
const needle = query.toLowerCase()
|
||||
const directoryBonus = candidate.kind === 'directory' ? 25 : 0
|
||||
if (name === needle) return 1_000 + directoryBonus
|
||||
if (name.startsWith(needle)) return 900 + directoryBonus
|
||||
if (name.includes(needle)) return 700 + directoryBonus
|
||||
if (path.includes(needle)) return 500 + directoryBonus
|
||||
const subsequence = subsequenceScore(path, needle)
|
||||
return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
|
||||
}
|
||||
|
||||
function subsequenceScore(target: string, query: string): number | undefined {
|
||||
let targetIndex = 0
|
||||
let gap = 0
|
||||
for (const character of query) {
|
||||
const found = target.indexOf(character, targetIndex)
|
||||
if (found < 0) return undefined
|
||||
gap += found - targetIndex
|
||||
targetIndex = found + 1
|
||||
}
|
||||
return Math.max(0, 100 - gap)
|
||||
}
|
||||
|
||||
function kindRank(kind: FileSearchCandidate['kind']): number {
|
||||
return kind === 'directory' ? 0 : 1
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
/* v8 ignore next -- entries and candidates are unique; host enumeration
|
||||
* order determines which comparison direction sort requests. */
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function waitForPromise<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
/* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */
|
||||
if (signal.aborted) return Promise.reject(errorReason(signal.reason, 'file search aborted'))
|
||||
return new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
const onAbort = (): void => { rejectPromise(errorReason(signal.reason, 'file search aborted')) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolvePromise(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
rejectPromise(errorReason(error, 'file search index failed'))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function errorReason(reason: unknown, fallback: string): Error {
|
||||
return reason instanceof Error ? reason : new Error(fallback, { cause: reason })
|
||||
}
|
||||
+288
-89
@@ -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,
|
||||
@@ -61,6 +60,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import {
|
||||
displayPromptContent,
|
||||
SessionId,
|
||||
type JsonValue,
|
||||
type Session,
|
||||
type SessionEvent,
|
||||
type SessionHeader,
|
||||
@@ -90,11 +90,84 @@ 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
|
||||
}
|
||||
import {
|
||||
activeAtToken,
|
||||
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
formatFileMention,
|
||||
WorkspaceFileSearch,
|
||||
} from './file-autocomplete.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
} from './file-autocomplete.ts'
|
||||
|
||||
export const name = 'ui-tui'
|
||||
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
|
||||
|
||||
/** Presentation settings for the pi-tui terminal mode. */
|
||||
/** Model guidance for path-only file references selected through the TUI. */
|
||||
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
|
||||
|
||||
/** Interaction and presentation settings for the pi-tui terminal mode. */
|
||||
export interface TuiConfig {
|
||||
/** Render model reasoning blocks. */
|
||||
showReasoning?: boolean
|
||||
@@ -112,6 +185,12 @@ export interface TuiConfig {
|
||||
modelDialogWidth?: number
|
||||
/** Model-selector maximum height in terminal rows. */
|
||||
modelDialogMaxHeight?: number
|
||||
/** Maximum fuzzy file candidates displayed for one `@` query. */
|
||||
fileSearchMaxResults?: number
|
||||
/** Maximum paths retained in one `@` workspace index. */
|
||||
fileSearchMaxEntries?: number
|
||||
/** Directory basenames excluded from `@` traversal and completion. */
|
||||
fileSearchExcludedDirectories?: string[]
|
||||
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
|
||||
showHardwareCursor?: boolean
|
||||
/** Apply the built-in ANSI color palette. */
|
||||
@@ -135,14 +214,16 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
|
||||
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
|
||||
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
|
||||
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
|
||||
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
|
||||
const showHardwareCursorSchema = z.boolean().default(false)
|
||||
const colorSchema = z.boolean().default(true)
|
||||
// No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
|
||||
const truecolorSchema = z.boolean()
|
||||
const titleSchema = z.string().default('DeepSeek Harness')
|
||||
|
||||
/** Schemastery schema for presentation settings embedded by app bundles. */
|
||||
export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
const tuiConfigSchemaFields = {
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
@@ -151,11 +232,17 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
fileSearchMaxResults: fileSearchMaxResultsSchema,
|
||||
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
|
||||
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
|
||||
showHardwareCursor: showHardwareCursorSchema,
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
title: titleSchema,
|
||||
})
|
||||
}
|
||||
|
||||
/** Schemastery schema for presentation settings embedded by app bundles. */
|
||||
export const TuiConfigSchema: z<TuiConfig> = z.object(tuiConfigSchemaFields)
|
||||
|
||||
/** Serializable plugin configuration. */
|
||||
export interface Config extends TuiConfig {
|
||||
@@ -177,18 +264,21 @@ export const Config: z<Config> = z.object({
|
||||
welcome: z.string(),
|
||||
sessionId: z.string().default('main'),
|
||||
resumeCommand: z.string(),
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
questionDialogWidth: questionDialogWidthSchema,
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
showHardwareCursor: showHardwareCursorSchema,
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
title: titleSchema,
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
|
||||
questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
|
||||
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
|
||||
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
|
||||
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
|
||||
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
|
||||
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
|
||||
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
|
||||
showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor,
|
||||
color: tuiConfigSchemaFields.color,
|
||||
truecolor: tuiConfigSchemaFields.truecolor,
|
||||
title: tuiConfigSchemaFields.title,
|
||||
})
|
||||
|
||||
/** Fully defaulted TUI presentation settings. */
|
||||
@@ -201,6 +291,9 @@ export interface ResolvedTuiConfig {
|
||||
questionDialogMaxHeight: number
|
||||
modelDialogWidth: number
|
||||
modelDialogMaxHeight: number
|
||||
fileSearchMaxResults: number
|
||||
fileSearchMaxEntries: number
|
||||
fileSearchExcludedDirectories: string[]
|
||||
showHardwareCursor: boolean
|
||||
color: boolean
|
||||
truecolor: boolean
|
||||
@@ -239,6 +332,9 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 72,
|
||||
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
|
||||
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
|
||||
showHardwareCursor: config?.showHardwareCursor ?? false,
|
||||
color: config?.color ?? true,
|
||||
truecolor: config?.truecolor ?? false,
|
||||
@@ -745,7 +841,7 @@ function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
}
|
||||
|
||||
class ToolCardComponent implements Component {
|
||||
private result: { content: ContentBlock[]; isError: boolean; meta?: unknown } | undefined
|
||||
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
|
||||
private expanded = false
|
||||
private callView: ToolCallView
|
||||
private resultView: ToolResultView | undefined
|
||||
@@ -1290,14 +1386,15 @@ 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. */
|
||||
class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
/** Merge path-only file candidates and optional session snapshots with commands. */
|
||||
class ReferenceAutocompleteProvider implements AutocompleteProvider {
|
||||
constructor(
|
||||
private readonly base: CombinedAutocompleteProvider,
|
||||
private readonly sessions: SessionReferenceService,
|
||||
private readonly files: WorkspaceFileSearch,
|
||||
private readonly sessions: SessionReferenceService | undefined,
|
||||
private readonly agent: Agent,
|
||||
) {}
|
||||
|
||||
@@ -1311,17 +1408,33 @@ class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
const currentLine = lines[cursorLine]
|
||||
/* v8 ignore next -- Editor always supplies its current state line. */
|
||||
if (currentLine === undefined) return basePromise
|
||||
const token = /(?:^|\s)(@[^\s]*)$/u.exec(currentLine.slice(0, cursorCol))?.[1]
|
||||
if (token === undefined) return basePromise
|
||||
let candidates
|
||||
try {
|
||||
candidates = await this.sessions.listCandidates(this.agent, token.slice(1), undefined, options.signal)
|
||||
} catch {
|
||||
const token = activeAtToken(currentLine, cursorCol)
|
||||
if (token === undefined) {
|
||||
this.files.invalidate()
|
||||
return basePromise
|
||||
}
|
||||
const base = await basePromise
|
||||
const filePromise = this.files.list(token.query, options.signal).catch(() => [])
|
||||
const sessionPromise = this.sessions === undefined || token.quoted
|
||||
? Promise.resolve([])
|
||||
: this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => [])
|
||||
const [base, fileCandidates, sessionCandidates] = await Promise.all([
|
||||
basePromise,
|
||||
filePromise,
|
||||
sessionPromise,
|
||||
])
|
||||
if (options.signal.aborted) return base
|
||||
const items: AutocompleteItem[] = candidates.map((candidate) => {
|
||||
const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => {
|
||||
const value = formatFileMention(candidate, token.quoted)
|
||||
if (value === undefined) return []
|
||||
const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
|
||||
const directory = candidate.kind === 'directory'
|
||||
return [{
|
||||
value,
|
||||
label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`,
|
||||
description: displayInlineText(candidate.path),
|
||||
}]
|
||||
})
|
||||
const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => {
|
||||
const mentionLabel = displayInlineText(candidate.label)
|
||||
const sessionId = displayInlineText(candidate.sessionId)
|
||||
const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
|
||||
@@ -1332,8 +1445,9 @@ class SessionAutocompleteProvider implements AutocompleteProvider {
|
||||
description,
|
||||
}
|
||||
})
|
||||
const items = [...fileItems, ...sessionItems]
|
||||
if (items.length === 0) return base
|
||||
return { items: [...items, ...(base?.items ?? [])], prefix: token }
|
||||
return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
@@ -1503,6 +1617,11 @@ export function createTuiChat(
|
||||
// rather than declaring an injection that would make the TUI require them.
|
||||
const skills = ctx.get('skills')
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const fileSearch = new WorkspaceFileSearch(cwd, {
|
||||
maxResults: resolved.fileSearchMaxResults,
|
||||
maxEntries: resolved.fileSearchMaxEntries,
|
||||
excludedDirectories: resolved.fileSearchExcludedDirectories,
|
||||
})
|
||||
const skillAbort = new AbortController()
|
||||
const tokens = sessionTokens(agent.session)
|
||||
const toolCards = new Map<string, ToolCardComponent>()
|
||||
@@ -1512,7 +1631,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<
|
||||
@@ -1570,6 +1690,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 => {
|
||||
@@ -1609,29 +1764,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()
|
||||
}
|
||||
@@ -1940,7 +2095,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(
|
||||
@@ -1963,31 +2118,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()
|
||||
}
|
||||
@@ -2058,20 +2230,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()
|
||||
@@ -2222,9 +2397,12 @@ export function createTuiChat(
|
||||
agent.session.header.cwd ?? process.cwd(),
|
||||
)
|
||||
const sessionReferences = ctx.get('sessionReferences')
|
||||
editor.setAutocompleteProvider(sessionReferences === undefined
|
||||
? base
|
||||
: new SessionAutocompleteProvider(base, sessionReferences, agent))
|
||||
editor.setAutocompleteProvider(new ReferenceAutocompleteProvider(
|
||||
base,
|
||||
fileSearch,
|
||||
sessionReferences,
|
||||
agent,
|
||||
))
|
||||
}
|
||||
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
|
||||
refreshCommandAutocomplete()
|
||||
@@ -2308,6 +2486,16 @@ export function createTuiChat(
|
||||
handler: () => { requestExit(); return { kind: 'success' } },
|
||||
})
|
||||
})
|
||||
const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'ui:tui-file-reference',
|
||||
order: 99,
|
||||
// Tool visibility can change dynamically or by agent scope. Empty
|
||||
// sections are omitted by renderPrompt, so guidance never names a tool
|
||||
// that this agent cannot call.
|
||||
text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
|
||||
})
|
||||
})
|
||||
|
||||
const runCommand = (text: string): void => {
|
||||
const controller = new AbortController()
|
||||
@@ -2517,7 +2705,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 }
|
||||
@@ -2555,6 +2743,7 @@ export function createTuiChat(
|
||||
|
||||
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'tool/result') fileSearch.invalidate()
|
||||
recordEventUsage(tokens, event)
|
||||
advanceTurnPhase(event)
|
||||
if (event.type === 'steering/message') {
|
||||
@@ -2603,6 +2792,7 @@ export function createTuiChat(
|
||||
|
||||
const detachListeners = (): void => {
|
||||
skillAbort.abort()
|
||||
fileSearch.dispose()
|
||||
removeInputListener()
|
||||
disposeCommandChanges()
|
||||
stopBannerReveal()
|
||||
@@ -2650,10 +2840,13 @@ export function createTuiChat(
|
||||
} catch (error: unknown) {
|
||||
disposed = true
|
||||
detachListeners()
|
||||
void commandFiber.dispose().catch(
|
||||
void Promise.all([
|
||||
commandFiber.dispose(),
|
||||
fileReferencePromptFiber.dispose(),
|
||||
]).catch(
|
||||
/* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
|
||||
(cleanupError: unknown) => {
|
||||
ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
|
||||
ctx.logger.warn(`ui-tui: scoped cleanup after startup failure failed: ${errorChain(cleanupError)}`)
|
||||
},
|
||||
)
|
||||
clearStatus()
|
||||
@@ -2661,13 +2854,19 @@ export function createTuiChat(
|
||||
ui.stop()
|
||||
throw error
|
||||
}
|
||||
tuiServiceFiber = ctx.inject([], (serviceCtx) => {
|
||||
new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager)
|
||||
})
|
||||
startBannerReveal()
|
||||
|
||||
return {
|
||||
async dispose(): Promise<void> {
|
||||
detachListeners()
|
||||
await shutdown(false)
|
||||
await commandFiber.dispose()
|
||||
await Promise.all([
|
||||
commandFiber.dispose(),
|
||||
fileReferencePromptFiber.dispose(),
|
||||
])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user