Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts # packages/llm/llm-deepseek/src/adapter.ts # packages/llm/llm-deepseek/tests/adapter.spec.ts # packages/llm/llm-deepseek/tests/serialize.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm-pi-ai/src/index.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm/src/types.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/src/index.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
1499 files changed
+48621
-21956
No files matched your search
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Editor autocomplete provider merging path-only file candidates and optional
|
||||
* session-reference snapshots with the base slash-command completions.
|
||||
* @module @deepseek-ai/dsh-tui/chat/autocomplete
|
||||
*/
|
||||
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
type AutocompleteSuggestions,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
formatSessionReferenceMention,
|
||||
type SessionReferenceService,
|
||||
} from '@deepseek-ai/dsh-session-reference'
|
||||
import { displayInlineText } from '../components/text.ts'
|
||||
import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts'
|
||||
|
||||
/** Merge path-only file candidates and optional session snapshots with commands. */
|
||||
export class ReferenceAutocompleteProvider implements AutocompleteProvider {
|
||||
constructor(
|
||||
private readonly base: CombinedAutocompleteProvider,
|
||||
private readonly files: WorkspaceFileSearch,
|
||||
private readonly sessions: SessionReferenceService | undefined,
|
||||
private readonly agent: Agent,
|
||||
) {}
|
||||
|
||||
async getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
options: { signal: AbortSignal; force?: boolean },
|
||||
): Promise<AutocompleteSuggestions | null> {
|
||||
const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options)
|
||||
const currentLine = lines[cursorLine]
|
||||
/* v8 ignore next -- Editor always supplies its current state line. */
|
||||
if (currentLine === undefined) return basePromise
|
||||
const token = activeAtToken(currentLine, cursorCol)
|
||||
if (token === undefined) {
|
||||
this.files.invalidate()
|
||||
return 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 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)
|
||||
const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
|
||||
return {
|
||||
value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }),
|
||||
label: `Session · ${mentionLabel}`,
|
||||
description,
|
||||
}
|
||||
})
|
||||
const items = [...fileItems, ...sessionItems]
|
||||
if (items.length === 0) return base
|
||||
return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
||||
return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
|
||||
}
|
||||
|
||||
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
||||
return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shared collaborator surface every chat-channel sub-controller receives from
|
||||
* `createTuiChat`. Each controller's own `*Deps` extends {@link ChatChannelDeps}
|
||||
* (and {@link ChannelNotice} when it reports outcomes) with the extra services
|
||||
* it needs. Value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`)
|
||||
* are stable for the channel's life; the callbacks stay on the object so a
|
||||
* controller always calls the channel's current implementation.
|
||||
* @module @deepseek-ai/dsh-tui/chat/channel
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { TuiOverlayManager } from '../extension/overlay-manager.ts'
|
||||
import type { Palette } from '../components/theme.ts'
|
||||
import type { ResolvedTuiConfig } from '../config.ts'
|
||||
|
||||
/** Collaborators shared by every chat-channel sub-controller. */
|
||||
export interface ChatChannelDeps {
|
||||
readonly ctx: Context
|
||||
readonly resolved: ResolvedTuiConfig
|
||||
readonly palette: Palette
|
||||
readonly overlayManager: TuiOverlayManager
|
||||
/** Redraw the channel. */
|
||||
requestRender(): void
|
||||
/** Whether the channel has begun shutting down. */
|
||||
isDisposed(): boolean
|
||||
}
|
||||
|
||||
/** Append a channel notice line; controllers that report outcomes mix this in. */
|
||||
export interface ChannelNotice {
|
||||
appendNotice(message: string, kind?: 'info' | 'warning' | 'error'): void
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
* 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
|
||||
* @module @deepseek-ai/dsh-tui/chat/file-autocomplete
|
||||
*/
|
||||
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Zero-state helpers for the interactive chat channel: prompt-directory and
|
||||
* Git-branch formatting, surface/tool-call derivations over the session log,
|
||||
* session-reference context cards, the placeholder editor, and banner-reveal
|
||||
* timing constants. None of these close over channel state.
|
||||
* @module @deepseek-ai/dsh-tui/chat/helpers
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
CURSOR_MARKER,
|
||||
Editor,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Editor that shows a placeholder without making it editable content. */
|
||||
export class HintEditor extends Editor {
|
||||
/** Placeholder shown in the empty input row; `undefined` hides it. */
|
||||
hint: string | undefined
|
||||
/** Prompt text rendered before the placeholder, matching the live prompt width. */
|
||||
hintPrefix = ''
|
||||
|
||||
override render(width: number): string[] {
|
||||
const lines = super.render(width)
|
||||
if (this.hint === undefined || this.getText() !== '') return lines
|
||||
const content = lines[0]
|
||||
/* v8 ignore next -- Editor always renders one content row. */
|
||||
if (content === undefined) return lines
|
||||
const padding = ' '.repeat(this.getPaddingX())
|
||||
/* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */
|
||||
const marker = this.focused ? CURSOR_MARKER : ''
|
||||
const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix))
|
||||
const placeholder = truncateToWidth(this.hint, available, '')
|
||||
const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder)
|
||||
lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}`
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the session working directory as a prompt label: `~` for home,
|
||||
* `~/rel` for a home-relative path, the raw path otherwise.
|
||||
* @param cwd - operational working directory from the session header.
|
||||
* @returns unescaped prompt label.
|
||||
*/
|
||||
export function formatCwd(cwd: string | undefined): string {
|
||||
if (cwd === undefined) return 'cwd unset'
|
||||
const home = homedir()
|
||||
const rel = relative(resolve(home), resolve(cwd))
|
||||
if (rel === '') return '~'
|
||||
/* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
|
||||
if (isAbsolute(rel)) return cwd
|
||||
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
|
||||
return cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current Git branch for the prompt context line.
|
||||
* @param cwd - operational working directory to query.
|
||||
* @returns branch name, or `undefined` outside a worktree or on any failure.
|
||||
*/
|
||||
export function gitBranch(cwd: string): string | undefined {
|
||||
try {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)),
|
||||
)
|
||||
const branch = execFileSync('git', ['branch', '--show-current'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1_000,
|
||||
}).trim()
|
||||
/* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */
|
||||
return branch === '' ? undefined : branch
|
||||
} catch (_gitUnavailableOrOutsideWorktree) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence numbers currently visible on the session surface.
|
||||
* @param session - session whose surface nodes to read.
|
||||
* @returns the set of visible event sequence numbers.
|
||||
*/
|
||||
export function activeSurfaceSeqs(session: Session): Set<number> {
|
||||
return new Set(session.surface.nodes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-call ids whose owning assistant message is on the active surface.
|
||||
* @param session - session whose events to scan.
|
||||
* @param active - sequence numbers currently on the surface.
|
||||
* @returns the set of active tool-call ids.
|
||||
*/
|
||||
export function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
|
||||
for (const block of event.data.content) {
|
||||
if (block.type === 'tool-call') ids.add(block.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a session-reference context card's display labels from an event source.
|
||||
* @param source - event source to inspect.
|
||||
* @returns per-reference labels, or `undefined` when the source is not a reference card.
|
||||
*/
|
||||
export function sessionReferenceCard(source: unknown): string[] | undefined {
|
||||
if (typeof source !== 'object' || source === null) return undefined
|
||||
const record = source as Record<string, unknown>
|
||||
if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
|
||||
const references = record['references'] as unknown[]
|
||||
const labels: string[] = []
|
||||
for (const reference of references) {
|
||||
if (typeof reference !== 'object' || reference === null) return undefined
|
||||
const entry = reference as Record<string, unknown>
|
||||
const sessionId = entry['sessionId']
|
||||
const label = entry['label']
|
||||
if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined
|
||||
labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
/** Milliseconds between banner sweep-reveal frames (~60 fps). */
|
||||
export const BANNER_REVEAL_INTERVAL_MS = 15
|
||||
|
||||
/** Number of sweep frames the banner reveal spreads the terminal width over. */
|
||||
export const BANNER_REVEAL_STEPS = 24
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Model-selection sub-controller for the interactive chat channel: the queued
|
||||
* `/model` command, the keyboard model selector overlay with reasoning-effort
|
||||
* selection, and resolution of the selected model's context window. Owns the
|
||||
* context-window cache the prompt and status views read; the caller owns the
|
||||
* shared {@link AgentLlmTargetRef}.
|
||||
* @module @deepseek-ai/dsh-tui/chat/model-command
|
||||
*/
|
||||
|
||||
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { displayText } from '../components/text.ts'
|
||||
import {
|
||||
ModelDialog,
|
||||
readModelChoices,
|
||||
targetLabel,
|
||||
targetReasoningLabel,
|
||||
type ModelChoice,
|
||||
type ModelDialogSelection,
|
||||
} from '../components/dialogs.ts'
|
||||
import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** Collaborators the model controller needs from the chat channel. */
|
||||
export interface ModelControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
/** Shared selected-target handle owned by the channel. */
|
||||
readonly target: AgentLlmTargetRef
|
||||
}
|
||||
|
||||
/** Model-selection controller for one chat channel. */
|
||||
export interface ModelController {
|
||||
/** Resolved context window of the selected model, or `undefined` if unknown. */
|
||||
contextWindow(): number | undefined
|
||||
/** Queue a `/model` command; empty argument opens the selector. */
|
||||
queueModelCommand(raw: string): void
|
||||
/** Drop the pending context-window resolution (shutdown). */
|
||||
resetContextResolution(): void
|
||||
/** Forget the tracked selector overlay (shutdown). */
|
||||
clearOverlay(): void
|
||||
}
|
||||
|
||||
type ContextResolution =
|
||||
| { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
|
||||
| { readonly kind: 'error'; readonly error: unknown }
|
||||
|
||||
/**
|
||||
* Build the model-selection controller for one chat channel.
|
||||
* @param deps - channel collaborators and shared target handle.
|
||||
* @returns the controller wired to the channel's overlay and prompt views.
|
||||
*/
|
||||
export function createModelController(deps: ModelControllerDeps): ModelController {
|
||||
const { ctx, resolved, palette, overlayManager, target } = deps
|
||||
let contextWindow: number | undefined
|
||||
let contextResolution: Promise<ContextResolution> | undefined
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
const resolution: Promise<ContextResolution> = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const),
|
||||
(error: unknown) => ({ kind: 'error', error } as const),
|
||||
)
|
||||
contextResolution = resolution
|
||||
void resolution.then((result) => {
|
||||
if (contextResolution !== resolution) return
|
||||
if (result.kind === 'error') {
|
||||
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
contextWindow = result.contextWindow
|
||||
deps.requestRender()
|
||||
})
|
||||
}
|
||||
resolveContextWindow(target.current)
|
||||
|
||||
const selectModel = (
|
||||
selected: ModelChoice,
|
||||
explicitReasoning?: { effort: ReasoningEffortId | undefined },
|
||||
): void => {
|
||||
const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model
|
||||
const reasoningEffort = explicitReasoning === undefined
|
||||
? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort)
|
||||
: explicitReasoning.effort
|
||||
if (sameRoute && target.current?.reasoningEffort === reasoningEffort) {
|
||||
const reasoning = targetReasoningLabel(selected, reasoningEffort)
|
||||
deps.appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`)
|
||||
return
|
||||
}
|
||||
target.current = {
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
}
|
||||
resolveContextWindow(target.current)
|
||||
const reasoning = targetReasoningLabel(selected, reasoningEffort)
|
||||
deps.appendNotice([
|
||||
`Model selected: ${targetLabel(selected)}.`,
|
||||
...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`],
|
||||
'New steps will use it.',
|
||||
].join(' '))
|
||||
}
|
||||
|
||||
const showModelSelector = (choices: readonly ModelChoice[]): void => {
|
||||
const current = target.current === undefined ? 'unset' : targetLabel(target.current)
|
||||
if (choices.length === 0) {
|
||||
deps.appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
|
||||
return
|
||||
}
|
||||
void modelOverlay?.close()
|
||||
const session = overlayManager.open({
|
||||
create: () => new ModelDialog(
|
||||
choices,
|
||||
target.current,
|
||||
resolved.maxModelOptions,
|
||||
palette,
|
||||
(selection: ModelDialogSelection) => {
|
||||
void session.close()
|
||||
selectModel(selection.choice, { effort: selection.reasoningEffort })
|
||||
},
|
||||
() => { void session.close() },
|
||||
),
|
||||
options: {
|
||||
width: resolved.modelDialogWidth,
|
||||
maxHeight: resolved.modelDialogMaxHeight,
|
||||
anchor: 'center',
|
||||
margin: 1,
|
||||
},
|
||||
})
|
||||
modelOverlay = session
|
||||
void session.closed.then(() => {
|
||||
if (modelOverlay === session) modelOverlay = undefined
|
||||
})
|
||||
deps.requestRender()
|
||||
}
|
||||
|
||||
const handleModelCommand = async (raw: string): Promise<void> => {
|
||||
const choices = await readModelChoices(ctx, target.current)
|
||||
if (deps.isDisposed()) return
|
||||
const argument = raw.trim()
|
||||
if (argument === '') {
|
||||
showModelSelector(choices)
|
||||
return
|
||||
}
|
||||
const parts = argument.split(/\s+/u)
|
||||
if (parts.length > 2) {
|
||||
deps.appendNotice('Usage: /model [provider/]model', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
let matches: ModelChoice[]
|
||||
if (parts.length === 2) {
|
||||
matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
|
||||
} else {
|
||||
const value = argument
|
||||
const qualified = choices.filter(choice => targetLabel(choice) === value)
|
||||
matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
deps.appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
|
||||
return
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
deps.appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
|
||||
return
|
||||
}
|
||||
const selected = matches[0]
|
||||
/* v8 ignore next -- a non-empty matches array always has index zero. */
|
||||
if (selected === undefined) return
|
||||
selectModel(selected)
|
||||
}
|
||||
|
||||
return {
|
||||
contextWindow: () => contextWindow,
|
||||
queueModelCommand(raw: string): void {
|
||||
modelCommands = modelCommands.then(async () => {
|
||||
await handleModelCommand(raw)
|
||||
}).catch((error: unknown) => {
|
||||
if (!deps.isDisposed()) deps.appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
|
||||
})
|
||||
},
|
||||
resetContextResolution(): void {
|
||||
contextResolution = undefined
|
||||
},
|
||||
clearOverlay(): void {
|
||||
modelOverlay = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Ask-user-question sub-machine for the interactive chat channel. Registers the
|
||||
* user-interaction provider, presents one question overlay at a time in FIFO
|
||||
* order, and settles each request on answer, abort, overlay error, or channel
|
||||
* shutdown.
|
||||
* @module @deepseek-ai/dsh-tui/chat/questions
|
||||
*/
|
||||
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionAnswerItem,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { QuestionDialog } from '../components/dialogs.ts'
|
||||
import type { ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** One queued or active ask-user-question request and its running answers. */
|
||||
interface PendingQuestion {
|
||||
request: AskUserQuestionRequest
|
||||
index: number
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
resolve(answer: AskUserQuestionAnswer): void
|
||||
reject(error: unknown): void
|
||||
onAbort: () => void
|
||||
overlay: TuiOverlaySession | undefined
|
||||
}
|
||||
|
||||
/** Collaborators the question queue needs from the chat channel. */
|
||||
export type QuestionQueueDeps = ChatChannelDeps
|
||||
|
||||
/** Ask-user-question controller for one chat channel. */
|
||||
export interface QuestionQueue {
|
||||
/** Reject the active and all queued questions (shutdown). */
|
||||
rejectAll(): void
|
||||
/** Remove the user-interaction provider registration. */
|
||||
unregister(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ask-user-question queue for one chat channel.
|
||||
* @param deps - channel collaborators and overlay host.
|
||||
* @returns the controller used at shutdown to drain and unregister.
|
||||
*/
|
||||
export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue {
|
||||
const { ctx, resolved, palette, overlayManager } = deps
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
|
||||
const removeAbortListener = (pending: PendingQuestion): void => {
|
||||
pending.request.signal?.removeEventListener('abort', pending.onAbort)
|
||||
}
|
||||
|
||||
const rejectQuestion = (pending: PendingQuestion): void => {
|
||||
void pending.overlay?.close()
|
||||
pending.overlay = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
'ask_user_question was interrupted before the user answered',
|
||||
'ASK_ABORTED',
|
||||
))
|
||||
}
|
||||
|
||||
const startNextQuestion = (): void => {
|
||||
if (activeQuestion !== undefined || deps.isDisposed()) return
|
||||
const pending = questionQueue.shift()
|
||||
if (pending === undefined) return
|
||||
activeQuestion = pending
|
||||
const show = (): void => {
|
||||
const question = pending.request.questions[pending.index]
|
||||
if (question === undefined) {
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.resolve({ answers: pending.answers })
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
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 },
|
||||
},
|
||||
})
|
||||
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()
|
||||
})
|
||||
deps.requestRender()
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
const unregister = ctx.userInteraction.registerProvider({
|
||||
ask(request) {
|
||||
return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
|
||||
const pending: PendingQuestion = {
|
||||
request,
|
||||
index: 0,
|
||||
answers: [],
|
||||
resolve: resolveAnswer,
|
||||
reject,
|
||||
overlay: undefined,
|
||||
onAbort: () => {
|
||||
if (activeQuestion === pending) {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
// A non-active pending ask remains in the queue until this listener settles it.
|
||||
questionQueue.splice(questionQueue.indexOf(pending), 1)
|
||||
rejectQuestion(pending)
|
||||
},
|
||||
}
|
||||
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
|
||||
questionQueue.push(pending)
|
||||
startNextQuestion()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
rejectAll(): void {
|
||||
if (activeQuestion !== undefined) {
|
||||
const pending = activeQuestion
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
}
|
||||
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
|
||||
},
|
||||
unregister,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Session-resume sub-controller for the interactive chat channel: the
|
||||
* `/resume` selector, per-candidate summary reads that tolerate a corrupt
|
||||
* neighbor, the pre-handoff preflight, the terminal handoff itself, and the
|
||||
* durable resume-hint command printed on exit.
|
||||
* @module @deepseek-ai/dsh-tui/chat/resume
|
||||
*/
|
||||
|
||||
import type { TUI } from '@earendil-works/pi-tui'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionLogSnapshot,
|
||||
SessionQueryService,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { HintEditor } from './helpers.ts'
|
||||
import { formatCwd } from './helpers.ts'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import type { TuiRuntime } from '../runtime.ts'
|
||||
import type { Config } from '../config.ts'
|
||||
import {
|
||||
ResumePicker,
|
||||
summarizeResumeCandidate,
|
||||
type ResumeCandidate,
|
||||
} from '../components/dialogs.ts'
|
||||
import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** Collaborators the resume controller needs from the chat channel. */
|
||||
export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
readonly agent: Agent
|
||||
readonly config: Config
|
||||
readonly runtime: TuiRuntime
|
||||
readonly persistence: SessionPersistence | undefined
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
readonly ui: TUI
|
||||
readonly editor: HintEditor
|
||||
/** Current agent status, re-read at each resume precondition point. */
|
||||
agentStatus(): AgentStatus
|
||||
}
|
||||
|
||||
/** Session-resume controller for one chat channel. */
|
||||
export interface ResumeController {
|
||||
/** Open the current-workspace searchable session selector. */
|
||||
showResume(): void
|
||||
/**
|
||||
* The resume command for the current session — the configured template with
|
||||
* every `{session}` filled — but only once the session is durably persisted;
|
||||
* `undefined` otherwise.
|
||||
*/
|
||||
currentResumeCommand(): Promise<string | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session-resume controller for one chat channel.
|
||||
* @param deps - channel collaborators, terminal handles, and optional services.
|
||||
* @returns the controller wired to the `/resume` command and exit hint.
|
||||
*/
|
||||
export function createResumeController(deps: ResumeControllerDeps): ResumeController {
|
||||
const {
|
||||
ctx, agent, config, runtime, resolved, palette, overlayManager,
|
||||
persistence, sessionQuery, ui, editor,
|
||||
} = deps
|
||||
let resumeOverlay: TuiOverlaySession | undefined
|
||||
let resumeInFlight = false
|
||||
let resumeScan = 0
|
||||
|
||||
/**
|
||||
* Persisted sessions for this workspace, newest first. Empty when no
|
||||
* persistence backend is mounted or a listing failure would otherwise block
|
||||
* exit or crash `/resume`; the resume hint is best-effort convenience.
|
||||
*/
|
||||
const listWorkspaceSessions = async (): Promise<SessionHeader[]> => {
|
||||
if (persistence === undefined) return []
|
||||
let all: readonly SessionHeader[]
|
||||
try {
|
||||
all = await persistence.list()
|
||||
} catch {
|
||||
// A listing failure must never block terminal exit or crash `/resume`.
|
||||
return []
|
||||
}
|
||||
return all
|
||||
.filter(header => header.cwd === agent.session.header.cwd)
|
||||
}
|
||||
|
||||
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
|
||||
const readResumeCandidate = async (
|
||||
record: SessionRecord,
|
||||
providers: ReadonlySet<string>,
|
||||
): Promise<ResumeCandidate> => {
|
||||
try {
|
||||
let snapshot: SessionLogSnapshot
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) {
|
||||
snapshot = {
|
||||
session: structuredClone(live.header),
|
||||
events: live.events.map(event => structuredClone(event)),
|
||||
}
|
||||
} else {
|
||||
/* v8 ignore next -- caller checks the optional service before mapping records */
|
||||
if (sessionQuery === undefined) throw new Error('session query is unavailable')
|
||||
snapshot = await sessionQuery.readSession(record.header.id)
|
||||
}
|
||||
return summarizeResumeCandidate(
|
||||
record,
|
||||
snapshot,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
providers,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
record,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read every mutable precondition immediately before terminal handoff. */
|
||||
const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => {
|
||||
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
|
||||
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
|
||||
const initialStatus = deps.agentStatus()
|
||||
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
|
||||
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
|
||||
const candidate = await readResumeCandidate(
|
||||
record,
|
||||
new Set(ctx.llm.listProviders().map(provider => provider.id)),
|
||||
)
|
||||
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
|
||||
const finalStatus = deps.agentStatus()
|
||||
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
|
||||
return candidate
|
||||
}
|
||||
|
||||
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
|
||||
if (resumeInFlight) return
|
||||
resumeInFlight = true
|
||||
let terminalReleased = false
|
||||
try {
|
||||
const checked = await preflightResume(candidate.record.header.id)
|
||||
const hostHandoff = runtime.handoffResume
|
||||
if (hostHandoff === undefined) {
|
||||
const template = config.resumeCommand
|
||||
const fallback = template?.replaceAll('{session}', checked.record.header.id)
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
deps.appendNotice(fallback === undefined
|
||||
? 'Session is resumable, but this host cannot hand it off in place.'
|
||||
: `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning')
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
|
||||
if (deps.isDisposed()) return
|
||||
await ctx.sessions.flush(agent.session)
|
||||
// Disposal can run while the flush promise is pending.
|
||||
if (deps.isDisposed()) return
|
||||
if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`)
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
await runtime.terminal.drainInput(100, 20)
|
||||
// Disposal can run while terminal draining is pending.
|
||||
if (deps.isDisposed()) return
|
||||
ui.stop()
|
||||
terminalReleased = true
|
||||
await hostHandoff(checked.record.header.id)
|
||||
throw new Error('resume host returned without replacing the process')
|
||||
} catch (error: unknown) {
|
||||
if (!deps.isDisposed()) {
|
||||
if (terminalReleased) {
|
||||
ui.start()
|
||||
ui.setFocus(editor)
|
||||
deps.appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error')
|
||||
} else {
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
deps.appendNotice(`Resume failed: ${errorChain(error)}`, 'error')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resumeInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentResumeCommand: async (): Promise<string | undefined> => {
|
||||
if (config.resumeCommand === undefined) return undefined
|
||||
const sessions = await listWorkspaceSessions()
|
||||
if (!sessions.some(header => header.id === agent.session.id)) return undefined
|
||||
return config.resumeCommand.replaceAll('{session}', agent.session.id)
|
||||
},
|
||||
showResume(): void {
|
||||
if (agent.status !== 'idle') {
|
||||
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
|
||||
return
|
||||
}
|
||||
if (sessionQuery === undefined) {
|
||||
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
|
||||
return
|
||||
}
|
||||
const scan = ++resumeScan
|
||||
void resumeOverlay?.close()
|
||||
void sessionQuery.listSessions().then(async (records) => {
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd)
|
||||
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
|
||||
const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers)))
|
||||
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|
||||
|| a.record.header.id.localeCompare(b.record.header.id))
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
const session = overlayManager.open({
|
||||
create: host => new ResumePicker(
|
||||
candidates,
|
||||
resolved.maxResumeOptions,
|
||||
runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd),
|
||||
() => host.viewport.rows,
|
||||
palette,
|
||||
(candidate) => { void handoffResume(candidate, session) },
|
||||
() => { void session.close() },
|
||||
),
|
||||
options: {
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
anchor: 'top-left',
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
resumeOverlay = session
|
||||
void session.closed.then(() => {
|
||||
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
|
||||
if (resumeOverlay === session) resumeOverlay = undefined
|
||||
})
|
||||
deps.requestRender()
|
||||
}, (error: unknown) => {
|
||||
if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Manual `/skill:<name> [instructions]` parsing and model-visible rendering for
|
||||
* the terminal front door.
|
||||
* @module @deepseek-ai/dsh-tui/chat/skill-invocation
|
||||
*/
|
||||
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { SkillDefinition, SkillResourceBase } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
/** Prefix that marks an editor submission as a manual skill invocation. */
|
||||
export const SKILL_COMMAND_PREFIX = '/skill:'
|
||||
|
||||
/** Parsed `/skill:<name> [instructions]` submission; `name` is empty when the prefix carries no name. */
|
||||
export interface ParsedSkillCommand {
|
||||
/** Skill name typed after `/skill:`, up to the first space. */
|
||||
name: string
|
||||
/** Trimmed text after the name; empty when none was typed. */
|
||||
instructions: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a `/skill:<name> [instructions]` submission into its name and trailing instructions.
|
||||
* @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}.
|
||||
* @returns the skill name and any trailing instructions.
|
||||
*/
|
||||
export function parseSkillCommand(text: string): ParsedSkillCommand {
|
||||
const rest = text.slice(SKILL_COMMAND_PREFIX.length)
|
||||
const spaceIndex = rest.indexOf(' ')
|
||||
if (spaceIndex === -1) return { name: rest, instructions: '' }
|
||||
return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() }
|
||||
}
|
||||
|
||||
/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */
|
||||
function skillResourceReference(base: SkillResourceBase | undefined): string | undefined {
|
||||
if (base === undefined) return undefined
|
||||
switch (base.kind) {
|
||||
case 'directory':
|
||||
return `References in this skill are relative to ${base.path}.`
|
||||
case 'url':
|
||||
return `References in this skill are relative to ${base.url}.`
|
||||
case 'opaque':
|
||||
return base.description
|
||||
default:
|
||||
return assertNever(base, 'SkillResourceBase.kind')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a manually invoked skill into the model-visible user-message text. The
|
||||
* `<skill>` block carries the body and, when the provider supplies one, its
|
||||
* resource base; the trimmed `instructions` follow the block as the user's
|
||||
* request for this turn. The name is registry-validated kebab-case
|
||||
* (the skill registry rejects any other) and the resource base is trusted
|
||||
* same-process provider prose, so — unlike the model-facing `dsh-tool-skill`
|
||||
* result, which escapes for a tool channel — this user turn is assembled raw.
|
||||
* @param skill - the loaded skill definition.
|
||||
* @param instructions - trimmed text typed after `/skill:<name>`; empty when absent.
|
||||
* @returns the user-message text delivered to the agent.
|
||||
*/
|
||||
export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string {
|
||||
const lines = [`<skill name="${skill.name}">`]
|
||||
const reference = skillResourceReference(skill.resourceBase)
|
||||
if (reference !== undefined) lines.push(reference, '')
|
||||
lines.push(skill.content, '</skill>')
|
||||
const block = lines.join('\n')
|
||||
return instructions === '' ? block : `${block}\n\n${instructions}`
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Per-step timing model and running-status glyph animation for the terminal
|
||||
* front door. Timing buckets are replayed from the session event stream; the
|
||||
* running glyph fades in on turn start, throbs while the turn runs, and fades
|
||||
* out on turn end.
|
||||
* @module @deepseek-ai/dsh-tui/chat/timing
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Palette } from '../components/theme.ts'
|
||||
|
||||
/**
|
||||
* Render cadence of the running prompt while active, and while the glyph fades
|
||||
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
|
||||
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
|
||||
* changed terminal cells are re-emitted, so the faster tick stays cheap.
|
||||
*/
|
||||
export const STATUS_ANIMATION_INTERVAL_MS = 50
|
||||
|
||||
/**
|
||||
* Milliseconds over which the running glyph fades in when a turn starts and
|
||||
* fades out after it ends. The fade is an envelope over the running pulse:
|
||||
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
|
||||
*/
|
||||
export const STATUS_FADE_MS = 300
|
||||
|
||||
/** Milliseconds for one full brightness throb of the running glyph. */
|
||||
export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
|
||||
/**
|
||||
* Brightness floor of the running throb, as a fraction of the settled gray. At
|
||||
* 0 the pulse swells from the near-background trough up to full and back. The
|
||||
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
|
||||
* cosine breathes symmetrically bold→dim→bold.
|
||||
*/
|
||||
export const STATUS_PULSE_FLOOR = 0
|
||||
|
||||
/**
|
||||
* Muted-gray foreground the truecolor running glyph fades through, from the
|
||||
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
|
||||
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
|
||||
* appearing rather than a colored indicator. Foreground-only, matching the
|
||||
* brand gradient, so it stays legible on any terminal background.
|
||||
*/
|
||||
const STATUS_FADE_GRAY = {
|
||||
trough: [43, 43, 43],
|
||||
settled: [136, 136, 136],
|
||||
} as const
|
||||
|
||||
/** The active phase of a running step, one bucket of accumulated wall time. */
|
||||
export type TimingBucket = 'ttft' | 'thinking' | 'responding' | 'tools'
|
||||
|
||||
/** Turn/step coordinates of one assistant step. */
|
||||
export type StepPosition = { turn: number; step: number }
|
||||
|
||||
/** Accumulated wall time per phase for one step or session slice. */
|
||||
export interface TimingTotals {
|
||||
ttft: number
|
||||
thinking: number
|
||||
responding: number
|
||||
tools: number
|
||||
}
|
||||
|
||||
interface TimingState {
|
||||
totals: TimingTotals
|
||||
active: { bucket: TimingBucket; since: number } | undefined
|
||||
}
|
||||
|
||||
const TIMING_BUCKET_LABELS: Record<TimingBucket, string> = {
|
||||
ttft: 'Model wait',
|
||||
thinking: 'Thinking',
|
||||
responding: 'Response',
|
||||
tools: 'Tools',
|
||||
}
|
||||
|
||||
const TIMING_BUCKETS: readonly TimingBucket[] = ['ttft', 'thinking', 'responding', 'tools']
|
||||
|
||||
function emptyTimingTotals(): TimingTotals {
|
||||
return { ttft: 0, thinking: 0, responding: 0, tools: 0 }
|
||||
}
|
||||
|
||||
function timingState(startedAt?: number): TimingState {
|
||||
return {
|
||||
totals: emptyTimingTotals(),
|
||||
/* v8 ignore next -- production timing state always begins at a logged step timestamp. */
|
||||
active: startedAt === undefined ? undefined : { bucket: 'ttft', since: startedAt },
|
||||
}
|
||||
}
|
||||
|
||||
function sameStep(event: SessionEvent, position: StepPosition): boolean {
|
||||
return typeof event.data === 'object'
|
||||
&& 'turn' in event.data && 'step' in event.data
|
||||
&& event.data.turn === position.turn && event.data.step === position.step
|
||||
}
|
||||
|
||||
function closeTimingBucket(state: TimingState, at: number): void {
|
||||
if (state.active === undefined) return
|
||||
state.totals[state.active.bucket] += Math.max(0, at - state.active.since)
|
||||
state.active = undefined
|
||||
}
|
||||
|
||||
function enterTimingBucket(state: TimingState, bucket: TimingBucket | undefined, at: number): void {
|
||||
if (state.active?.bucket === bucket) return
|
||||
closeTimingBucket(state, at)
|
||||
if (bucket !== undefined) state.active = { bucket, since: at }
|
||||
}
|
||||
|
||||
function advanceStepTiming(
|
||||
state: TimingState,
|
||||
event: Extract<SessionEvent, { type: 'assistant/chunk' | 'tool/call' | 'step/end' }>,
|
||||
): void {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (state.active?.bucket === 'ttft') enterTimingBucket(state, undefined, event.time)
|
||||
if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) {
|
||||
enterTimingBucket(state, 'thinking', event.time)
|
||||
} else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) {
|
||||
enterTimingBucket(state, 'responding', event.time)
|
||||
}
|
||||
} else if (event.type === 'tool/call') {
|
||||
enterTimingBucket(state, 'tools', event.time)
|
||||
} else {
|
||||
closeTimingBucket(state, event.time)
|
||||
}
|
||||
}
|
||||
|
||||
function timingTotalsAt(state: TimingState, at?: number): TimingTotals {
|
||||
const totals = { ...state.totals }
|
||||
if (state.active !== undefined && at !== undefined) {
|
||||
totals[state.active.bucket] += Math.max(0, at - state.active.since)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one step's accumulated per-phase timing up to clock `at`.
|
||||
* @param events - Session events to replay.
|
||||
* @param position - Turn/step coordinates of the step.
|
||||
* @param at - Render clock to accumulate the open bucket up to.
|
||||
* @returns The step's per-phase totals.
|
||||
*/
|
||||
export function stepTimingAt(
|
||||
events: readonly SessionEvent[],
|
||||
position: StepPosition,
|
||||
at: number,
|
||||
): TimingTotals {
|
||||
const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position))
|
||||
if (startIndex < 0) return emptyTimingTotals()
|
||||
const start = events[startIndex] as Extract<SessionEvent, { type: 'step/start' }>
|
||||
const state = timingState(start.time)
|
||||
for (let index = startIndex + 1; index < events.length; index += 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.time > at) break
|
||||
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
|
||||
&& sameStep(event, position)) {
|
||||
advanceStepTiming(state, event)
|
||||
if (event.type === 'step/end') break
|
||||
}
|
||||
}
|
||||
return timingTotalsAt(state, at)
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn index of the currently open turn, or `undefined` when none is open.
|
||||
* @param events - Session events to scan from the tail.
|
||||
* @returns The open turn index, or `undefined`.
|
||||
*/
|
||||
export function openTurn(events: readonly SessionEvent[]): number | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'turn/end') return undefined
|
||||
if (event.type === 'turn/start') return event.data.turn
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-specific status glyph, keyed by the running step's active timing bucket.
|
||||
* `ttft` is the pre-first-token wait a running turn falls back to between steps.
|
||||
*/
|
||||
export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
|
||||
ttft: '◍',
|
||||
thinking: '✻',
|
||||
responding: '●',
|
||||
tools: '⚙',
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the currently open step's active timing bucket, or `undefined` when no
|
||||
* step is open. The open step is the last `step/start` with no later matching
|
||||
* `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}.
|
||||
* @param events - Session events to scan.
|
||||
* @returns The open step's active bucket, or `undefined`.
|
||||
*/
|
||||
export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | undefined {
|
||||
let startIndex = -1
|
||||
let start: Extract<SessionEvent, { type: 'step/start' }> | undefined
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'step/end') return undefined
|
||||
if (event.type === 'step/start') {
|
||||
startIndex = index
|
||||
start = event
|
||||
break
|
||||
}
|
||||
if (event.type === 'turn/end') return undefined
|
||||
}
|
||||
if (start === undefined) return undefined
|
||||
const position = start.data
|
||||
const state = timingState(start.time)
|
||||
for (let index = startIndex + 1; index < events.length; index += 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
|
||||
&& sameStep(event, position)) {
|
||||
advanceStepTiming(state, event)
|
||||
}
|
||||
}
|
||||
return state.active?.bucket
|
||||
}
|
||||
|
||||
/**
|
||||
* The running agent's phase glyph, or `undefined` when idle. A running turn
|
||||
* with no open step falls back to the pre-first-token wait so a glyph is always
|
||||
* available while the agent works; it fades in on turn start, throbs while the
|
||||
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
|
||||
* @param events - Session events to derive the phase from.
|
||||
* @param running - Whether the agent is currently running.
|
||||
* @returns The phase glyph, or `undefined` when idle.
|
||||
*/
|
||||
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
|
||||
if (!running) return undefined
|
||||
const bucket = openStepPhase(events) ?? 'ttft'
|
||||
return TIMING_BUCKET_GLYPHS[bucket]
|
||||
}
|
||||
|
||||
/**
|
||||
* The running throb's brightness at continuous clock `nowMs`: a cosine between
|
||||
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
|
||||
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
|
||||
* fade envelope, which alone drives appear/disappear at turn boundaries.
|
||||
*
|
||||
* @param nowMs - Monotonic render clock in milliseconds.
|
||||
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
|
||||
*/
|
||||
export function pulseLevel(nowMs: number): number {
|
||||
const phase = (nowMs % STATUS_PULSE_PERIOD_MS) / STATUS_PULSE_PERIOD_MS
|
||||
const wave = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase)
|
||||
return STATUS_PULSE_FLOOR + (1 - STATUS_PULSE_FLOOR) * wave
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of the running glyph at fade `opacity` (0 = near-background trough
|
||||
* gray, 1 = settled dim gray). The character and its width never change — only
|
||||
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
|
||||
* the caret dimly breathing, never a colored indicator.
|
||||
*
|
||||
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
|
||||
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
|
||||
* and the running throb render as a smooth, symmetric brightness swing with no
|
||||
* hard cutoff to clip the trough into a blank. Without truecolor there is no
|
||||
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
|
||||
* shows the glyph in the palette's muted role or leaves a blank column — a
|
||||
* single dim appear/disappear at fixed width, still dim rather than accent, and
|
||||
* no throb-driven blink. With color off entirely a visible glyph is bare,
|
||||
* holding the caret column on a monochrome terminal.
|
||||
*
|
||||
* @param glyph - The phase glyph to paint.
|
||||
* @param palette - Active palette supplying the muted (dim gray) role.
|
||||
* @param colorEnabled - Whether ANSI is emitted at all.
|
||||
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.
|
||||
* @param opacity - Brightness fraction in [0, 1] for the truecolor gray.
|
||||
* @param visible - Whether the non-truecolor fallback shows the glyph at all.
|
||||
* @returns The gray glyph at this opacity, or a single space when hidden.
|
||||
*/
|
||||
export function fadeGlyph(
|
||||
glyph: string,
|
||||
palette: Palette,
|
||||
colorEnabled: boolean,
|
||||
truecolor: boolean,
|
||||
opacity: number,
|
||||
visible: boolean,
|
||||
): string {
|
||||
if (truecolor && colorEnabled) {
|
||||
const o = Math.min(Math.max(opacity, 0), 1)
|
||||
const [tr, tg, tb] = STATUS_FADE_GRAY.trough
|
||||
const [sr, sg, sb] = STATUS_FADE_GRAY.settled
|
||||
const r = Math.round(tr + (sr - tr) * o)
|
||||
const g = Math.round(tg + (sg - tg) * o)
|
||||
const b = Math.round(tb + (sb - tb) * o)
|
||||
return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m`
|
||||
}
|
||||
if (!visible) return ' '
|
||||
return colorEnabled ? palette.muted(glyph) : glyph
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a non-negative elapsed span at 100 ms resolution.
|
||||
* @param elapsedMs - Elapsed milliseconds.
|
||||
* @returns The formatted duration (e.g. `1.5s`, `2m03.4s`).
|
||||
*/
|
||||
export function formatStatusDuration(elapsedMs: number): string {
|
||||
const tenths = Math.floor(Math.max(0, elapsedMs) / 100)
|
||||
const seconds = tenths / 10
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${minutes}m${(seconds - minutes * 60).toFixed(1).padStart(4, '0')}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the non-zero timing buckets of one step as a middot-joined summary.
|
||||
* @param totals - Per-phase totals to format.
|
||||
* @param includeModelWait - Whether to always include the model-wait bucket.
|
||||
* @returns The formatted timing summary.
|
||||
*/
|
||||
export function formatTimingTotals(totals: TimingTotals, includeModelWait = false): string {
|
||||
return TIMING_BUCKETS
|
||||
.filter(bucket => totals[bucket] > 0 || (includeModelWait && bucket === 'ttft'))
|
||||
.map(bucket => `${TIMING_BUCKET_LABELS[bucket]} ${formatStatusDuration(totals[bucket])}`)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the queued-steering badge shown on the running status line.
|
||||
* @param queued - Number of queued steering messages.
|
||||
* @returns The badge text, or `undefined` when nothing is queued.
|
||||
*/
|
||||
export function formatQueuedStatus(queued: number): string | undefined {
|
||||
return queued > 0 ? `${queued} queued` : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a completion timestamp as `YYYY-MM-DD HH:MM:SS` in local time.
|
||||
* @param time - Epoch milliseconds.
|
||||
* @returns The formatted local timestamp.
|
||||
*/
|
||||
export function formatCompletionTime(time: number): string {
|
||||
const date = new Date(time)
|
||||
const parts = [
|
||||
date.getFullYear().toString().padStart(4, '0'),
|
||||
(date.getMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getDate().toString().padStart(2, '0'),
|
||||
]
|
||||
const clock = [date.getHours(), date.getMinutes(), date.getSeconds()]
|
||||
.map(value => value.toString().padStart(2, '0'))
|
||||
.join(':')
|
||||
return `${parts.join('-')} ${clock}`
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Running token accounting for the terminal footer. Usage is keyed per
|
||||
* turn/step so replayed or re-emitted usage replaces rather than double-counts.
|
||||
* @module @deepseek-ai/dsh-tui/chat/tokens
|
||||
*/
|
||||
|
||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Running token totals for the footer, keyed per turn/step so replayed or
|
||||
* re-emitted usage replaces rather than double-counts; `input` is uncached
|
||||
* input, cache buckets are disjoint.
|
||||
*/
|
||||
export interface SessionTokenTotals {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
readonly byStep: Map<string, TokenUsage>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one step's usage into the running totals, replacing any prior usage
|
||||
* logged for the same turn/step.
|
||||
* @param totals - Running totals mutated in place.
|
||||
* @param turn - Turn index of the usage.
|
||||
* @param step - Step index of the usage.
|
||||
* @param usage - The step's token usage.
|
||||
*/
|
||||
export function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void {
|
||||
const key = `${turn}:${step}`
|
||||
const previous = totals.byStep.get(key)
|
||||
if (previous !== undefined) {
|
||||
totals.input -= previous.inputTokens
|
||||
totals.output -= previous.outputTokens
|
||||
totals.cacheRead -= previous.cacheReadTokens ?? 0
|
||||
totals.cacheWrite -= previous.cacheWriteTokens ?? 0
|
||||
}
|
||||
totals.byStep.set(key, usage)
|
||||
totals.input += usage.inputTokens
|
||||
totals.output += usage.outputTokens
|
||||
totals.cacheRead += usage.cacheReadTokens ?? 0
|
||||
totals.cacheWrite += usage.cacheWriteTokens ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a usage-bearing session event into the running totals.
|
||||
* @param totals - Running totals mutated in place.
|
||||
* @param event - Session event; ignored when it carries no usage.
|
||||
*/
|
||||
export function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void {
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
|
||||
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage)
|
||||
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
||||
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of billed input (prompt) tokens served from the provider cache, as an
|
||||
* integer percent, or `undefined` before any input is billed (avoids 0/0 and a
|
||||
* meaningless rate on an empty session).
|
||||
* @param totals - Running totals to measure.
|
||||
* @returns The cache hit rate percent, or `undefined` when no input is billed.
|
||||
*/
|
||||
export function cacheHitRate(totals: SessionTokenTotals): number | undefined {
|
||||
const billedInput = totals.input + totals.cacheRead + totals.cacheWrite
|
||||
if (billedInput === 0) return undefined
|
||||
return Math.round((totals.cacheRead / billedInput) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold every usage-bearing event in a session into fresh totals.
|
||||
* @param session - Session whose events supply usage.
|
||||
* @returns The accumulated token totals.
|
||||
*/
|
||||
export function sessionTokens(session: Session): SessionTokenTotals {
|
||||
const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() }
|
||||
for (const event of session.events) {
|
||||
recordEventUsage(totals, event)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a token count with a compact k/m suffix for the footer.
|
||||
* @param value - Token count.
|
||||
* @returns The compact display string.
|
||||
*/
|
||||
export function formatTokens(value: number): string {
|
||||
if (value < 1_000) return String(value)
|
||||
if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`
|
||||
if (value < 1_000_000) return `${Math.round(value / 1_000)}k`
|
||||
return `${(value / 1_000_000).toFixed(1)}m`
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Content-block primitives shared across the terminal front door: flattening
|
||||
* session content to display text and parsing tool-call arguments.
|
||||
* @module @deepseek-ai/dsh-tui/components/content
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* Flatten content blocks into a single display string, recursing into
|
||||
* tool-result content and naming unknown block types.
|
||||
* @param content - Content blocks to flatten.
|
||||
* @returns The concatenated display text.
|
||||
*/
|
||||
export function contentText(content: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
parts.push(block.text)
|
||||
break
|
||||
case 'tool-call':
|
||||
parts.push(`${block.name}(${block.arguments})`)
|
||||
break
|
||||
case 'tool-result':
|
||||
parts.push(contentText(block.content))
|
||||
break
|
||||
case 'image':
|
||||
parts.push(`[image attachment ${block.attachment.attachmentId}]`)
|
||||
break
|
||||
default: {
|
||||
const rawType = (block as { type?: unknown }).type
|
||||
parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** A tool call's arguments parsed from their JSON source, with a validity flag. */
|
||||
export interface ParsedArguments {
|
||||
value: unknown
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool-call arguments from their JSON source.
|
||||
* @param raw - Raw JSON arguments text.
|
||||
* @returns The parsed value, or the raw text with `valid: false` on parse failure.
|
||||
*/
|
||||
export function parseArguments(raw: string): ParsedArguments {
|
||||
try {
|
||||
return { value: JSON.parse(raw), valid: true }
|
||||
} catch {
|
||||
return { value: raw, valid: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* pi-tui dialog and selector components for the terminal front door: the status
|
||||
* card, prompt-context line, model selector, resume picker, and user-question
|
||||
* dialog, plus the model-choice and resume-candidate data they present.
|
||||
* @module @deepseek-ai/dsh-tui/components/dialogs
|
||||
*/
|
||||
|
||||
import {
|
||||
Input,
|
||||
Key,
|
||||
SelectList,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
type Component,
|
||||
type Focusable,
|
||||
type SelectItem,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
type Agent,
|
||||
type AgentLlmTarget,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionLogSnapshot,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
|
||||
import { dialogSelectTheme, type Palette } from './theme.ts'
|
||||
import {
|
||||
renderTuiPromptTemplate,
|
||||
type TuiPromptTemplateToken,
|
||||
} from '../prompt.ts'
|
||||
|
||||
/** A selectable model advertised by a provider, with its display name, description, and reasoning metadata. */
|
||||
export interface ModelChoice extends AgentLlmTarget {
|
||||
modelName: string
|
||||
description?: string
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider/model route and selected reasoning effort resolved from a model dialog.
|
||||
*/
|
||||
export interface ModelDialogSelection {
|
||||
choice: ModelChoice
|
||||
reasoningEffort: ReasoningEffortId | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a provider/model target as its `provider/model` label.
|
||||
* @param target - The LLM target.
|
||||
* @returns The `provider/model` label.
|
||||
*/
|
||||
export function targetLabel(target: AgentLlmTarget): string {
|
||||
return `${target.provider}/${target.model}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a target compactly as its model name with any selected reasoning effort appended.
|
||||
* @param target - The LLM target.
|
||||
* @returns The compact `model [effort]` label.
|
||||
*/
|
||||
export function compactTargetLabel(target: AgentLlmTarget): string {
|
||||
return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display label for a choice's reasoning effort.
|
||||
* @param choice - The model choice carrying advertised reasoning metadata.
|
||||
* @param effort - The selected effort, or `undefined` for provider default.
|
||||
* @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata.
|
||||
*/
|
||||
export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined {
|
||||
if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default'
|
||||
return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the agent's initial LLM target from its logged request header or options.
|
||||
* @param agent - The driven agent.
|
||||
* @returns The initial target, or `undefined` when unset.
|
||||
*/
|
||||
export function initialTarget(agent: Agent): AgentLlmTarget | undefined {
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
if (logged !== undefined) {
|
||||
if (logged.reasoningEffort === undefined) {
|
||||
return { provider: logged.provider, model: logged.model }
|
||||
}
|
||||
return { provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort }
|
||||
}
|
||||
if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
|
||||
return { provider: agent.options.provider, model: agent.options.model }
|
||||
}
|
||||
|
||||
/**
|
||||
* List every advertised model across registered providers, appending the current
|
||||
* target when a provider does not advertise it.
|
||||
* @param ctx - Context supplying the LLM service.
|
||||
* @param current - The current target, appended when unadvertised.
|
||||
* @returns The model choices, flattened across providers.
|
||||
*/
|
||||
export async function readModelChoices(
|
||||
ctx: Context,
|
||||
current: AgentLlmTarget | undefined,
|
||||
): Promise<ModelChoice[]> {
|
||||
const providers = ctx.llm.listProviders()
|
||||
const groups = await Promise.all(providers.map(async (provider) => {
|
||||
const advertised = await ctx.llm.listModels(provider.id)
|
||||
const models: LlmModelInfo[] = [...advertised]
|
||||
if (
|
||||
current?.provider === provider.id
|
||||
&& !models.some(model => model.id === current.model)
|
||||
) {
|
||||
models.push({ provider: provider.id, id: current.model, name: current.model })
|
||||
}
|
||||
return Promise.all(models.map(async (model): Promise<ModelChoice> => {
|
||||
const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning
|
||||
return {
|
||||
provider: provider.id,
|
||||
model: model.id,
|
||||
modelName: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
}))
|
||||
}))
|
||||
return groups.flat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a diagnostic integer with grouping separators.
|
||||
* @param value - Integer to format.
|
||||
* @returns The grouped decimal string.
|
||||
*/
|
||||
export function formatDiagnosticNumber(value: number): string {
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a diagnostic timestamp as an ISO date-time in UTC.
|
||||
* @param value - Epoch milliseconds.
|
||||
* @returns The formatted UTC timestamp.
|
||||
*/
|
||||
export function formatDiagnosticTime(value: number): string {
|
||||
return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a pluralized count for a diagnostic row.
|
||||
* @param value - Count.
|
||||
* @param singular - Singular noun; an `s` is appended for other counts.
|
||||
* @returns The formatted count.
|
||||
*/
|
||||
export function formatDiagnosticCount(value: number, singular: string): string {
|
||||
return `${String(value)} ${singular}${value === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a fixed-width filled meter bar for a percentage.
|
||||
* @param percent - Percentage in [0, 100].
|
||||
* @param palette - Active role palette.
|
||||
* @returns The rendered meter.
|
||||
*/
|
||||
export function diagnosticMeter(percent: number, palette: Palette): string {
|
||||
const width = 16
|
||||
const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width)
|
||||
return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}`
|
||||
}
|
||||
|
||||
/** One `label: value` row of a status card group. */
|
||||
export type StatusCardRow = readonly [label: string, value: string]
|
||||
|
||||
/** Bordered, grouped field card for one point-in-time status snapshot. */
|
||||
export class StatusCardComponent implements Component {
|
||||
constructor(
|
||||
private readonly groups: readonly (readonly StatusCardRow[])[],
|
||||
private readonly palette: Palette,
|
||||
) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`))
|
||||
const naturalLabelWidth = Math.max(...labels.map(label => label.length))
|
||||
const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) =>
|
||||
1 + naturalLabelWidth + 2 + visibleWidth(value))))
|
||||
const cardWidth = Math.min(
|
||||
Math.max(8, width),
|
||||
Math.max('Session status'.length + 5, naturalBodyWidth + 4),
|
||||
)
|
||||
const innerWidth = Math.max(1, cardWidth - 4)
|
||||
const labelWidth = Math.min(
|
||||
naturalLabelWidth,
|
||||
Math.max(1, Math.floor(innerWidth / 3)),
|
||||
)
|
||||
const body: string[] = []
|
||||
for (const [groupIndex, group] of this.groups.entries()) {
|
||||
if (groupIndex > 0) body.push('')
|
||||
for (const [label, value] of group) {
|
||||
const plainLabel = truncateToWidth(`${label}:`, labelWidth, '')
|
||||
const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} `
|
||||
const continuation = ' '.repeat(1 + labelWidth + 2)
|
||||
const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix))
|
||||
const wrapped = wrapTextWithAnsi(value, valueWidth)
|
||||
for (const [lineIndex, line] of wrapped.entries()) {
|
||||
body.push(`${lineIndex === 0 ? prefix : continuation}${line}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '')
|
||||
const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5))
|
||||
const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}`
|
||||
const lines = [top]
|
||||
for (const line of body) {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`)
|
||||
}
|
||||
lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`))
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
/** The left/right template line rendered above the editor. */
|
||||
export class PromptContextComponent implements Component {
|
||||
constructor(
|
||||
private readonly leftTemplate: readonly TuiPromptTemplateToken[],
|
||||
private readonly rightTemplate: readonly TuiPromptTemplateToken[],
|
||||
private readonly resolve: (name: string) => string | undefined,
|
||||
) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const right = truncateToWidth(renderTuiPromptTemplate(this.rightTemplate, this.resolve), width, '')
|
||||
const rightWidth = visibleWidth(right)
|
||||
const leftCapacity = Math.max(0, width - rightWidth - (rightWidth === 0 ? 0 : 2))
|
||||
const left = truncateToWidth(renderTuiPromptTemplate(this.leftTemplate, this.resolve), leftCapacity, '')
|
||||
if (rightWidth === 0) return [left]
|
||||
const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - rightWidth))
|
||||
return [`${left}${gap}${right}`]
|
||||
}
|
||||
}
|
||||
|
||||
/** A user's answer to one question: chosen option labels and an optional custom answer. */
|
||||
export interface QuestionSelection {
|
||||
selected: string[]
|
||||
custom?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a bordered dialog frame around body lines with a titled top edge.
|
||||
* @param title - Dialog title shown in the top border.
|
||||
* @param body - Body lines.
|
||||
* @param width - Dialog width in columns.
|
||||
* @param palette - Active role palette.
|
||||
* @returns The framed dialog lines.
|
||||
*/
|
||||
export function renderDialog(
|
||||
title: string,
|
||||
body: readonly string[],
|
||||
width: number,
|
||||
palette: Palette,
|
||||
): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
const topLabel = ` ${displayText(title)} `
|
||||
const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮`
|
||||
const lines: string[] = [palette.accent(top)]
|
||||
for (const line of body) {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`)
|
||||
}
|
||||
lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`))
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */
|
||||
export class ModelDialog implements Component {
|
||||
private readonly list: SelectList
|
||||
private readonly items: Map<string, SelectItem>
|
||||
private readonly choices: Map<string, ModelChoice>
|
||||
private readonly efforts: Map<string, ReasoningEffortId | undefined>
|
||||
private readonly currentValue: string | undefined
|
||||
|
||||
constructor(
|
||||
choices: readonly ModelChoice[],
|
||||
current: AgentLlmTarget | undefined,
|
||||
maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
done: (selection: ModelDialogSelection) => void,
|
||||
cancel: () => void,
|
||||
) {
|
||||
this.items = new Map()
|
||||
this.choices = new Map()
|
||||
this.efforts = new Map()
|
||||
this.currentValue = current === undefined ? undefined : targetLabel(current)
|
||||
for (const choice of choices) {
|
||||
const value = targetLabel(choice)
|
||||
const isCurrent = current?.provider === choice.provider && current.model === choice.model
|
||||
this.choices.set(value, choice)
|
||||
this.efforts.set(
|
||||
value,
|
||||
isCurrent
|
||||
? current.reasoningEffort ?? choice.reasoning?.defaultEffort
|
||||
: choice.reasoning?.defaultEffort,
|
||||
)
|
||||
this.items.set(value, {
|
||||
value,
|
||||
label: displayText(value),
|
||||
description: this.describeChoice(choice, isCurrent),
|
||||
})
|
||||
}
|
||||
this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette))
|
||||
const currentIndex = current === undefined
|
||||
? 0
|
||||
: choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
|
||||
this.list.setSelectedIndex(currentIndex)
|
||||
this.list.onSelect = (item) => {
|
||||
const selected = choices.find(choice => targetLabel(choice) === item.value)
|
||||
/* v8 ignore next -- SelectList only returns values built from `choices`. */
|
||||
if (selected === undefined) return
|
||||
done({ choice: selected, reasoningEffort: this.efforts.get(item.value) })
|
||||
}
|
||||
this.list.onCancel = cancel
|
||||
}
|
||||
|
||||
private describeChoice(choice: ModelChoice, isCurrent: boolean): string {
|
||||
const effortLabel = targetReasoningLabel(choice, this.efforts.get(targetLabel(choice)))
|
||||
return [
|
||||
displayText(choice.modelName),
|
||||
...choice.description === undefined ? [] : [displayText(choice.description)],
|
||||
...effortLabel === undefined ? [] : [displayText(effortLabel)],
|
||||
...isCurrent ? ['current'] : [],
|
||||
].join(' — ')
|
||||
}
|
||||
|
||||
private cycleReasoningEffort(): void {
|
||||
const selectedItem = this.list.getSelectedItem()
|
||||
/* v8 ignore next -- the dialog is opened only for a non-empty catalog. */
|
||||
if (selectedItem === null) return
|
||||
const choice = this.choices.get(selectedItem.value)
|
||||
if (choice?.reasoning === undefined) return
|
||||
const current = this.efforts.get(selectedItem.value)
|
||||
const efforts: Array<ReasoningEffortId | undefined> = [
|
||||
...choice.reasoning.defaultEffort === undefined ? [undefined] : [],
|
||||
...choice.reasoning.efforts.map(effort => effort.id),
|
||||
]
|
||||
const currentIndex = efforts.indexOf(current)
|
||||
const next = efforts[(currentIndex + 1) % efforts.length]
|
||||
this.efforts.set(selectedItem.value, next)
|
||||
const item = this.items.get(selectedItem.value)
|
||||
/* v8 ignore next -- items and choices are constructed from the same values. */
|
||||
if (item === undefined) return
|
||||
item.description = this.describeChoice(choice, selectedItem.value === this.currentValue)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.list.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.shift(Key.tab))) {
|
||||
this.cycleReasoningEffort()
|
||||
} else {
|
||||
this.list.handleInput(data)
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
return renderDialog('Select model', [
|
||||
...this.list.render(innerWidth),
|
||||
'',
|
||||
this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
|
||||
], width, this.palette)
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider/model route recovered from a resume candidate's log. */
|
||||
export interface ResumeRoute {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** A preflighted resume selector row summarizing one persisted session. */
|
||||
export interface ResumeCandidate {
|
||||
record: SessionRecord
|
||||
title: string
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
route?: ResumeRoute
|
||||
goalPhase?: GoalPhase
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
|
||||
const event = snapshot.events.findLast(item => item.type === 'turn/end')
|
||||
if (event === undefined) return 'no completed turn'
|
||||
const reason = event.data.reason
|
||||
switch (reason.kind) {
|
||||
case 'completed': return `turn ${event.data.turn}: completed`
|
||||
case 'aborted': return `turn ${event.data.turn}: cancelled`
|
||||
case 'error': return `turn ${event.data.turn}: error`
|
||||
case 'disposed': return `turn ${event.data.turn}: disposed`
|
||||
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
|
||||
case 'interrupted': return `turn ${event.data.turn}: interrupted`
|
||||
default: return `turn ${event.data.turn}: unknown result`
|
||||
}
|
||||
}
|
||||
|
||||
function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
|
||||
const header = snapshot.events.findLast(item => item.type === 'request/header')
|
||||
if (header?.type === 'request/header') {
|
||||
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
|
||||
}
|
||||
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one resume selector row from a record and its log snapshot, deriving the
|
||||
* title, route, goal phase, and any reason the session cannot be resumed here.
|
||||
* @param record - The session record.
|
||||
* @param snapshot - The session's log snapshot.
|
||||
* @param currentId - The current session id.
|
||||
* @param cwd - The current workspace directory.
|
||||
* @param availableProviders - Providers registered in this runtime.
|
||||
* @returns The summarized resume candidate.
|
||||
*/
|
||||
export function summarizeResumeCandidate(
|
||||
record: SessionRecord,
|
||||
snapshot: SessionLogSnapshot,
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
|
||||
const route = resumeRoute(snapshot)
|
||||
const foldedGoal = foldGoal(snapshot.events).goal
|
||||
let disabledReason: string | undefined
|
||||
if (record.header.id === currentId) disabledReason = 'current session'
|
||||
else if (record.live) disabledReason = 'session is already live in this runtime'
|
||||
else if (record.header.cwd !== cwd) disabledReason = 'different workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
return {
|
||||
record,
|
||||
title,
|
||||
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
|
||||
lastTurn: resumeTurnLabel(snapshot),
|
||||
...route === undefined ? {} : { route },
|
||||
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
|
||||
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
|
||||
...disabledReason === undefined ? {} : { disabledReason },
|
||||
}
|
||||
}
|
||||
|
||||
/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
|
||||
export class ResumePicker implements Component, Focusable {
|
||||
private readonly search = new Input()
|
||||
private pasteBuffer: string | undefined
|
||||
private selectedIndex = 0
|
||||
private error = ''
|
||||
focused = false
|
||||
|
||||
constructor(
|
||||
private readonly candidates: readonly ResumeCandidate[],
|
||||
private readonly maxVisible: number,
|
||||
private readonly workspaceLabel: string,
|
||||
private readonly viewportRows: () => number,
|
||||
private readonly palette: Palette,
|
||||
private readonly done: (candidate: ResumeCandidate) => void,
|
||||
private readonly cancel: () => void,
|
||||
) {}
|
||||
|
||||
invalidate(): void {
|
||||
this.search.invalidate()
|
||||
}
|
||||
|
||||
private filtered(): ResumeCandidate[] {
|
||||
const query = this.search.getValue().trim().toLocaleLowerCase()
|
||||
if (query === '') return [...this.candidates]
|
||||
return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|
||||
|| candidate.record.header.id.toLocaleLowerCase().includes(query))
|
||||
}
|
||||
|
||||
private visibleCandidateCount(): number {
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
|
||||
return Math.min(this.maxVisible, candidateBudget)
|
||||
}
|
||||
|
||||
private handleBracketedPaste(data: string): boolean {
|
||||
const start = data.indexOf(BRACKETED_PASTE_START)
|
||||
if (this.pasteBuffer === undefined && start < 0) return false
|
||||
if (this.pasteBuffer === undefined) {
|
||||
const prefix = data.slice(0, start)
|
||||
if (prefix !== '') this.handleInput(prefix)
|
||||
this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length)
|
||||
} else {
|
||||
this.pasteBuffer += data
|
||||
}
|
||||
const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END)
|
||||
if (end < 0) return true
|
||||
const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end))
|
||||
const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length)
|
||||
this.pasteBuffer = undefined
|
||||
const previous = this.search.getValue()
|
||||
this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`)
|
||||
if (this.search.getValue() !== previous) {
|
||||
this.selectedIndex = 0
|
||||
this.error = ''
|
||||
}
|
||||
if (remaining !== '') this.handleInput(remaining)
|
||||
this.invalidate()
|
||||
return true
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.handleBracketedPaste(data)) return
|
||||
const filtered = this.filtered()
|
||||
if (matchesKey(data, Key.ctrl('c'))) {
|
||||
this.cancel()
|
||||
return
|
||||
}
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
if (this.search.getValue() === '') this.cancel()
|
||||
else {
|
||||
this.search.setValue('')
|
||||
this.selectedIndex = 0
|
||||
this.error = ''
|
||||
}
|
||||
} else if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = filtered.length === 0
|
||||
? 0
|
||||
: (this.selectedIndex + filtered.length - 1) % filtered.length
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
|
||||
} else if (matchesKey(data, Key.pageUp)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount())
|
||||
} else if (matchesKey(data, Key.pageDown)) {
|
||||
this.selectedIndex = Math.min(
|
||||
Math.max(0, filtered.length - 1),
|
||||
this.selectedIndex + this.visibleCandidateCount(),
|
||||
)
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const selected = filtered[this.selectedIndex]
|
||||
if (selected === undefined) this.error = 'No session matches this search.'
|
||||
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
|
||||
else this.done(selected)
|
||||
} else {
|
||||
const previous = this.search.getValue()
|
||||
this.search.focused = this.focused
|
||||
this.search.handleInput(data)
|
||||
if (this.search.getValue() !== previous) {
|
||||
this.selectedIndex = 0
|
||||
this.error = ''
|
||||
}
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
this.search.focused = this.focused
|
||||
const height = Math.max(1, this.viewportRows())
|
||||
const horizontalPadding = width >= 12 ? 2 : 0
|
||||
const contentWidth = Math.max(1, width - horizontalPadding * 2)
|
||||
const indent = ' '.repeat(horizontalPadding)
|
||||
const filtered = this.filtered()
|
||||
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
|
||||
const selected = filtered[this.selectedIndex]
|
||||
const position = selected === undefined ? 0 : this.selectedIndex + 1
|
||||
const lines: string[] = [
|
||||
'',
|
||||
`${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
|
||||
'',
|
||||
]
|
||||
|
||||
const searchInnerWidth = Math.max(1, contentWidth - 4)
|
||||
lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`)
|
||||
const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ')
|
||||
const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '')
|
||||
lines.push(
|
||||
`${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`,
|
||||
`${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`,
|
||||
'',
|
||||
`${indent}${this.palette.muted(displayText(this.workspaceLabel))}`,
|
||||
'',
|
||||
)
|
||||
|
||||
const visibleCount = this.visibleCandidateCount()
|
||||
const start = Math.max(0, Math.min(
|
||||
this.selectedIndex - Math.floor(visibleCount / 2),
|
||||
filtered.length - visibleCount,
|
||||
))
|
||||
const end = Math.min(filtered.length, start + visibleCount)
|
||||
const push = (line: string): void => {
|
||||
lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`)
|
||||
}
|
||||
for (let index = start; index < end; index += 1) {
|
||||
const candidate = filtered[index] as ResumeCandidate
|
||||
const active = index === this.selectedIndex
|
||||
const status = [
|
||||
candidate.disabledReason === 'current session' ? 'current' : undefined,
|
||||
candidate.record.live ? 'live' : undefined,
|
||||
candidate.record.persisted ? 'persisted' : undefined,
|
||||
].filter((value): value is string => value !== undefined).join(' · ')
|
||||
const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}`
|
||||
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
|
||||
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
|
||||
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
|
||||
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
|
||||
push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
||||
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
if (candidate.disabledReason !== undefined) {
|
||||
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
|
||||
}
|
||||
}
|
||||
if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
|
||||
if (this.error !== '') {
|
||||
lines.push('')
|
||||
push(this.palette.error(displayText(this.error)))
|
||||
}
|
||||
|
||||
const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}`
|
||||
while (lines.length < height - 2) lines.push('')
|
||||
lines.push(footer, '')
|
||||
return lines.slice(0, height)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bottom-anchored dialog for one user question with option or custom-answer modes. */
|
||||
export class QuestionDialog implements Component, Focusable {
|
||||
private selectedIndex = 0
|
||||
private selected = new Set<number>()
|
||||
private mode: 'options' | 'custom'
|
||||
private error = ''
|
||||
private readonly input = new Input()
|
||||
private readonly options: NonNullable<AskUserQuestionItem['options']>
|
||||
focused = false
|
||||
|
||||
constructor(
|
||||
private readonly question: AskUserQuestionItem,
|
||||
private readonly position: number,
|
||||
private readonly total: number,
|
||||
private readonly unanswered: number,
|
||||
private readonly maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
private readonly done: (selection: QuestionSelection) => void,
|
||||
private readonly cancel: () => void,
|
||||
) {
|
||||
this.options = question.options ?? []
|
||||
this.mode = this.options.length > 0 ? 'options' : 'custom'
|
||||
this.input.onSubmit = (value) => { this.submitCustom(value) }
|
||||
this.input.onEscape = () => {
|
||||
if (this.options.length > 0) {
|
||||
this.mode = 'options'
|
||||
this.error = ''
|
||||
} else {
|
||||
this.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.invalidate()
|
||||
if (this.mode === 'custom') {
|
||||
this.input.focused = this.focused
|
||||
this.input.handleInput(data)
|
||||
return
|
||||
}
|
||||
const options = this.options
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1
|
||||
} else if (matchesKey(data, Key.space) && this.question.multiSelect) {
|
||||
if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
|
||||
else this.selected.add(this.selectedIndex)
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
|
||||
if (indices.length === 0) {
|
||||
this.error = 'Select at least one option, or press Tab for a custom answer.'
|
||||
return
|
||||
}
|
||||
this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
|
||||
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
|
||||
this.mode = 'custom'
|
||||
this.error = ''
|
||||
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
|
||||
this.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private submitCustom(value: string): void {
|
||||
const custom = value.trim()
|
||||
if (custom === '') {
|
||||
this.error = 'Enter an answer before submitting.'
|
||||
return
|
||||
}
|
||||
this.done({ selected: [], custom })
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
this.input.focused = this.focused
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
|
||||
const lines = [
|
||||
this.palette.muted(header),
|
||||
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
|
||||
]
|
||||
const push = (line: string): void => { lines.push(line) }
|
||||
// Supporting detail (e.g. the full plan under review) renders between the
|
||||
// question and the answer surface, kept out of option labels.
|
||||
if (this.question.detail !== undefined) {
|
||||
push('')
|
||||
for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line)
|
||||
}
|
||||
push('')
|
||||
if (this.mode === 'custom') {
|
||||
for (const line of this.input.render(innerWidth)) push(line)
|
||||
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
|
||||
} else {
|
||||
const options = this.options
|
||||
const start = Math.max(0, Math.min(
|
||||
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
||||
options.length - this.maxVisible,
|
||||
))
|
||||
const end = Math.min(options.length, start + this.maxVisible)
|
||||
const optionRows = options.slice(start, end).map((option, offset) => {
|
||||
const index = start + offset
|
||||
const mark = this.question.multiSelect
|
||||
? this.selected.has(index) ? '[x] ' : '[ ] '
|
||||
: ''
|
||||
return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
|
||||
})
|
||||
const descriptionColumn = Math.min(
|
||||
Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
|
||||
Math.max(1, Math.floor(innerWidth * 0.55)),
|
||||
)
|
||||
for (let index = start; index < end; index += 1) {
|
||||
// `index < end <= options.length`; the options array is borrowed immutably for this dialog.
|
||||
const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
|
||||
const mark = this.question.multiSelect
|
||||
? this.selected.has(index) ? '[x] ' : '[ ] '
|
||||
: ''
|
||||
const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
|
||||
const leftStyled = index === this.selectedIndex
|
||||
? this.palette.bold(this.palette.accent(left))
|
||||
: left
|
||||
const description = option.description === undefined
|
||||
? ''
|
||||
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
|
||||
push(`${leftStyled}${description}`)
|
||||
}
|
||||
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
|
||||
const controls = [
|
||||
'Tab custom answer',
|
||||
...(options.length > 1 ? ['↑/↓ navigate'] : []),
|
||||
...(this.question.multiSelect ? ['Space toggle'] : []),
|
||||
'Enter submit',
|
||||
'Esc interrupt',
|
||||
]
|
||||
const hint = this.palette.dim(controls.join(' • '))
|
||||
for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
|
||||
}
|
||||
if (this.error) {
|
||||
for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
|
||||
}
|
||||
return ['', ...lines, ''].map((line) => {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Terminal text sanitization shared across the pi-tui front door. External text
|
||||
* (model output, tool results, clipboard) is escaped or stripped of C0/C1
|
||||
* controls before the TUI adds its own application-owned ANSI.
|
||||
* @module @deepseek-ai/dsh-tui/components/text
|
||||
*/
|
||||
|
||||
const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu
|
||||
const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
|
||||
const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
|
||||
const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu
|
||||
|
||||
/** Bracketed-paste start marker emitted by terminals around pasted content. */
|
||||
export const BRACKETED_PASTE_START = '\u001B[200~'
|
||||
/** Bracketed-paste end marker emitted by terminals around pasted content. */
|
||||
export const BRACKETED_PASTE_END = '\u001B[201~'
|
||||
|
||||
/**
|
||||
* Escape external C0/C1 controls before pi-tui adds application-owned ANSI.
|
||||
* Line feeds remain structural so transcript and tool output retain their layout.
|
||||
* @param text - Untrusted text to render.
|
||||
* @returns The text with control characters escaped as `\xNN`.
|
||||
*/
|
||||
export function displayText(text: string): string {
|
||||
return text.replace(TERMINAL_CONTROL_PATTERN, control =>
|
||||
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape external controls for terminal fields that must remain on one line.
|
||||
* @param text - Untrusted text to render inline.
|
||||
* @returns The escaped text with newlines rendered as `\x0a`.
|
||||
*/
|
||||
export function displayInlineText(text: string): string {
|
||||
return displayText(text).replaceAll('\n', '\\x0a')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove terminal controls from clipboard text before an editable field stores it.
|
||||
* @param text - Raw pasted clipboard text.
|
||||
* @returns The text stripped of OSC, CSI, escape, and control sequences.
|
||||
*/
|
||||
export function sanitizePastedText(text: string): string {
|
||||
return text
|
||||
.replace(TERMINAL_OSC_PATTERN, '')
|
||||
.replace(TERMINAL_CSI_PATTERN, '')
|
||||
.replace(TERMINAL_ESCAPE_PATTERN, '')
|
||||
.replace(TERMINAL_CONTROL_PATTERN, '')
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Theme-agnostic ANSI palette and derived pi-tui themes for the terminal front
|
||||
* door. The palette is built from the standard 16-color ANSI set plus SGR
|
||||
* attributes so every terminal remaps it to its active color scheme.
|
||||
* @module @deepseek-ai/dsh-tui/components/theme
|
||||
*/
|
||||
|
||||
import type {
|
||||
MarkdownTheme,
|
||||
SelectListTheme,
|
||||
TerminalColorScheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
|
||||
/** Theme-agnostic role colors and SGR attribute wrappers. */
|
||||
export interface Palette {
|
||||
accent: (text: string) => string
|
||||
accent2: (text: string) => string
|
||||
text: (text: string) => string
|
||||
muted: (text: string) => string
|
||||
dim: (text: string) => string
|
||||
success: (text: string) => string
|
||||
warning: (text: string) => string
|
||||
error: (text: string) => string
|
||||
code: (text: string) => string
|
||||
added: (text: string) => string
|
||||
removed: (text: string) => string
|
||||
bold: (text: string) => string
|
||||
italic: (text: string) => string
|
||||
underline: (text: string) => string
|
||||
strike: (text: string) => string
|
||||
/** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
|
||||
selected: (text: string) => string
|
||||
}
|
||||
|
||||
function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
|
||||
return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
|
||||
* attributes, which every terminal remaps to its active color scheme. Body
|
||||
* `text` stays the terminal's default foreground so it reads on light and dark
|
||||
* backgrounds alike; grouping uses foreground-only bold, underlined role
|
||||
* headers and reverse video rather than fixed background fills or per-line
|
||||
* prefixes, so a transcript drag-select copies message text without stray
|
||||
* glyphs.
|
||||
*
|
||||
* @param enabled - Whether ANSI is emitted at all.
|
||||
* @param scheme - Active terminal color scheme; adjusts dim and code roles.
|
||||
* @returns The role palette for the given scheme.
|
||||
*/
|
||||
export function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
|
||||
return {
|
||||
accent: ansi('94', '39', enabled),
|
||||
accent2: ansi('95', '39', enabled),
|
||||
text: text => text,
|
||||
muted: ansi('90', '39', enabled),
|
||||
// SGR 2 (dim) lightens text on a light background — substitute ANSI 90
|
||||
// (bright black / gray) which renders as a readable muted tone on any scheme.
|
||||
dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
|
||||
success: ansi('32', '39', enabled),
|
||||
warning: ansi('33', '39', enabled),
|
||||
error: ansi('31', '39', enabled),
|
||||
// ANSI 36 (cyan) is difficult to read on a light background — use
|
||||
// ANSI 34 (blue) which is legible on both light and dark schemes.
|
||||
code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
|
||||
added: ansi('32', '39', enabled),
|
||||
removed: ansi('31', '39', enabled),
|
||||
bold: ansi('1', '22', enabled),
|
||||
italic: ansi('3', '23', enabled),
|
||||
underline: ansi('4', '24', enabled),
|
||||
strike: ansi('9', '29', enabled),
|
||||
selected: ansi('7', '27', enabled),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek brand gradient stops (indigo → light blue) taken from the
|
||||
* deepseek.com logo, painted across the startup banner's product name on
|
||||
* truecolor terminals. Fixed brand identity, deliberately outside the
|
||||
* theme-adaptive {@link Palette}.
|
||||
*/
|
||||
const BRAND_GRADIENT = [
|
||||
[77, 107, 254], // #4D6BFE
|
||||
[57, 130, 255], // #3982FF
|
||||
[36, 152, 255], // #2498FF
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
|
||||
* interpolation across its stops.
|
||||
*
|
||||
* @param t - Position along the gradient; clamped to [0, 1].
|
||||
* @returns The interpolated `[r, g, b]` channels, each rounded to 0–255.
|
||||
*/
|
||||
function brandColorAt(t: number): readonly [number, number, number] {
|
||||
const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1)
|
||||
const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2)
|
||||
const local = span - index
|
||||
// `index` is clamped to a valid adjacent pair, so both lookups are in-bounds.
|
||||
const from = BRAND_GRADIENT[index] as readonly [number, number, number]
|
||||
const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number]
|
||||
return [
|
||||
Math.round(from[0] + (to[0] - from[0]) * local),
|
||||
Math.round(from[1] + (to[1] - from[1]) * local),
|
||||
Math.round(from[2] + (to[2] - from[2]) * local),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint `text` left-to-right in the DeepSeek brand gradient with per-character
|
||||
* 24-bit foreground codes, resetting to the default foreground at the end.
|
||||
* Foreground-only, so it stays legible on any terminal background; the caller
|
||||
* gates it on truecolor support and wraps it in bold.
|
||||
*
|
||||
* @param text - Text to colorize; sampled once per character.
|
||||
* @returns `text` wrapped in truecolor SGR foreground codes.
|
||||
*/
|
||||
export function gradientText(text: string): string {
|
||||
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
|
||||
// samples exactly one color per visible letter.
|
||||
const last = Math.max(1, text.length - 1)
|
||||
let painted = ''
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const [r, g, b] = brandColorAt(index / last)
|
||||
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
|
||||
}
|
||||
return `${painted}\x1b[39m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the pi-tui Markdown theme from a role palette.
|
||||
* @param palette - Active role palette.
|
||||
* @returns The Markdown theme wired to palette roles.
|
||||
*/
|
||||
export function markdownTheme(palette: Palette): MarkdownTheme {
|
||||
return {
|
||||
heading: text => palette.accent(text),
|
||||
link: text => palette.accent(text),
|
||||
// pi-tui requires this URL slot but its current Markdown renderer does not invoke it.
|
||||
/* v8 ignore next */
|
||||
linkUrl: text => palette.dim(text),
|
||||
code: text => palette.code(text),
|
||||
codeBlock: text => palette.code(text),
|
||||
// pi-tui presents both fence rows through this callback. Keep the opening
|
||||
// language label, but hide Markdown syntax and the otherwise-empty close.
|
||||
codeBlockBorder: text => palette.dim(text.slice(3)),
|
||||
quote: text => palette.muted(text),
|
||||
quoteBorder: text => palette.accent2(text),
|
||||
hr: text => palette.dim(text),
|
||||
listBullet: text => palette.accent(text),
|
||||
bold: text => palette.bold(text),
|
||||
italic: text => palette.italic(text),
|
||||
strikethrough: text => palette.strike(text),
|
||||
underline: text => palette.underline(text),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the pi-tui select-list theme from a role palette.
|
||||
* @param palette - Active role palette.
|
||||
* @returns The select-list theme wired to palette roles.
|
||||
*/
|
||||
export function selectTheme(palette: Palette): SelectListTheme {
|
||||
return {
|
||||
selectedPrefix: palette.accent,
|
||||
selectedText: palette.accent,
|
||||
description: palette.muted,
|
||||
scrollInfo: palette.dim,
|
||||
noMatch: palette.warning,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the reverse-video dialog select-list theme from a role palette.
|
||||
* @param palette - Active role palette.
|
||||
* @returns The dialog select-list theme with a reverse-video selection.
|
||||
*/
|
||||
export function dialogSelectTheme(palette: Palette): SelectListTheme {
|
||||
return {
|
||||
...selectTheme(palette),
|
||||
selectedText: text => palette.selected(palette.accent(text)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* pi-tui transcript components: the startup banner, user/assistant messages,
|
||||
* per-step timing footer, streaming assistant buffer, tool cards, and the todo
|
||||
* panel. Each is a pure function of its inputs and the active palette.
|
||||
* @module @deepseek-ai/dsh-tui/components/transcript
|
||||
*/
|
||||
|
||||
import {
|
||||
Container,
|
||||
Markdown,
|
||||
Spacer,
|
||||
Text,
|
||||
truncateToWidth,
|
||||
wrapTextWithAnsi,
|
||||
type Component,
|
||||
type MarkdownTheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
TerminalCallView,
|
||||
ToolCallView,
|
||||
ToolDefinition,
|
||||
ToolResultView,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import type { FileDiff } from '@deepseek-ai/dsh-tools'
|
||||
import { renderUnknownXml } from './xml-tool-output.ts'
|
||||
import { displayInlineText, displayText } from './text.ts'
|
||||
import { gradientText, type Palette } from './theme.ts'
|
||||
import { contentText, type ParsedArguments } from './content.ts'
|
||||
import {
|
||||
formatCompletionTime,
|
||||
formatTimingTotals,
|
||||
stepTimingAt,
|
||||
type StepPosition,
|
||||
} from '../chat/timing.ts'
|
||||
|
||||
/** Concatenate the text of every block of one type, separated by blank lines. */
|
||||
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
|
||||
return content
|
||||
.filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
|
||||
.map(block => block.text)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/** Render a value as terminal-safe text: strings escaped, other values as pretty JSON. */
|
||||
function pretty(value: unknown): string {
|
||||
if (typeof value === 'string') return displayText(value)
|
||||
// JSON.stringify is typed to return string but yields undefined for e.g. symbols.
|
||||
const serialized = JSON.stringify(value, null, 2) as string | undefined
|
||||
return displayText(serialized ?? String(value))
|
||||
}
|
||||
|
||||
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
|
||||
function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
// The card header is a fixed `Tool / <name>` frame that never names a file, so
|
||||
// each hunk always carries its own path header (no redundancy to suppress).
|
||||
const lines = [palette.bold(displayText(diff.path))]
|
||||
if (diff.oldText !== null) {
|
||||
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`))
|
||||
}
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* A message's bold, underlined role header in the role color. The underline
|
||||
* bands each role without a background fill or per-line prefix, so it reads on
|
||||
* any theme and a body drag-select copies the message text verbatim.
|
||||
*/
|
||||
function messageHeader(label: string, color: (text: string) => string, palette: Palette): string {
|
||||
return palette.bold(palette.underline(color(displayText(label))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Borderless startup banner: product title, an optional configured subtitle,
|
||||
* and the session id. No box frame — each line renders as plain left-padded
|
||||
* text (matching transcript notices) so it reads on any theme.
|
||||
*/
|
||||
export class HeaderComponent implements Component {
|
||||
/** Columns of the banner currently revealed; `undefined` renders it whole. */
|
||||
private revealWidth: number | undefined
|
||||
|
||||
constructor(
|
||||
private readonly agent: Agent,
|
||||
private readonly subtitle: () => string | undefined,
|
||||
private readonly palette: Palette,
|
||||
private readonly gradient: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Clip the banner to `width` columns (the sweep reveal); `undefined` restores it.
|
||||
* @param width - Revealed banner width in columns, or `undefined` for the whole banner.
|
||||
*/
|
||||
setRevealWidth(width: number | undefined): void {
|
||||
this.revealWidth = width
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const usable = Math.max(1, width - 2)
|
||||
const name = this.gradient
|
||||
? this.palette.bold(gradientText('DEEPSEEK'))
|
||||
: this.palette.bold(this.palette.accent('DEEPSEEK'))
|
||||
const title = `${name} ${this.palette.bold('HARNESS')}`
|
||||
const detail = displayText(this.agent.session.id)
|
||||
const subtitle = this.subtitle()
|
||||
const lines = [
|
||||
title,
|
||||
...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
|
||||
this.palette.dim(detail),
|
||||
]
|
||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||
.map(line => ` ${truncateToWidth(line, usable, '')}`)
|
||||
if (this.revealWidth === undefined) return lines
|
||||
const revealed = this.revealWidth
|
||||
return lines.map(line => truncateToWidth(line, revealed, ''))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A user or steering prompt in the transcript. An underlined accent role header
|
||||
* plus blank-line spacing separate it from surrounding blocks; body lines carry
|
||||
* no prefix or indent, so a terminal drag-select copies the prompt verbatim.
|
||||
*/
|
||||
export class UserMessageComponent extends Container {
|
||||
constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
|
||||
super()
|
||||
this.addChild(new Text(messageHeader(label, palette.accent, palette), 0, 0))
|
||||
this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, {
|
||||
preserveOrderedListMarkers: true,
|
||||
preserveBackslashEscapes: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Children of a settled assistant message: optional reasoning block then the response text. */
|
||||
function assistantMessageChildren(
|
||||
content: readonly ContentBlock[],
|
||||
showReasoning: boolean,
|
||||
palette: Palette,
|
||||
mdTheme: MarkdownTheme,
|
||||
): Component[] {
|
||||
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
|
||||
const text = displayText(content
|
||||
.flatMap(block => block.type === 'text'
|
||||
? [block.text]
|
||||
: block.type === 'image'
|
||||
? [`[image attachment ${block.attachment.attachmentId}]`]
|
||||
: [])
|
||||
.join('\n\n')
|
||||
.trim())
|
||||
const children: Component[] = [
|
||||
new Spacer(1),
|
||||
new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0),
|
||||
]
|
||||
if (reasoning && showReasoning) {
|
||||
children.push(
|
||||
new Text(palette.italic(palette.muted('Reasoning')), 0, 0),
|
||||
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.muted(value), italic: true }),
|
||||
)
|
||||
}
|
||||
if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }))
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* A step's timing summary, rendered as a self-refreshing footer that stays at
|
||||
* the tail of the step's output. Kept separate from the assistant message so
|
||||
* the timing line trails any tool cards the step appends after its message.
|
||||
*/
|
||||
class StepTimingComponent extends Container {
|
||||
private completionTime: number | undefined
|
||||
|
||||
constructor(
|
||||
private readonly position: StepPosition,
|
||||
private readonly events: () => readonly SessionEvent[],
|
||||
private readonly now: () => number,
|
||||
private readonly palette: Palette,
|
||||
) {
|
||||
super()
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
complete(time: number): void {
|
||||
this.completionTime = time
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
this.rebuild()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now())
|
||||
const timing = formatTimingTotals(totals, true)
|
||||
const header = this.completionTime === undefined
|
||||
? timing
|
||||
: `${timing} · Completed ${formatCompletionTime(this.completionTime)}`
|
||||
this.addChild(new Text(this.palette.dim(header), 0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamingBlock {
|
||||
type: string
|
||||
text: string
|
||||
block?: ContentBlock
|
||||
}
|
||||
|
||||
/** A live assistant step: streamed reasoning/text blocks until the message settles. */
|
||||
export class StreamingAssistantComponent extends Container {
|
||||
private readonly blocks = new Map<number, StreamingBlock>()
|
||||
private settledContent: readonly ContentBlock[] | undefined
|
||||
/**
|
||||
* The step's timing footer. The renderer keeps it at the tail of the chat so
|
||||
* it trails any tool cards the step appends after this assistant message; it
|
||||
* is not a child of this component.
|
||||
*/
|
||||
readonly timing: StepTimingComponent
|
||||
|
||||
constructor(
|
||||
position: StepPosition,
|
||||
events: () => readonly SessionEvent[],
|
||||
now: () => number,
|
||||
private showReasoning: boolean,
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
super()
|
||||
this.timing = new StepTimingComponent(position, events, now, palette)
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the streamed blocks with the step's settled content.
|
||||
* @param content - The settled assistant content blocks.
|
||||
*/
|
||||
settle(content: readonly ContentBlock[]): void {
|
||||
this.settledContent = content
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this step's assistant message has settled.
|
||||
* @returns `true` once {@link settle} has run.
|
||||
*/
|
||||
isSettled(): boolean {
|
||||
return this.settledContent !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the step's timing footer to its completion time.
|
||||
* @param time - Step completion time in epoch milliseconds.
|
||||
*/
|
||||
complete(time: number): void {
|
||||
this.timing.complete(time)
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
this.rebuild()
|
||||
this.timing.invalidate()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one streamed chunk into the live block buffer and re-render.
|
||||
* @param chunk - The streamed assistant chunk.
|
||||
*/
|
||||
update(chunk: StreamChunk): void {
|
||||
if (chunk.type === 'block-start') {
|
||||
this.blocks.set(chunk.index, { type: chunk.blockType, text: '' })
|
||||
} else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
|
||||
const type = chunk.type === 'text-delta' ? 'text' : 'reasoning'
|
||||
const block = this.blocks.get(chunk.index) ?? { type, text: '' }
|
||||
block.text += chunk.text
|
||||
this.blocks.set(chunk.index, block)
|
||||
} else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) {
|
||||
this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text })
|
||||
} else if (chunk.type === 'block-end' && chunk.block.type === 'image') {
|
||||
this.blocks.set(chunk.index, { type: 'image', text: '', block: chunk.block })
|
||||
}
|
||||
this.rebuild()
|
||||
this.timing.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle whether reasoning blocks render, then re-render.
|
||||
* @param show - Whether to show reasoning blocks.
|
||||
*/
|
||||
setShowReasoning(show: boolean): void {
|
||||
this.showReasoning = show
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.flatMap<ContentBlock>(([, block]) => {
|
||||
if (block.type === 'text') return [{ type: 'text', text: block.text }]
|
||||
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
|
||||
if (block.type === 'image' && block.block?.type === 'image') return [block.block]
|
||||
return []
|
||||
})
|
||||
for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) {
|
||||
this.addChild(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tool call and its result, rendered as a collapsible status card. */
|
||||
export class ToolCardComponent implements Component {
|
||||
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
|
||||
private expanded = false
|
||||
private callView: ToolCallView
|
||||
private resultView: ToolResultView | undefined
|
||||
|
||||
constructor(
|
||||
private readonly name: string,
|
||||
private readonly parsed: ParsedArguments,
|
||||
private readonly definition: ToolDefinition | undefined,
|
||||
private readonly maxOutputLines: number,
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
this.callView = this.presentCall()
|
||||
}
|
||||
|
||||
private presentCall(): ToolCallView {
|
||||
if (this.parsed.valid && this.definition?.presentCall) {
|
||||
try {
|
||||
const view = this.definition.presentCall(this.parsed.value)
|
||||
if (view !== undefined) return view
|
||||
} catch (error: unknown) {
|
||||
return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` }
|
||||
}
|
||||
}
|
||||
return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the tool result and derive its result view.
|
||||
* @param event - The `tool/result` event payload.
|
||||
*/
|
||||
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
|
||||
this.result = {
|
||||
content: [...event.content],
|
||||
isError: event.isError,
|
||||
...event.meta !== undefined ? { meta: event.meta } : {},
|
||||
}
|
||||
if (this.parsed.valid && this.definition?.presentResult) {
|
||||
try {
|
||||
const view = this.definition.presentResult(this.parsed.value, this.result)
|
||||
if (view !== undefined) this.resultView = view
|
||||
} catch (error: unknown) {
|
||||
this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand or collapse the card's body preview.
|
||||
* @param expanded - Whether the full body is shown.
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const isError = this.result?.isError ?? false
|
||||
// A ring marker: hollow while the call is pending, filled once it settles;
|
||||
// the header color (warning/success/error) tells pending from ok from error.
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined
|
||||
const unknownXml = this.definition === undefined && genericContent !== undefined
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(genericContent)),
|
||||
this.maxOutputLines,
|
||||
this.expanded,
|
||||
displayText,
|
||||
text => this.palette.muted(text),
|
||||
/* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */
|
||||
count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`),
|
||||
)
|
||||
: undefined
|
||||
const body = unknownXml ?? (genericContent !== undefined && rawBody.length > 0
|
||||
? new Markdown(rawBody.join('\n'), 0, 0, this.mdTheme, { color: value => this.palette.text(value) }).render(width)
|
||||
: rawBody)
|
||||
const headLines = Math.ceil(this.maxOutputLines / 2)
|
||||
const tailLines = this.maxOutputLines - headLines
|
||||
const visibleBody = unknownXml !== undefined || this.expanded || body.length <= this.maxOutputLines
|
||||
? body
|
||||
: [
|
||||
...body.slice(0, headLines),
|
||||
this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
|
||||
...body.slice(body.length - tailLines),
|
||||
]
|
||||
// The header is a fixed `Tool / <name>` frame in the status color (warning
|
||||
// pending / success ok / error), flat — no bold or underline, so one color
|
||||
// reads consistently across the whole row. Every tool-specific detail (a
|
||||
// read's path, a diff, command output) lives in the body below; the sole
|
||||
// header extra is a bash card's model-authored description, appended as a
|
||||
// `/ <desc>` segment. The body stays unprefixed so a drag-select copies only
|
||||
// the tool text; body lines pass through Text so overlong output wraps.
|
||||
const statusColor = this.result === undefined
|
||||
? this.palette.warning
|
||||
: isError ? this.palette.error : this.palette.success
|
||||
// The header is a single card row: collapse an embedded newline in the
|
||||
// description to an inline escape so it cannot break onto extra rows and
|
||||
// collide with the body lines that follow.
|
||||
const desc = this.headerDescription()
|
||||
const headerText = `${glyph} Tool / ${displayText(this.name)}${desc === undefined ? '' : ` / ${displayInlineText(desc)}`}`
|
||||
const header = truncateToWidth(headerText, Math.max(1, width - 2), '')
|
||||
const lines = [statusColor(header)]
|
||||
if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width))
|
||||
return lines
|
||||
}
|
||||
|
||||
/** The pending terminal call view, when this row is a terminal card. */
|
||||
private terminalPending(): TerminalCallView | undefined {
|
||||
return this.callView.card === 'terminal' ? this.callView : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional header `/ <desc>` segment: a bash (terminal) card's
|
||||
* model-authored description. Non-terminal tools contribute no header detail —
|
||||
* their presenter title moves into the body instead.
|
||||
*/
|
||||
private headerDescription(): string | undefined {
|
||||
const description = this.terminalPending()?.description
|
||||
return description !== undefined && description !== '' ? description : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The presenter's title for a non-terminal card, shown as the first body line
|
||||
* (a read's `Read src/foo.ts`, a diff's `Edit files`) now that the header is a
|
||||
* fixed `Tool / <name>` frame. The result-state title replaces the pending one.
|
||||
*/
|
||||
private bodyTitle(): string {
|
||||
return this.resultView?.title ?? this.callView.title
|
||||
}
|
||||
|
||||
private renderBody(): string[] {
|
||||
const view = this.resultView ?? this.callView
|
||||
if (view.card === 'terminal') {
|
||||
const pending = this.terminalPending()
|
||||
const lines: string[] = []
|
||||
// The command shows as a $-line here whenever it is not the header: either a
|
||||
// description headlines the row (the command still belongs somewhere) or the row
|
||||
// is a pending undescribed call (the classic running-command echo). A completed
|
||||
// undescribed row keeps the command only in the header.
|
||||
// The command and cwd are each a single card row, so escape a multi-line
|
||||
// command inline (displayInlineText) — a real newline would break onto extra
|
||||
// rows and collide with the output below.
|
||||
const headlined = pending?.description !== undefined && pending.description !== ''
|
||||
const commandInBody = pending !== undefined && (headlined || this.result === undefined)
|
||||
if (commandInBody) lines.push(this.palette.code(`$ ${displayInlineText(pending.title)}`))
|
||||
if (pending?.cwd) lines.push(this.palette.dim(displayInlineText(pending.cwd)))
|
||||
if (this.resultView?.card === 'terminal') {
|
||||
if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n'))
|
||||
if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
|
||||
if (this.resultView.signal !== undefined) {
|
||||
lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`))
|
||||
}
|
||||
} else if (this.result !== undefined) {
|
||||
lines.push(...displayText(contentText(this.result.content)).split('\n'))
|
||||
}
|
||||
return lines.filter(Boolean)
|
||||
}
|
||||
if (view.card === 'diff') {
|
||||
// The header no longer names the file, so each diff keeps its own path
|
||||
// header. A trailing footer summarizes the change (`+A -R · N file(s)`).
|
||||
let added = 0
|
||||
let removed = 0
|
||||
const hunks = view.diffs.flatMap((diff, index) => {
|
||||
if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length
|
||||
added += displayText(diff.newText).split('\n').length
|
||||
return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)]
|
||||
})
|
||||
const files = view.diffs.length
|
||||
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
|
||||
return [...hunks, footer]
|
||||
}
|
||||
const content = view.content ?? this.result?.content
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
// `Tool / <name>` frame (a terminal card keeps its command $-line instead).
|
||||
// Skip it when it only repeats the tool name (the fallback presenter for a
|
||||
// tool with no presentCall, or an unknown tool), which the header already shows.
|
||||
const bodyTitle = this.bodyTitle()
|
||||
if (bodyTitle !== displayText(this.name)) lines.push(displayInlineText(bodyTitle))
|
||||
if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n'))
|
||||
const rawInput = this.result === undefined && this.callView.card === 'generic'
|
||||
? this.callView.rawInput
|
||||
: undefined
|
||||
if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n'))
|
||||
return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1))
|
||||
}
|
||||
}
|
||||
|
||||
/** The plan/todo panel rendered above the prompt. */
|
||||
export class TodoComponent implements Component {
|
||||
private todos: readonly TodoItem[] = []
|
||||
|
||||
constructor(private readonly palette: Palette) {}
|
||||
|
||||
/**
|
||||
* Replace the rendered plan items.
|
||||
* @param todos - The current todo items.
|
||||
*/
|
||||
update(todos: readonly TodoItem[]): void {
|
||||
this.todos = todos
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.todos.length === 0) return []
|
||||
const lines = [this.palette.bold(this.palette.accent('Plan'))]
|
||||
for (const todo of this.todos) {
|
||||
const prefix = todo.status === 'completed'
|
||||
? this.palette.success('✓')
|
||||
: todo.status === 'in_progress'
|
||||
? this.palette.warning('●')
|
||||
: this.palette.dim('○')
|
||||
const content = displayText(todo.content)
|
||||
const text = todo.status === 'completed' ? this.palette.muted(content) : content
|
||||
lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
|
||||
}
|
||||
return ['', ...lines]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Conservative readable-tree rendering for model-facing text containing one XML
|
||||
* document, used by the transcript's tool and context cards.
|
||||
* @module @deepseek-ai/dsh-tui/components/xml-tool-output
|
||||
*/
|
||||
|
||||
import { SaxesParser } from 'saxes'
|
||||
|
||||
interface XmlElement {
|
||||
readonly name: string
|
||||
readonly attributes: readonly XmlAttribute[]
|
||||
readonly children: XmlNode[]
|
||||
}
|
||||
|
||||
interface XmlAttribute {
|
||||
readonly name: string
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
type XmlNode = XmlElement | string
|
||||
|
||||
function parseXml(source: string, display: (text: string) => string): XmlElement | undefined {
|
||||
const parser = new SaxesParser({ xmlns: false })
|
||||
const stack: XmlElement[] = []
|
||||
let root: XmlElement | undefined
|
||||
const state = { invalid: false }
|
||||
const reject = (): void => { state.invalid = true }
|
||||
parser.on('opentag', (tag) => {
|
||||
const element: XmlElement = {
|
||||
name: tag.name,
|
||||
// Attribute values and text pass through `display` because character references can
|
||||
// expand to valid-XML control characters (tab, CR, DEL, C1) that pre-parse escaping
|
||||
// of the raw source never saw. Element names cannot carry them: control characters
|
||||
// are not XML name characters and character references do not apply inside names.
|
||||
attributes: Object.entries(tag.attributes).map(([name, value]) => ({ name, value: display(value) })),
|
||||
children: [],
|
||||
}
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) {
|
||||
if (root !== undefined) reject()
|
||||
root = element
|
||||
} else {
|
||||
parent.children.push(element)
|
||||
}
|
||||
stack.push(element)
|
||||
})
|
||||
parser.on('text', (text) => {
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) {
|
||||
if (text.trim() !== '') reject()
|
||||
} else {
|
||||
parent.children.push(display(text))
|
||||
}
|
||||
})
|
||||
parser.on('cdata', (text) => {
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) reject()
|
||||
else parent.children.push(display(text))
|
||||
})
|
||||
parser.on('closetag', () => { stack.pop() })
|
||||
parser.on('xmldecl', reject)
|
||||
parser.on('processinginstruction', reject)
|
||||
parser.on('doctype', reject)
|
||||
parser.on('comment', reject)
|
||||
parser.on('error', reject)
|
||||
parser.write(source).close()
|
||||
return state.invalid ? undefined : root
|
||||
}
|
||||
|
||||
function elementLabel(element: XmlElement): string {
|
||||
const attributes = element.attributes.map(attribute => `${attribute.name}=${JSON.stringify(attribute.value)}`).join(' ')
|
||||
return attributes === '' ? element.name : `${element.name} (${attributes})`
|
||||
}
|
||||
|
||||
function meaningfulChildren(element: XmlElement): readonly XmlNode[] {
|
||||
return element.children.filter(child => typeof child !== 'string' || child.trim() !== '')
|
||||
}
|
||||
|
||||
function textBlock(text: string, depth: number): string[] {
|
||||
return text.replace(/^\n|\n$/gu, '').split('\n').map(line => `${' '.repeat(depth)}${line}`)
|
||||
}
|
||||
|
||||
function treeLines(element: XmlElement, depth: number, label: (text: string) => string): string[] {
|
||||
const indent = ' '.repeat(depth)
|
||||
const children = meaningfulChildren(element)
|
||||
if (children.length === 0) return [`${indent}${label(elementLabel(element))}`]
|
||||
if (children.length === 1 && typeof children[0] === 'string' && !children[0].includes('\n')) {
|
||||
return [`${indent}${label(`${elementLabel(element)}:`)} ${children[0].trim()}`]
|
||||
}
|
||||
const lines = [`${indent}${label(elementLabel(element))}`]
|
||||
for (const child of children) {
|
||||
if (typeof child === 'string') lines.push(...textBlock(child, depth + 1))
|
||||
else lines.push(...treeLines(child, depth + 1, label))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] {
|
||||
if (lines.length <= limit) return [...lines]
|
||||
const head = Math.ceil(limit / 2)
|
||||
const tail = limit - head
|
||||
return [...lines.slice(0, head), omitted(lines.length - limit), ...lines.slice(lines.length - tail)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a complete XML document as an indented tree, or decline without changing partial/mixed text.
|
||||
* @param source - Raw model-facing text from a context message or unknown tool result.
|
||||
* @param maxChildLines - Collapsed budget independently applied to each top-level child's lines and
|
||||
* to the number of top-level children, so many siblings cannot grow the collapsed card without bound.
|
||||
* @param expanded - Whether to retain every rendered child line.
|
||||
* @param display - Escapes parsed text and attribute values for terminal output; character references
|
||||
* can expand to control characters that pre-parse escaping never saw.
|
||||
* @param label - Styles element names and attributes.
|
||||
* @param omitted - Renders the omitted-line marker for a collapsed child or child range.
|
||||
* @returns Tree rows, or `undefined` when `source` is not one supported complete XML document.
|
||||
*/
|
||||
export function renderUnknownXml(
|
||||
source: string,
|
||||
maxChildLines: number,
|
||||
expanded: boolean,
|
||||
display: (text: string) => string,
|
||||
label: (text: string) => string,
|
||||
omitted: (count: number) => string,
|
||||
): string[] | undefined {
|
||||
const root = parseXml(source, display)
|
||||
if (root === undefined) return undefined
|
||||
const blocks = meaningfulChildren(root).map(child =>
|
||||
typeof child === 'string' ? textBlock(child, 1) : treeLines(child, 1, label))
|
||||
const rootLine = label(elementLabel(root))
|
||||
if (expanded) return [rootLine, ...blocks.flat()]
|
||||
const previewed = blocks.map(block => preview(block, maxChildLines, omitted))
|
||||
if (previewed.length <= maxChildLines) return [rootLine, ...previewed.flat()]
|
||||
const head = Math.ceil(maxChildLines / 2)
|
||||
const tail = maxChildLines - head
|
||||
const hidden = blocks.slice(head, blocks.length - tail).reduce((total, block) => total + block.length, 0)
|
||||
return [
|
||||
rootLine,
|
||||
...previewed.slice(0, head).flat(),
|
||||
omitted(hidden),
|
||||
...previewed.slice(previewed.length - tail).flat(),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Serializable configuration and defaults for the pi-tui terminal mode. Loader
|
||||
* schema validation normally fills defaults; {@link resolveTuiConfig} applies
|
||||
* the same defaults for direct callers that bypass the Loader.
|
||||
* @module @deepseek-ai/dsh-tui/config
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
} from './chat/file-autocomplete.ts'
|
||||
|
||||
/** Theme and prompt-template settings for the pi-tui terminal mode. */
|
||||
export interface TuiThemeConfig {
|
||||
/** Apply the built-in ANSI color palette. */
|
||||
color?: boolean
|
||||
/** Paint the startup banner with the 24-bit DeepSeek brand gradient. */
|
||||
truecolor?: boolean
|
||||
/** Left-aligned template on the row above the editor. */
|
||||
leftPrompt?: string
|
||||
/** Right-aligned template on the row above the editor. */
|
||||
rightPrompt?: string
|
||||
/** Template used as the editor's first-line prefix. */
|
||||
inputPrompt?: string
|
||||
/** Static placeholder shown in an empty editor while the agent is running. */
|
||||
inputPlaceholder?: string
|
||||
}
|
||||
|
||||
/** Interaction and presentation settings for the pi-tui terminal mode. */
|
||||
export interface TuiConfig {
|
||||
/** Render model reasoning blocks. */
|
||||
showReasoning?: boolean
|
||||
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
|
||||
maxToolOutputLines?: number
|
||||
/** Maximum options visible at once in a user-question panel. */
|
||||
maxQuestionOptions?: number
|
||||
/** Maximum models visible at once in the model selector. */
|
||||
maxModelOptions?: number
|
||||
/** Maximum sessions visible at once in the resume selector. */
|
||||
maxResumeOptions?: number
|
||||
/** User-question panel width in terminal columns, clamped to the terminal. */
|
||||
questionDialogWidth?: number
|
||||
/** User-question panel maximum height in terminal rows. */
|
||||
questionDialogMaxHeight?: number
|
||||
/** Model-selector width in terminal columns. */
|
||||
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
|
||||
/** Color and prompt-template settings. */
|
||||
theme?: TuiThemeConfig
|
||||
/** Terminal window title while the UI is mounted; a logged session title prefixes it. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
const showReasoningSchema = z.boolean().default(true)
|
||||
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
|
||||
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
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(76)
|
||||
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 DEFAULT_LEFT_PROMPT = '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}'
|
||||
const DEFAULT_RIGHT_PROMPT = '${timing}'
|
||||
const DEFAULT_INPUT_PROMPT = '${symbol} ${indicator}'
|
||||
const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel'
|
||||
const TuiThemeConfigSchema: z<TuiThemeConfig> = z.object({
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
leftPrompt: z.string().default(DEFAULT_LEFT_PROMPT),
|
||||
rightPrompt: z.string().default(DEFAULT_RIGHT_PROMPT),
|
||||
inputPrompt: z.string().default(DEFAULT_INPUT_PROMPT),
|
||||
inputPlaceholder: z.string().default(DEFAULT_INPUT_PLACEHOLDER),
|
||||
})
|
||||
const titleSchema = z.string().default('DeepSeek Harness')
|
||||
|
||||
const tuiConfigSchemaFields = {
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
maxResumeOptions: maxResumeOptionsSchema,
|
||||
questionDialogWidth: questionDialogWidthSchema,
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
fileSearchMaxResults: fileSearchMaxResultsSchema,
|
||||
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
|
||||
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
|
||||
showHardwareCursor: showHardwareCursorSchema,
|
||||
theme: TuiThemeConfigSchema,
|
||||
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 {
|
||||
/** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */
|
||||
welcome?: string
|
||||
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
|
||||
sessionId?: string
|
||||
/**
|
||||
* Shell command fallback printed on exit or after selecting a session when
|
||||
* the host cannot hand off in place. Every `{session}` becomes the selected
|
||||
* id; the TUI never executes this text. Absent disables only the fallback,
|
||||
* not the interactive selector.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
}
|
||||
|
||||
/** Schemastery schema for the full plugin configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
welcome: z.string(),
|
||||
sessionId: z.string().default('main'),
|
||||
resumeCommand: z.string(),
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
|
||||
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
|
||||
questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
|
||||
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
|
||||
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
|
||||
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
|
||||
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
|
||||
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
|
||||
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
|
||||
showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor,
|
||||
theme: tuiConfigSchemaFields.theme,
|
||||
title: tuiConfigSchemaFields.title,
|
||||
})
|
||||
|
||||
/** Fully defaulted TUI theme settings. */
|
||||
export interface ResolvedTuiThemeConfig {
|
||||
color: boolean
|
||||
truecolor: boolean
|
||||
leftPrompt: string
|
||||
rightPrompt: string
|
||||
inputPrompt: string
|
||||
inputPlaceholder: string
|
||||
}
|
||||
|
||||
/** Fully defaulted TUI presentation settings. */
|
||||
export interface ResolvedTuiConfig {
|
||||
showReasoning: boolean
|
||||
maxToolOutputLines: number
|
||||
maxQuestionOptions: number
|
||||
maxModelOptions: number
|
||||
maxResumeOptions: number
|
||||
questionDialogWidth: number
|
||||
questionDialogMaxHeight: number
|
||||
modelDialogWidth: number
|
||||
modelDialogMaxHeight: number
|
||||
fileSearchMaxResults: number
|
||||
fileSearchMaxEntries: number
|
||||
fileSearchExcludedDirectories: string[]
|
||||
showHardwareCursor: boolean
|
||||
theme: ResolvedTuiThemeConfig
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply direct-call defaults after Loader schema validation has normally run.
|
||||
*
|
||||
* @param config - Deployment-provided terminal presentation settings.
|
||||
* @returns Complete settings consumed by the TUI renderer.
|
||||
*/
|
||||
export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
|
||||
return {
|
||||
showReasoning: config?.showReasoning ?? true,
|
||||
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
|
||||
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
|
||||
maxModelOptions: config?.maxModelOptions ?? 8,
|
||||
maxResumeOptions: config?.maxResumeOptions ?? 8,
|
||||
questionDialogWidth: config?.questionDialogWidth ?? 200,
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 76,
|
||||
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,
|
||||
theme: {
|
||||
color: config?.theme?.color ?? true,
|
||||
truecolor: config?.theme?.truecolor ?? false,
|
||||
leftPrompt: config?.theme?.leftPrompt ?? DEFAULT_LEFT_PROMPT,
|
||||
rightPrompt: config?.theme?.rightPrompt ?? DEFAULT_RIGHT_PROMPT,
|
||||
inputPrompt: config?.theme?.inputPrompt ?? DEFAULT_INPUT_PROMPT,
|
||||
inputPlaceholder: config?.theme?.inputPlaceholder ?? DEFAULT_INPUT_PLACEHOLDER,
|
||||
},
|
||||
title: config?.title ?? 'DeepSeek Harness',
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -3,12 +3,12 @@
|
||||
*
|
||||
* 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
|
||||
* @module @deepseek-ai/dsh-tui/extension/overlay-manager
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { TuiExtensionService } from './index.ts'
|
||||
import type { TuiExtensionService } from '../index.ts'
|
||||
import type {
|
||||
Component,
|
||||
Focusable,
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
TuiOverlayState,
|
||||
TuiTheme,
|
||||
TuiViewport,
|
||||
} from './extension.ts'
|
||||
} from './types.ts'
|
||||
|
||||
/** pi-tui operations retained by the front door instead of exposed to plugins. */
|
||||
export interface TuiOverlayDriver {
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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
|
||||
* @module @deepseek-ai/dsh-tui/extension/types
|
||||
*/
|
||||
|
||||
/** Terminal component shape accepted from a trusted TUI extension. */
|
||||
+576
-2357
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Mutable terminal-prompt value registry consumed by the TUI template renderer.
|
||||
* Values are trusted presentation fragments and may contain ANSI control sequences.
|
||||
* @module @deepseek-ai/dsh-tui/prompt
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export const name = 'tui-prompt'
|
||||
|
||||
const VALUE_NAME = /^[a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)*$/u
|
||||
|
||||
/** Handle owned by one prompt-value registration. */
|
||||
export interface TuiPromptValueHandle {
|
||||
/**
|
||||
* Replace the current fragment and schedule a coalesced change notification
|
||||
* so the owning renderer redraws. Setting the current value again is a no-op.
|
||||
* @param value - Trusted ANSI-capable fragment, or `undefined` while unavailable.
|
||||
*/
|
||||
set(value: string | undefined): void
|
||||
|
||||
/** Unregister this value; subsequent {@link TuiPromptValueHandle.set} calls fail. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
interface RegisteredValue {
|
||||
value: string | undefined
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
tuiPrompt: TuiPromptService
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a change subscription registered with {@link TuiPromptService.subscribe}. */
|
||||
export type TuiPromptUnsubscribe = () => void
|
||||
|
||||
/** One literal or variable token in a parsed TUI prompt template. */
|
||||
export type TuiPromptTemplateToken =
|
||||
| { readonly kind: 'literal'; readonly value: string }
|
||||
| { readonly kind: 'value'; readonly name: string }
|
||||
|
||||
/**
|
||||
* Parse a prompt template into immutable literal and value tokens.
|
||||
* @param template - Text containing `${name}` references.
|
||||
* @returns Tokens consumed by {@link renderTuiPromptTemplate}.
|
||||
*/
|
||||
export function parseTuiPromptTemplate(template: string): readonly TuiPromptTemplateToken[] {
|
||||
const tokens: TuiPromptTemplateToken[] = []
|
||||
const pattern = /\$\{([^}]*)\}/gu
|
||||
let offset = 0
|
||||
for (const match of template.matchAll(pattern)) {
|
||||
const index = match.index
|
||||
const name = match[1]
|
||||
/* v8 ignore next -- the sole capture always exists when this pattern matches. */
|
||||
if (name === undefined) continue
|
||||
if (index > offset) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset, index) }))
|
||||
tokens.push(Object.freeze({ kind: 'value', name }))
|
||||
offset = index + match[0].length
|
||||
}
|
||||
if (offset < template.length) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset) }))
|
||||
return Object.freeze(tokens)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate one parsed prompt while removing horizontal separators adjacent
|
||||
* only to unavailable values.
|
||||
* @param tokens - Parsed template tokens.
|
||||
* @param resolve - Current value lookup.
|
||||
* @returns ANSI-capable rendered prompt text.
|
||||
*/
|
||||
export function renderTuiPromptTemplate(
|
||||
tokens: readonly TuiPromptTemplateToken[],
|
||||
resolve: (name: string) => string | undefined,
|
||||
): string {
|
||||
const rendered: string[] = []
|
||||
let omitLeadingWhitespace = false
|
||||
for (const token of tokens) {
|
||||
if (token.kind === 'value') {
|
||||
const value = resolve(token.name)
|
||||
if (value === undefined) {
|
||||
omitLeadingWhitespace = true
|
||||
} else {
|
||||
rendered.push(value)
|
||||
omitLeadingWhitespace = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
rendered.push(omitLeadingWhitespace ? token.value.replace(/^[\t ]+/u, '') : token.value)
|
||||
omitLeadingWhitespace = false
|
||||
}
|
||||
return rendered.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Context-global mutable values interpolated by TUI theme prompt templates.
|
||||
* A registration, mutation, or disposal schedules one coalesced notification to
|
||||
* the renderer subscribed with {@link TuiPromptService.subscribe}, so a value
|
||||
* that changes on its own schedule (not only in response to a UI event) still
|
||||
* redraws. Notification is a direct in-service callback, not a Cordis event.
|
||||
*/
|
||||
export class TuiPromptService extends Service {
|
||||
private readonly values = new Map<string, RegisteredValue>()
|
||||
// Per-subscription record identity, not callback identity: two fibers may
|
||||
// subscribe the same function, and disposing one must not remove the other's.
|
||||
private readonly listeners = new Set<{ readonly listener: () => unknown }>()
|
||||
private notificationQueued = false
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'tuiPrompt')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one globally unique template value under the calling Cordis effect.
|
||||
* @param name - Lowercase slash-separated template name.
|
||||
* @param initialValue - Initial trusted ANSI-capable fragment.
|
||||
* @returns A mutable handle whose disposal unregisters the name.
|
||||
*/
|
||||
register(name: string, initialValue?: string): TuiPromptValueHandle {
|
||||
if (!VALUE_NAME.test(name)) {
|
||||
throw new TypeError(`TUI prompt value name "${name}" must match ${String(VALUE_NAME)}`)
|
||||
}
|
||||
if (this.values.has(name)) throw new Error(`TUI prompt value "${name}" is already registered`)
|
||||
|
||||
const registered: RegisteredValue = { value: initialValue }
|
||||
let active = true
|
||||
const effectDisposer = this.ctx.effect(() => {
|
||||
this.values.set(name, registered)
|
||||
this.scheduleChange()
|
||||
// Cordis runs this cleanup at most once per effect, and deleting an
|
||||
// absent key is a no-op, so no re-entrancy guard is needed here; `active`
|
||||
// exists only to reject a late {@link TuiPromptValueHandle.set}.
|
||||
return () => {
|
||||
active = false
|
||||
this.values.delete(name)
|
||||
this.scheduleChange()
|
||||
}
|
||||
}, `tuiPrompt.register(${name})`)
|
||||
|
||||
return Object.freeze({
|
||||
set: (value: string | undefined): void => {
|
||||
if (!active) throw new Error(`TUI prompt value "${name}" is disposed`)
|
||||
if (registered.value === value) return
|
||||
registered.value = value
|
||||
this.scheduleChange()
|
||||
},
|
||||
dispose: (): void => { void effectDisposer() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a registered fragment without evaluating plugin code.
|
||||
* @param name - Exact registered template name.
|
||||
* @returns The current fragment, or `undefined` when unknown or unavailable.
|
||||
*/
|
||||
get(name: string): string | undefined {
|
||||
return this.values.get(name)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe registration and value changes. The listener runs after a coalesced
|
||||
* microtask following any burst of mutations; the renderer re-reads current
|
||||
* values on that callback. The subscription is owned by the calling Cordis
|
||||
* effect, so it is removed when the subscriber's fiber disposes; the returned
|
||||
* disposer removes it early. Listener failures are contained — a synchronous
|
||||
* throw or a rejected returned promise cannot starve the other observers.
|
||||
* @param listener - Invoked once per coalesced change burst. Delivery does
|
||||
* not wait on a returned promise; its rejection is only observed and logged,
|
||||
* never left unhandled, so an async listener cannot order later observers.
|
||||
* @returns A disposer that removes the subscription.
|
||||
*/
|
||||
subscribe(listener: () => unknown): TuiPromptUnsubscribe {
|
||||
const record = { listener }
|
||||
const disposeEffect = this.ctx.effect(() => {
|
||||
this.listeners.add(record)
|
||||
return () => { this.listeners.delete(record) }
|
||||
}, 'tuiPrompt.subscribe')
|
||||
return () => { void disposeEffect() }
|
||||
}
|
||||
|
||||
/** Coalesce mutation bursts into one notification while containing each observer. */
|
||||
private scheduleChange(): void {
|
||||
if (this.notificationQueued) return
|
||||
this.notificationQueued = true
|
||||
queueMicrotask(() => {
|
||||
this.notificationQueued = false
|
||||
// Snapshot so a listener may subscribe/unsubscribe during delivery, but
|
||||
// re-check liveness: a listener that synchronously unsubscribes another
|
||||
// observer earlier in the same burst must silence it now, keeping the
|
||||
// subscription set authoritative during reentrant notification.
|
||||
for (const record of [...this.listeners]) {
|
||||
if (this.listeners.has(record)) this.notifyOne(record.listener)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Deliver one change notification, containing a synchronous throw or a rejected promise. */
|
||||
private notifyOne(listener: () => unknown): void {
|
||||
let returned: unknown
|
||||
try {
|
||||
returned = listener()
|
||||
} catch (error: unknown) {
|
||||
// errorChain never throws, even on a hostile toString/getter, so the
|
||||
// notification microtask can never escape to starve later observers.
|
||||
this.ctx.logger.warn(`tui-prompt change listener threw: ${errorChain(error)}`)
|
||||
return
|
||||
}
|
||||
// A listener may be async; contain a rejected promise the same as a throw.
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`tui-prompt change listener rejected: ${errorChain(error)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default TuiPromptService
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Host and process boundary the interactive TUI runs against: the resume-handoff
|
||||
* host and the {@link TuiRuntime} the shipped CLI supplies (terminal, process
|
||||
* exit, clock, and optional prompt/git overrides). These are plain interfaces so
|
||||
* tests can drive the channel with a fake terminal.
|
||||
* @module @deepseek-ai/dsh-tui/runtime
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */
|
||||
export interface TuiResumeHost {
|
||||
/**
|
||||
* Dispose the current app and replace it with a runtime for `sessionId`.
|
||||
* Success does not return. A host may reject before it commits teardown;
|
||||
* after commit it owns fatal reporting and process exit.
|
||||
* @param sessionId - validated persisted session selected by the user.
|
||||
*/
|
||||
handoff(sessionId: SessionId): Promise<never>
|
||||
}
|
||||
|
||||
/** Runtime boundary used by the interactive TUI. */
|
||||
export interface TuiRuntime {
|
||||
/** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
|
||||
terminal: Terminal
|
||||
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
|
||||
exit(code: number): void
|
||||
/**
|
||||
* Override the prompt's logical working-directory label without changing the session directory used by tools.
|
||||
* @param cwd - Operational working directory from the session header.
|
||||
* @returns Unescaped label; the TUI makes terminal controls visible.
|
||||
*/
|
||||
formatCwd?: (cwd: string | undefined) => string
|
||||
/**
|
||||
* Override the Git branch shown in the prompt context line; production resolves it once at mount.
|
||||
* @param cwd - Operational working directory from the session header.
|
||||
* @returns Unescaped branch name, or `undefined` outside a Git worktree.
|
||||
*/
|
||||
gitBranch?: (cwd: string) => string | undefined
|
||||
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
|
||||
now?(): number
|
||||
/** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */
|
||||
handoffResume?: TuiResumeHost['handoff']
|
||||
}
|
||||
Reference in New Issue
Block a user