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:
Yichen Jiang
2026-07-28 11:41:40 +08:00
1499 files changed
+48621 -21956

No files matched your search

+95
View File
@@ -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)
}
}
+31
View File
@@ -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
}
@@ -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'
+137
View File
@@ -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
+191
View File
@@ -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
},
}
}
+168
View File
@@ -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,
}
}
+245
View File
@@ -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}`
}
+347
View File
@@ -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}`
}
+96
View File
@@ -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`
}
+59
View File
@@ -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 }
}
}
+789
View File
@@ -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)))} `
})
}
}
+49
View File
@@ -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, '')
}
+184
View File
@@ -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 0255.
*/
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(),
]
}
+213
View File
@@ -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,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
View File
File diff suppressed because it is too large. Load diff
+217
View File
@@ -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
+45
View File
@@ -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']
}