Merge remote-tracking branch 'origin/master' into worktree/pr743-merge-20260727

# Conflicts:
#	examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt
#	packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt
This commit is contained in:
Tianyi Cui
2026-07-28 00:03:56 +08:00
184 changed files with 8971 additions and 4357 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 84ed3de78469ab778118ee5599acfd8476b0ecd1
README.zh.md: 771b89a91077db7543713b4ce1b5fce0c30c2a16
README.md: 528daef773635451ceb198ab3231c2dd87cb9413
README.zh.md: ed19023334389e3f64b8a2f3821307f1540876f2

View File

@@ -74,7 +74,7 @@ Startup fails before mounting when either process stream is not a TTY. The compo
## Color
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
## Model Experience

View File

@@ -74,7 +74,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
## 颜色
Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、工具卡片)使用彩色左侧 gutter bar而非填充背景块问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
## 模型体验

View File

@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./prompt": {
"types": "./lib/types/prompt.d.ts",
"default": "./lib/prompt.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/prompt.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -35,9 +40,9 @@
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -59,6 +64,7 @@
},
"dependencies": {
"@earendil-works/pi-tui": "0.80.7",
"saxes": "6.0.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
@@ -71,9 +77,9 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

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)
}
}

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
}

View File

@@ -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'

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

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
},
}
}

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,
}
}

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')
})
},
}
}

View File

@@ -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}`
}

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}`
}

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`
}

View File

@@ -0,0 +1,56 @@
/**
* 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
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 }
}
}

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)))} `
})
}
}

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, '')
}

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)),
}
}

View File

@@ -0,0 +1,529 @@
/**
* 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(textBlocks(content, 'text').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
}
/** 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 })
}
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 }]
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]
}
}

View File

@@ -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(),
]
}

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',
}
}

View File

@@ -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 {

View File

@@ -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. */

File diff suppressed because it is too large Load Diff

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

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']
}

View File

@@ -11,12 +11,12 @@ import type {
TuiOverlayOptions,
TuiOverlaySession,
TuiTheme,
} from '../src/extension.ts'
} from '../src/extension/types.ts'
import {
TuiExtensionServiceImpl,
TuiOverlayManager,
type TuiOverlayDriver,
} from '../src/overlay-manager.ts'
} from '../src/extension/overlay-manager.ts'
const theme: TuiTheme = Object.freeze({
text: (value: string) => `text:${value}`,

View File

@@ -6,7 +6,7 @@ import {
activeAtToken,
formatFileMention,
WorkspaceFileSearch,
} from '../src/file-autocomplete.ts'
} from '../src/chat/file-autocomplete.ts'
const searches: WorkspaceFileSearch[] = []
const roots: string[] = []

View File

@@ -17,10 +17,11 @@ import type {
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessageData } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
import { TestSessionQueryService } from './session-query.ts'
import TuiPromptService from '../src/prompt.ts'
interface FakeAgent extends Agent {
status: AgentStatus
@@ -48,6 +49,7 @@ export interface TuiHarnessOptions {
beforeMount?: (session: Session) => void
cwd?: string | null
formatCwd?: TuiRuntime['formatCwd']
gitBranch?: TuiRuntime['gitBranch']
/** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */
agentOptions?: AgentOptions
contextWindow?: number
@@ -98,6 +100,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(TuiPromptService)
const catalog = options.catalog ?? {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [
@@ -111,12 +114,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
},
} as never)
if (options.configureContext === undefined) {
const tools = options.tools ?? {}
ctx.provide('tools', {
get(name: string) {
return tools[name]
},
} as never)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
for (const tool of Object.values(options.tools ?? {})) ctx.tools.register(tool)
} else {
await options.configureContext(ctx)
}
@@ -238,7 +238,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const controller = createTuiChat(ctx, Object.assign({
...options.omitWelcome === true ? {} : { welcome: 'Coding agent ready.' },
sessionId,
color: false,
theme: { color: false },
}, options.config), {
terminal,
exit,
@@ -248,6 +248,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
...(options.now === undefined ? {} : { now: options.now }),
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
gitBranch: options.gitBranch ?? (() => 'tui-staging'),
})
return { ctx, session, agent, terminal, exit, controller }
}

View File

@@ -21,6 +21,7 @@ describe('dsh-tui plugin export shape', () => {
'llm',
'systemPrompt',
'tokenMeter',
'tuiPrompt',
])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')

View File

@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import TuiPromptService, {
parseTuiPromptTemplate,
renderTuiPromptTemplate,
} from '../src/prompt.ts'
const tick = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
describe('TUI prompt values', () => {
it('registers, updates, and disposes mutable values', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const value = ctx.tuiPrompt.register('git/worktree', '\x1b[32m(main)\x1b[0m')
expect(ctx.tuiPrompt.get('git/worktree')).toBe('\x1b[32m(main)\x1b[0m')
value.set('next')
expect(ctx.tuiPrompt.get('git/worktree')).toBe('next')
value.set(undefined)
expect(ctx.tuiPrompt.get('git/worktree')).toBeUndefined()
value.dispose()
expect(() => { value.set('late') }).toThrow(/disposed/)
await ctx.fiber.dispose()
})
it('coalesces a change burst into one notification and contains each observer', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
// Capture the containment warnings so the rejected-promise and sync-throw
// paths are each pinned (removing either catch drops its warning).
const warnings: string[] = []
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
// A synchronous thrower, an async rejecter, and a thrower whose error is
// hostile to string coercion all sit BEFORE the observed listener, so
// proving `after` still runs proves none of them starves it (a naive
// `String(error)` inside the containment would itself throw on the last).
const hostile = { toString() { throw new Error('hostile coercion') } }
const thrower = vi.fn(() => { throw new Error('sync observer boom') })
const rejecter = vi.fn(async () => { throw new Error('async observer boom') })
const hostileThrower = vi.fn(() => { throw hostile })
const after = vi.fn()
ctx.tuiPrompt.subscribe(thrower)
ctx.tuiPrompt.subscribe(rejecter)
ctx.tuiPrompt.subscribe(hostileThrower)
const unsubscribe = ctx.tuiPrompt.subscribe(after)
await tick() // drain the registration notifications
thrower.mockClear()
rejecter.mockClear()
hostileThrower.mockClear()
after.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
value.set('b') // unchanged: no additional schedule
value.set('c')
await tick()
await tick() // settle the contained rejected promise
// One coalesced callback for the whole burst; a throwing, rejecting, or
// hostile-to-render observer is contained and does not stop later observers.
expect(thrower).toHaveBeenCalledTimes(1)
expect(rejecter).toHaveBeenCalledTimes(1)
expect(hostileThrower).toHaveBeenCalledTimes(1)
expect(after).toHaveBeenCalledTimes(1)
// Each contained failure logged its own warning: the sync throw, the
// rejected promise, and the hostile-to-render throw (via non-throwing
// errorChain). Pinning the rejected-promise warning fails if its `.catch`
// containment is removed.
expect(warnings.some(w => w.includes('threw: sync observer boom'))).toBe(true)
expect(warnings.some(w => w.includes('rejected: async observer boom'))).toBe(true)
expect(warnings.some(w => w.includes('threw: <unrenderable value>'))).toBe(true)
// Unsubscribe stops further notifications for that listener.
unsubscribe()
value.set('d')
await tick()
expect(after).toHaveBeenCalledTimes(1)
await ctx.fiber.dispose()
})
it('removes a subscription when the subscriber fiber disposes', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const observed = vi.fn()
// Subscribe from a child plugin fiber that shares the service, then dispose
// only that fiber; the effect-owned subscription must go with it.
const child = ctx.plugin({
inject: ['tuiPrompt'],
apply: (childCtx) => { childCtx.tuiPrompt.subscribe(observed) },
})
await tick()
observed.mockClear()
await child.dispose()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
expect(observed).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('keeps one fiber\'s subscription when another disposes the same callback', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
// Both fibers subscribe the SAME function reference. Per-subscription record
// identity (not callback identity) keeps them independent, so disposing one
// must not silence the other.
const shared = vi.fn()
const first = ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } })
ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } })
await tick()
await first.dispose()
shared.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
// The second fiber's subscription survives the first's disposal.
expect(shared).toHaveBeenCalledTimes(1)
await ctx.fiber.dispose()
})
it('does not notify a subscription unsubscribed earlier in the same burst', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const victim = vi.fn()
// This listener is delivered first (subscribed first) and synchronously
// unsubscribes the victim during the same notification. The snapshot must
// re-check liveness so the later victim record does not fire this burst.
ctx.tuiPrompt.subscribe(() => { unsubscribeVictim() })
const unsubscribeVictim = ctx.tuiPrompt.subscribe(victim)
await tick()
victim.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
expect(victim).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('rejects invalid and duplicate names', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
expect(() => ctx.tuiPrompt.register('Bad Name')).toThrow(/must match/)
ctx.tuiPrompt.register('status')
expect(() => ctx.tuiPrompt.register('status')).toThrow(/already registered/)
await ctx.fiber.dispose()
})
})
describe('TUI prompt templates', () => {
it('interpolates values and removes separators around unavailable values', () => {
const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}')
const values = new Map([['cwd', '/work'], ['model', 'deepseek']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek')
})
it('keeps a trailing literal after the last value', () => {
const tokens = parseTuiPromptTemplate('${symbol} ${indicator} > ')
const values = new Map([['symbol', 'dsh'], ['indicator', '●']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('dsh ● > ')
})
it('preserves trusted ANSI fragments', () => {
const powerline = '\x1b[44m work \x1b[34;46m\x1b[0m'
expect(renderTuiPromptTemplate(parseTuiPromptTemplate('${powerline}'), () => powerline)).toBe(powerline)
})
})

View File

@@ -1,7 +1,7 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -12,7 +12,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import CommandService from '@deepseek-ai/dsh-commands'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import { createTuiChat } from '../src/index.ts'
import { createTuiChat, TuiPromptService } from '../src/index.ts'
import { HeadlessTerminal } from './headless-terminal.ts'
import { TestSessionQueryService } from './session-query.ts'
@@ -51,6 +51,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise<void> {
describe('TUI session-reference snapshot', () => {
it('snapshots compacted current-surface context on send and displays only its reference card', async () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 30, 0).getTime())
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -59,6 +60,7 @@ describe('TUI session-reference snapshot', () => {
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(TuiPromptService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
@@ -97,7 +99,7 @@ describe('TUI session-reference snapshot', () => {
const controller = createTuiChat(ctx, {
sessionId: target.id,
welcome: 'Session reference snapshot.',
color: true,
theme: { color: true },
title: 'DSH session reference',
}, { terminal, exit: () => {} })
await terminal.waitForFrame(0)
@@ -138,5 +140,6 @@ describe('TUI session-reference snapshot', () => {
await controller.dispose()
await ctx.fiber.dispose()
await terminal.dispose()
clock.mockRestore()
})
})

View File

@@ -1,99 +1,69 @@
terminal 100x40 buffer=normal length=40 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=36 bufferRow=36
cursor hidden column=7 viewportRow=34 bufferRow=34
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=green
5| "▌ ✓ pnpm run test:coverage "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-25 bold
6| "▌ Run the coverage gate "
style 0-0 fg=green
style 2-22 fg=bright-black
7| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
8| "▌ … +4 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
9| "▌ [exit 0] "
style 0-0 fg=green
style 2-9 dim
10| "▌ "
style 0-0 fg=green
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
7| "$ pnpm run test:coverage "
style 0-23 fg=cyan
8| "/workspace/project "
style 0-17 dim
9| "… +4 lines (Ctrl+O to expand) "
style 0-28 dim
10| "[exit 0] "
style 0-7 dim
11| <blank>
12| ""
style 0-0 fg=green
13| "▌ ✓ Edit renderer "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-16 bold
14| "▌ src/view.ts "
style 0-0 fg=green
style 2-12 bold
15| "▌ - old line "
style 0-0 fg=green
style 2-11 fg=red
16| "▌ … +5 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
17| "▌ + expect(screen).toMatchSnapshot() "
style 0-0 fg=green
style 2-35 fg=green
18| " "
style 0-0 fg=green
19| <blank>
20| "▌ "
style 0-0 fg=green
21| "▌ ✓ Delegate renderer audit "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-26 bold
22| "▌ The renderer has explicit lifecycle ownership. "
style 0-0 fg=green
23| "▌ "
style 0-0 fg=green
24| <blank>
25| "▌ "
style 0-0 fg=green
26| "▌ ✓ Read output from background task subagent-7 "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-46 bold
27| "▌ audit complete "
style 0-0 fg=green
28| "▌ [status: completed] "
style 0-0 fg=green
29| "▌ "
style 0-0 fg=green
30| <blank>
31| "▌ "
style 0-0 fg=green
32| "▌ ✓ Load skill dsh-code-review "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-29 bold
33| "▌ Loaded review instructions. "
style 0-0 fg=green
34| "▌ "
style 0-0 fg=green
35| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
36| " "
style 1-1 inverse
37| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
38| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 73-99 dim
39| <blank>
12| "● Tool / edit"
style 0-12 fg=green
13| "src/view.ts "
style 0-10 bold
14| "- old line "
style 0-9 fg=red
15| "… +3 lines (Ctrl+O to expand) "
style 0-28 dim
16| "└ +2 -2 · 1 file "
style 0-15 dim
17| <blank>
18| "● Tool / subagent"
style 0-16 fg=green
19| "Delegate renderer audit "
20| "The renderer has explicit lifecycle ownership. "
21| <blank>
22| "● Tool / task_output"
style 0-19 fg=green
23| "Read output from background task subagent-7 "
24| " "
25| "… +2 lines (Ctrl+O to expand) "
style 0-28 dim
26| " "
27| <blank>
28| "● Tool / skill"
style 0-13 fg=green
29| "Load skill dsh-code-review "
30| "Loaded review instructions. "
31| "Model wait 0.0s "
style 0-14 dim
32| <blank>
33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
34| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
35-39| <blank>

View File

@@ -1,117 +1,79 @@
terminal 100x40 buffer=normal length=48 base=8 viewport=8
terminal 100x40 buffer=normal length=43 base=3 viewport=3
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=37 bufferRow=45
cursor hidden column=7 viewportRow=39 bufferRow=42
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=green
5| "▌ ✓ pnpm run test:coverage "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-25 bold
6| "▌ Run the coverage gate "
style 0-0 fg=green
style 2-22 fg=bright-black
7| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
8| "▌ packages/ui/tui 100% "
style 0-0 fg=green
9| "▌ 4016 tests passed "
style 0-0 fg=green
10| "▌ 1 test skipped "
style 0-0 fg=green
11| "▌ coverage complete "
style 0-0 fg=green
12| "▌ [exit 0] "
style 0-0 fg=green
style 2-9 dim
13| "▌ "
style 0-0 fg=green
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
7| "$ pnpm run test:coverage "
style 0-23 fg=cyan
8| "/workspace/project "
style 0-17 dim
9| "packages/ui/tui 100% "
10| "4016 tests passed "
11| "1 test skipped "
12| "coverage complete "
13| "[exit 0] "
style 0-7 dim
14| <blank>
15| ""
style 0-0 fg=green
16| "▌ ✓ Edit renderer "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-16 bold
17| "▌ src/view.ts "
style 0-0 fg=green
style 2-12 bold
18| "▌ - old line "
style 0-0 fg=green
style 2-11 fg=red
19| "▌ - keep "
style 0-0 fg=green
style 2-7 fg=red
20| "▌ + new line "
style 0-0 fg=green
style 2-11 fg=green
21| "▌ + keep "
style 0-0 fg=green
style 2-7 fg=green
22| "▌ "
style 0-0 fg=green
23| "▌ tests/view.spec.ts "
style 0-0 fg=green
style 2-19 bold
24| "▌ + expect(screen).toMatchSnapshot() "
style 0-0 fg=green
style 2-35 fg=green
25| "▌ "
style 0-0 fg=green
15| "● Tool / edit"
style 0-12 fg=green
16| "src/view.ts "
style 0-10 bold
17| "- old line "
style 0-9 fg=red
18| "- keep "
style 0-5 fg=red
19| "+ new line "
style 0-9 fg=green
20| "+ keep "
style 0-5 fg=green
21| "└ +2 -2 · 1 file "
style 0-15 dim
22| <blank>
23| "● Tool / subagent"
style 0-16 fg=green
24| "Delegate renderer audit "
25| "The renderer has explicit lifecycle ownership. "
26| <blank>
27| ""
style 0-0 fg=green
28| "▌ ✓ Delegate renderer audit "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-26 bold
29| "▌ The renderer has explicit lifecycle ownership. "
style 0-0 fg=green
30| " "
style 0-0 fg=green
31| <blank>
32| "▌ "
style 0-0 fg=green
33| "▌ ✓ Read output from background task subagent-7 "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-46 bold
34| "▌ audit complete "
style 0-0 fg=green
35| "▌ [status: completed] "
style 0-0 fg=green
36| "▌ "
style 0-0 fg=green
37| <blank>
38| "▌ "
style 0-0 fg=green
39| "▌ ✓ Load skill dsh-code-review "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-29 bold
40| "▌ Loaded review instructions. "
style 0-0 fg=green
41| "▌ "
style 0-0 fg=green
42| <blank>
43| " Tool cards expanded. "
style 1-20 fg=bright-black
44| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
45| " "
style 1-1 inverse
46| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
47| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:expanded"
style 0-43 dim
style 74-99 dim
27| "● Tool / task_output"
style 0-19 fg=green
28| "Read output from background task subagent-7 "
29| " "
30| "console "
style 0-6 dim
31| " started background task bash-5 "
style 2-31 fg=cyan
32| " "
33| <blank>
34| "● Tool / skill"
style 0-13 fg=green
35| "Load skill dsh-code-review "
36| "Loaded review instructions. "
37| "Model wait 0.0s "
style 0-14 dim
38| <blank>
39| "Tool cards expanded. "
style 0-19 fg=bright-black
40| <blank>
41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
42| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse

View File

@@ -1,7 +1,7 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=4 bufferRow=4
cursor hidden column=7 viewportRow=8 bufferRow=8
viewport
0| " DEEPSEEK HARNESS"
style 1-1 fg=#4d6bfe bold
@@ -15,15 +15,22 @@ viewport
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
7-35| <blank>
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
8| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
9-35| <blank>

View File

@@ -1,39 +1,37 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=12 bufferRow=12
cursor hidden column=7 viewportRow=15 bufferRow=15
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=yellow
5| "▌ ◌ Echo two markers and combine them "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-36 bold
6| "const first = await tools.bash({ command: 'echo CODE_ONE' }) "
style 0-0 fg=yellow
7| "const second = await tools.bash({ command: 'echo CODE_TWO' }) "
style 0-0 fg=yellow
8| "▌ console.log(first, second) "
style 0-0 fg=yellow
9| "▌ return `${first}+${second}` "
style 0-0 fg=yellow
10| "▌ "
style 0-0 fg=yellow
11| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
12| " "
style 1-1 inverse
13| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
14| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
15-35| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "○ Tool / run_code"
style 0-16 fg=yellow
7| "Echo two markers and combine them "
8| "const first = await tools.bash({ command: 'echo CODE_ONE' }) "
9| "const second = await tools.bash({ command: 'echo CODE_TWO' }) "
10| "console.log(first, second) "
11| "return `${first}+${second}` "
12| "Model wait 0.0s "
style 0-14 dim
13| <blank>
14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
15| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
16-35| <blank>

View File

@@ -1,46 +1,45 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=active
title "DSH snapshot"
cursor hidden column=1 viewportRow=17 bufferRow=17
cursor hidden column=7 viewportRow=18 bufferRow=18
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Show the live update. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Reasoning "
style 1-9 fg=bright-black italic
10| " Inspecting width and styles. "
style 1-28 fg=bright-black italic
11| <blank>
12| " Assistant "
style 1-9 fg=bright-magenta bold
13| " Streaming visible state… "
style 11-23 bold
14| <blank>
15| " ⠋ Responding 0s · total 0s — Enter sends steering, Esc cancels "
style 1-1 fg=bright-blue
style 3-62 fg=bright-black
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 fg=bright-blue
17| " "
style 1-1 inverse
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 fg=bright-blue
19| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
20-35| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Reasoning "
style 0-8 fg=bright-black italic
6| "Inspecting width and styles. "
style 0-27 fg=bright-black italic
7| "Streaming visible state… "
style 10-22 bold
8| " "
9| "ts "
style 0-1 dim
10| " const visible = true "
style 2-21 fg=cyan
11| " "
12| "Model wait 1.0s · Thinking 2.0s "
style 0-30 dim
13| <blank>
14| "You "
style 0-2 fg=bright-blue bold underline
15| "Show the live update. "
16| <blank>
17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
18| " dsh ● press enter to steer and esc to cancel "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-44 dim
19-35| <blank>

View File

@@ -1,49 +1,45 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=16 bufferRow=16
cursor hidden column=7 viewportRow=21 bufferRow=21
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "▌ ◌ Inspect cordis runtime: tools "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-32 bold
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| ""
style 0-0 fg=yellow
7| "▌ ◌ Mount temporary Cordis Plugin "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-32 bold
8| "▌ { "
style 0-0 fg=yellow
9| " \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
style 0-0 fg=yellow
10| "▌ ready: true }) } }\" "
style 0-0 fg=yellow
11| "▌ } "
style 0-0 fg=yellow
12| " "
style 0-0 fg=yellow
13| <blank>
14| "▌ ◌ Unmount temporary Cordis Plugin dyn-1 "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-40 bold
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
19-35| <blank>
6| "○ Tool / cordis_inspect"
style 0-22 fg=yellow
7| "Inspect cordis runtime: tools "
8| <blank>
9| "○ Tool / cordis_mount"
style 0-20 fg=yellow
10| "Mount temporary Cordis Plugin "
11| "{ "
12| " \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready:"
13| "true }) } }\" "
14| "} "
15| <blank>
16| "○ Tool / cordis_unmount"
style 0-22 fg=yellow
17| "Unmount temporary Cordis Plugin dyn-1 "
18| "Model wait 0.0s "
style 0-14 dim
19| <blank>
20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
21| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
22-35| <blank>

View File

@@ -1,63 +1,78 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
terminal 92x32 buffer=normal length=38 base=6 viewport=6
lifecycle started=1 stopped=1 progress=inactive
title "DSH snapshot"
cursor visible column=0 viewportRow=31 bufferRow=31
cursor visible column=0 viewportRow=31 bufferRow=37
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
8| " "
9| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
10| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
11| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
12| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
13| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
14| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
20| <blank>
21| " provider stream failed after partial output "
style 1-43 fg=red
22| <blank>
23| " The previous process ended during this turn. "
style 1-44 fg=yellow
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 "
style 0-46 dim
6| <blank>
7| "Keyboard shortcuts "
style 0-17 fg=bright-blue bold
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 0-60 fg=bright-black
9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 0-74 fg=bright-black
10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 0-72 fg=bright-black
11| " "
12| "/clear — Clear the transcript view (session history is unchanged) "
style 0-64 fg=bright-black
13| "/exit — Exit after the active turn reaches idle "
style 0-46 fg=bright-black
14| "/help — Show keyboard shortcuts and commands "
style 0-43 fg=bright-black
15| "/model [[provider/]model] — Show or switch this session's model "
style 0-62 fg=bright-black
16| "/quit — Exit after the active turn reaches idle "
style 0-46 fg=bright-black
17| "/reasoning — Toggle reasoning blocks "
style 0-35 fg=bright-black
18| "/redraw — Invalidate components and redraw the terminal "
style 0-54 fg=bright-black
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 0-87 fg=bright-black
20| "/resume — List this workspace's resumable sessions "
style 0-49 fg=bright-black
21| "/status — Show session diagnostics, system prompt, and registered tools "
style 0-70 fg=bright-black
22| "/tools — Expand or collapse all tool cards "
style 0-41 fg=bright-black
23| "/skill:<name> [instructions] — load a skill into the conversation "
style 0-64 fg=bright-black
24| <blank>
25| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "
style 1-1 inverse
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
30-31| <blank>
25| "provider stream failed after partial output "
style 0-42 fg=red
26| <blank>
27| "The previous process ended during this turn. "
style 0-43 fg=yellow
28| <blank>
29| "Turn stopped: the agent was disposed. "
style 0-36 fg=yellow
30| <blank>
31| "Turn ended: plugin-policy. "
style 0-25 fg=yellow
32| <blank>
33| "Unknown command: /unknown-advanced-command "
style 0-41 fg=yellow
34| <blank>
35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
36| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
37| <blank>

View File

@@ -1,46 +1,40 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=15 bufferRow=15
cursor hidden column=7 viewportRow=17 bufferRow=17
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=yellow
5| "▌ ◌ workflow: tui-matrix "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-23 bold
6| "phase('Inspect') "
style 0-0 fg=yellow
7| "▌ const reports = await parallel([ "
style 0-0 fg=yellow
8| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
style 0-0 fg=yellow
9| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=yellow
style 2-30 dim
10| "▌ ]) "
style 0-0 fg=yellow
11| "▌ phase('Verify') "
style 0-0 fg=yellow
12| "▌ return { reports, verdict: 'covered' } "
style 0-0 fg=yellow
13| "▌ "
style 0-0 fg=yellow
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| " "
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "○ Tool / workflow"
style 0-16 fg=yellow
7| "workflow: tui-matrix "
8| "phase('Inspect') "
9| "const reports = await parallel([ "
10| "… +2 lines (Ctrl+O to expand) "
style 0-28 dim
11| "]) "
12| "phase('Verify') "
13| "return { reports, verdict: 'covered' } "
14| "Model wait 0.0s "
style 0-14 dim
15| <blank>
16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
17| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
18-35| <blank>

View File

@@ -1,63 +1,77 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
terminal 92x32 buffer=normal length=37 base=5 viewport=5
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=27 bufferRow=27
cursor hidden column=7 viewportRow=31 bufferRow=36
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
8| " "
9| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
10| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
11| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
12| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
13| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
14| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
20| <blank>
21| " provider stream failed after partial output "
style 1-43 fg=red
22| <blank>
23| " The previous process ended during this turn. "
style 1-44 fg=yellow
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 "
style 0-46 dim
6| <blank>
7| "Keyboard shortcuts "
style 0-17 fg=bright-blue bold
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 0-60 fg=bright-black
9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 0-74 fg=bright-black
10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 0-72 fg=bright-black
11| " "
12| "/clear — Clear the transcript view (session history is unchanged) "
style 0-64 fg=bright-black
13| "/exit — Exit after the active turn reaches idle "
style 0-46 fg=bright-black
14| "/help — Show keyboard shortcuts and commands "
style 0-43 fg=bright-black
15| "/model [[provider/]model] — Show or switch this session's model "
style 0-62 fg=bright-black
16| "/quit — Exit after the active turn reaches idle "
style 0-46 fg=bright-black
17| "/reasoning — Toggle reasoning blocks "
style 0-35 fg=bright-black
18| "/redraw — Invalidate components and redraw the terminal "
style 0-54 fg=bright-black
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 0-87 fg=bright-black
20| "/resume — List this workspace's resumable sessions "
style 0-49 fg=bright-black
21| "/status — Show session diagnostics, system prompt, and registered tools "
style 0-70 fg=bright-black
22| "/tools — Expand or collapse all tool cards "
style 0-41 fg=bright-black
23| "/skill:<name> [instructions] — load a skill into the conversation "
style 0-64 fg=bright-black
24| <blank>
25| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "
style 1-1 inverse
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
30-31| <blank>
25| "provider stream failed after partial output "
style 0-42 fg=red
26| <blank>
27| "The previous process ended during this turn. "
style 0-43 fg=yellow
28| <blank>
29| "Turn stopped: the agent was disposed. "
style 0-36 fg=yellow
30| <blank>
31| "Turn ended: plugin-policy. "
style 0-25 fg=yellow
32| <blank>
33| "Unknown command: /unknown-advanced-command "
style 0-41 fg=yellow
34| <blank>
35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
36| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse

View File

@@ -1,24 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=5 viewportRow=4 bufferRow=4
cursor hidden column=11 viewportRow=8 bufferRow=8
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
4| " @tsc "
style 5-5 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
6| " → File · terminal-special-case.t src/terminal-special-case.ts "
style 1-32 fg=bright-blue
7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
8-35| <blank>
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
8| " dsh > @tsc "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 11-11 inverse
9| " → File · terminal-special-case.t src/terminal-special-case.ts "
style 7-38 fg=bright-blue
10-35| <blank>

View File

@@ -1,42 +0,0 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=92 viewportRow=15 bufferRow=15
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
7-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
style 8-83 fg=bright-blue
14| " │ deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ "
style 8-8 fg=bright-blue
style 38-77 fg=bright-black
style 83-83 fg=bright-blue
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
style 8-8 fg=bright-blue
style 10-77 fg=bright-blue inverse
style 83-83 fg=bright-blue
16| " │ │ "
style 8-8 fg=bright-blue
style 83-83 fg=bright-blue
17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ "
style 8-8 fg=bright-blue
style 10-71 dim
style 83-83 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────────╯ "
style 8-83 fg=bright-blue
19-31| <blank>

View File

@@ -8,27 +8,34 @@ buffer
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
7-12| <blank>
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
8| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
9-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────────╮ "
style 8-83 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ "
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
style 8-8 fg=bright-blue
style 10-77 fg=bright-blue inverse
style 10-70 fg=bright-blue inverse
style 83-83 fg=bright-blue
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ "
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
style 8-8 fg=bright-blue
style 36-77 fg=bright-black
style 36-58 fg=bright-black
style 83-83 fg=bright-blue
16| " │ │ "
style 8-8 fg=bright-blue

View File

@@ -1,27 +1,32 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=7 bufferRow=7
cursor hidden column=7 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: provider default. New steps "
style 1-91 fg=bright-black
5| " will use it. "
style 1-12 fg=bright-black
6| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
7| " "
style 1-1 inverse
8| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
9| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-41 dim
style 65-91 dim
10-31| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 0-63 fg=bright-black
8| <blank>
9| "/workspace/project (tui-staging) deepseek-v4-pro ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-48 fg=bright-black
style 51-55 fg=bright-black
style 58-67 fg=bright-black
10| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
11-31| <blank>

View File

@@ -0,0 +1,40 @@
terminal 56x20 buffer=normal length=20 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=19 bufferRow=19
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 "
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-55 fg=bright-black
8| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
9-11| <blank>
12| " "
13| " Question 1/1 (1 unanswered) · Confirm "
style 2-38 fg=bright-black
14| " Continue with this change? "
15| " "
16| " 1. Proceed Apply the proposed change "
style 2-13 fg=bright-blue bold
style 16-40 fg=bright-black
17| " Tab custom answer • Enter submit • Esc interrupt "
style 2-49 dim
18| " "
19| <blank>

View File

@@ -8,12 +8,11 @@ viewport
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────"
style 0-55 dim
4| " "
style 1-1 inverse
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| " "
6| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black

View File

@@ -8,17 +8,14 @@ viewport
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────"
style 0-55 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────"
style 0-55 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context"
style 0-43 dim
style 46-55 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| " "
8| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black

View File

@@ -1,38 +1,34 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=13 bufferRow=13
cursor hidden column=7 viewportRow=12 bufferRow=12
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Start then cancel. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue bold underline
5| "Start then cancel. "
6| <blank>
7| "Retrying model request (1/∞) in 1000ms: temporary transport failure "
style 0-66 fg=yellow
8| <blank>
9| " Retrying model request (1/∞) in 1000ms: temporary transport failure "
style 1-67 fg=yellow
9| "Turn cancelled. "
style 0-14 fg=yellow
10| <blank>
11| " Turn cancelled. "
style 1-15 fg=yellow
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| " "
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
16-35| <blank>
11| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
12| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
13-35| <blank>

View File

@@ -1,35 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=11 bufferRow=11
cursor hidden column=7 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Let the bounded policy exhaust. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue bold underline
5| "Let the bounded policy exhaust. "
6| <blank>
7| "provider still unavailable "
style 0-25 fg=red
8| <blank>
9| " provider still unavailable "
style 1-26 fg=red
10| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
11| " "
style 1-1 inverse
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
14-35| <blank>
9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
10| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
11-35| <blank>

View File

@@ -1,39 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=14 bufferRow=14
cursor hidden column=7 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Recover this request. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue bold underline
5| "Recover this request. "
6| <blank>
7| "Retrying model request (1/2) in 500ms: provider rate limit "
style 0-57 fg=yellow
8| <blank>
9| " Retrying model request (1/2) in 500ms: provider rate limit "
style 1-58 fg=yellow
10| <blank>
11| " Assistant "
style 1-9 fg=bright-magenta bold
12| " Recovered on the next bounded attempt. "
13| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
14| " "
style 1-1 inverse
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
17-35| <blank>
9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
10| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
11-35| <blank>

View File

@@ -1,35 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=11 bufferRow=11
cursor hidden column=7 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Recover this request. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue bold underline
5| "Recover this request. "
6| <blank>
7| "Retrying model request (1/2) in 500ms: provider rate limit "
style 0-57 fg=yellow
8| <blank>
9| " Retrying model request (1/2) in 500ms: provider rate limit "
style 1-58 fg=yellow
10| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
11| " "
style 1-1 inverse
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
14-35| <blank>
9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
10| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
11-35| <blank>

View File

@@ -1,39 +1,35 @@
terminal 96x24 buffer=normal length=24 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH session reference"
cursor hidden column=1 viewportRow=14 bufferRow=14
cursor hidden column=7 viewportRow=14 bufferRow=14
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Session reference snapshot."
style 1-27 fg=bright-black
2| " mock • target-session"
style 1-23 dim
2| " target-session"
style 1-14 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ Use @Source session "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "You "
style 0-2 fg=bright-blue bold underline
5| "Use @Source session "
6| <blank>
7| "Referenced sessions · Source session (source-session) "
style 0-52 dim
8| <blank>
9| " Referenced sessions · Source session (source-session) "
style 1-53 dim
10| <blank>
11| " Assistant "
style 1-9 fg=bright-magenta bold
12| " Combined reference request accepted. "
13| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
14| " "
style 1-1 inverse
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| "mock /workspace/project ↑0 ↓0 tools:collapsed"
style 0-30 dim
style 81-95 dim
17-23| <blank>
9| "Assistant "
style 0-8 fg=bright-magenta bold underline
10| "Combined reference request accepted. "
11| "Model wait 0.0s · Completed 2026-07-21 12:30:00 "
style 0-46 dim
12| <blank>
13| "/workspace/project mock ↑0 ↓0"
style 0-17 fg=bright-blue bold
style 20-23 fg=bright-black
style 26-30 fg=bright-black
14| " dsh ◍ "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
15-23| <blank>

View File

@@ -0,0 +1,31 @@
terminal 44x18 buffer=normal length=18 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=38 viewportRow=13 bufferRow=13
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-43 fg=bright-black
8| " ↑ 1 more "
style 1-14 dim
9| " enough detail to wrap across multiple "
10| " full-width continuation rows without "
11| " leaving a prompt-sized gap at the right "
12| " edge. "
13| " Then suggest a simpler version. "
style 38-38 inverse
14-17| <blank>

View File

@@ -1,110 +1,120 @@
terminal 56x36 buffer=normal length=36 base=0 viewport=0
terminal 56x36 buffer=normal length=44 base=8 viewport=8
lifecycle started=1 stopped=0 progress=inactive
title "Inspect session diagnostics — DSH snapshot"
cursor hidden column=1 viewportRow=32 bufferRow=32
cursor hidden column=7 viewportRow=35 bufferRow=43
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Inspect session diagnostics"
style 1-27 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ inspect this session "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Assistant "
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
12| "╭─ Session status ─────────────────────────────────────╮"
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Session inspected. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
style 0-2 fg=bright-blue bold underline
9| "inspect this session "
10| <blank>
11| "╭─ Session status ─────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-blue bold
style 17-55 dim
13| "│ Session: main-session │"
12| "│ Session: main-session │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
14| "│ Title: Inspect session diagnostics │"
13| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
15| "│ Directory: /workspace/project │"
14| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
16| "│ Model: deepseek/deepseek-v4-pro (effort │"
15| "│ Model: deepseek/deepseek-v4-pro (effort │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-55 dim
17| "│ default; reasoning blocks shown) │"
16| "│ default; reasoning blocks shown) │"
style 0-0 dim
style 15-46 dim
style 55-55 dim
18| "│ │"
17| "│ │"
style 0-0 dim
style 55-55 dim
19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
20| "│ tool call │"
19| "│ tool call │"
style 0-0 dim
style 55-55 dim
21| "│ │"
20| "│ │"
style 0-0 dim
style 55-55 dim
22| "│ Tokens: 1,250 input + 340 output │"
21| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
22| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-26 fg=bright-blue
style 27-32 dim
style 55-55 dim
24| "│ + 250 write) │"
23| "│ + 250 write) │"
style 0-0 dim
style 55-55 dim
25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
24| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-20 fg=bright-blue
style 21-32 dim
style 55-55 dim
26| "│ 128,000) │"
25| "│ 128,000) │"
style 0-0 dim
style 55-55 dim
27| "│ │"
26| "│ │"
style 0-0 dim
style 55-55 dim
28| "│ Created: 2026-07-22 09:10:11 UTC │"
27| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
29| "│ Active: 2026-07-22 09:10:11 UTC │"
28| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
30| "╰──────────────────────────────────────────────────────╯"
29| "╰──────────────────────────────────────────────────────╯"
style 0-55 dim
31| "────────────────────────────────────────────────────────"
style 0-55 dim
32| " "
style 1-1 inverse
33| "────────────────────────────────────────────────────────"
style 0-55 dim
34| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 6"
style 0-55 dim
35| <blank>
30| <blank>
31| "System prompt "
style 0-12 fg=bright-blue bold
32| "You are an AI agent powered by the DeepSeek Harness SDK."
33| " "
34| "Paths prefixed with @ are files explicitly referenced by"
35| "the user. Use the read tool when their contents are "
36| "needed; do not claim to have inspected a file before "
37| "reading it. "
38| <blank>
39| "Registered tools "
style 0-15 fg=bright-blue bold
40| "read, write "
41| <blank>
42| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-48 fg=bright-black
style 51-55 fg=bright-black
43| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse

View File

@@ -1,99 +1,107 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
terminal 92x32 buffer=normal length=38 base=6 viewport=6
lifecycle started=1 stopped=0 progress=inactive
title "Inspect session diagnostics — DSH snapshot"
cursor hidden column=1 viewportRow=28 bufferRow=28
cursor hidden column=7 viewportRow=31 bufferRow=37
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Inspect session diagnostics"
style 1-27 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ inspect this session "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Assistant "
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
12| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Session inspected. "
6| "Model wait 0.0s "
style 0-14 dim
7| <blank>
8| "You "
style 0-2 fg=bright-blue bold underline
9| "inspect this session "
10| <blank>
11| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-blue bold
style 17-81 dim
13| "│ Session: main-session │"
12| "│ Session: main-session │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
14| "│ Title: Inspect session diagnostics │"
13| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
15| "│ Directory: /workspace/project │"
14| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
16| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
15| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-79 dim
style 81-81 dim
17| "│ │"
16| "│ │"
style 0-0 dim
style 81-81 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
17| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
19| "│ │"
18| "│ │"
style 0-0 dim
style 81-81 dim
20| "│ Tokens: 1,250 input + 340 output │"
19| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-26 fg=bright-blue
style 27-32 dim
style 81-81 dim
22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-20 fg=bright-blue
style 21-32 dim
style 81-81 dim
23| "│ │"
22| "│ │"
style 0-0 dim
style 81-81 dim
24| "│ Created: 2026-07-22 09:10:11 UTC │"
23| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
25| "│ Active: 2026-07-22 09:10:11 UTC │"
24| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 81-81 dim
26| "╰────────────────────────────────────────────────────────────────────────────────╯"
25| "╰────────────────────────────────────────────────────────────────────────────────╯"
style 0-81 dim
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| " "
style 1-1 inverse
29| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
30| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed"
style 0-57 dim
style 64-91 dim
31| <blank>
26| <blank>
27| "System prompt "
style 0-12 fg=bright-blue bold
28| "You are an AI agent powered by the DeepSeek Harness SDK. "
29| " "
30| "Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when "
31| "their contents are needed; do not claim to have inspected a file before reading it. "
32| <blank>
33| "Registered tools "
style 0-15 fg=bright-blue bold
34| "read, write "
35| <blank>
36| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k ↓340 cache 67% 33% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-48 fg=bright-black
style 51-71 fg=bright-black
style 74-84 fg=bright-black
37| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse

View File

@@ -0,0 +1,34 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=11 bufferRow=11
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Reasoning "
style 0-8 fg=bright-black italic
6| "Checking the result. "
style 0-19 fg=bright-black italic
7| "The result is ready. "
8| "Model wait 1.0s · Thinking 2.0s · Response 3.0s · Completed 2026-07-21 14:32:12 "
style 0-78 dim
9| <blank>
10| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
11| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
12-35| <blank>

View File

@@ -1,30 +1,36 @@
terminal 44x18 buffer=normal length=18 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=9 bufferRow=9
cursor hidden column=7 viewportRow=15 bufferRow=15
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Context · compact "
style 1-17 dim
5| " Compacted summary: the prior command "
style 1-43 fg=bright-black
6| " completed and its details were retired "
style 1-43 fg=bright-black
7| " from the active surface. "
style 1-24 fg=bright-black
8| "────────────────────────────────────────────"
style 0-43 dim
9| " "
style 1-1 inverse
10| "────────────────────────────────────────────"
style 0-43 dim
11| "deepseek-v4-flash /workspace/project ↑0 ↓0"
style 0-43 dim
12-17| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Context · workspace-context "
style 0-26 dim
8| "system-reminder "
style 0-14 fg=bright-black
9| " Additional instructions from: "
10| "nested/AGENTS.md "
11| " "
12| " Render workspace context XML clearly. "
13| <blank>
14| "/workspace/project (tui-staging) deepseek-v"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-43 fg=bright-black
15| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
16-17| <blank>

View File

@@ -1,27 +1,37 @@
terminal 104x30 buffer=normal length=30 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=7 bufferRow=7
cursor hidden column=7 viewportRow=14 bufferRow=14
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " Context · compact "
style 1-17 dim
5| " Compacted summary: the prior command completed and its details were retired from the active surface. "
style 1-100 fg=bright-black
6| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-103 dim
7| " "
style 1-1 inverse
8| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-103 dim
9| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 77-103 dim
10-29| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Context · workspace-context "
style 0-26 dim
8| "system-reminder "
style 0-14 fg=bright-black
9| " Additional instructions from: nested/AGENTS.md "
10| " "
11| " Render workspace context XML clearly. "
12| <blank>
13| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
14| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
15-29| <blank>

View File

@@ -1,59 +1,47 @@
terminal 80x24 buffer=normal length=24 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=20 bufferRow=20
cursor hidden column=7 viewportRow=20 bufferRow=20
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "Old prompt with a long line that exercises wrapping before compaction. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
style 0-2 fg=bright-blue bold underline
7| "Old prompt with a long line that exercises wrapping before compaction. "
8| <blank>
9| ""
style 0-0 fg=green
10| "▌ ✓ pnpm run test:coverage "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-25 bold
11| "▌ Run the coverage gate "
style 0-0 fg=green
style 2-22 fg=bright-black
12| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
13| "▌ packages/ui/tui 100% "
style 0-0 fg=green
14| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
15| "▌ 1 test skipped "
style 0-0 fg=green
16| "▌ coverage complete "
style 0-0 fg=green
17| "▌ [exit 0] "
style 0-0 fg=green
style 2-9 dim
18| "▌ "
style 0-0 fg=green
19| "────────────────────────────────────────────────────────────────────────────────"
style 0-79 dim
20| " "
style 1-1 inverse
21| "────────────────────────────────────────────────────────────────────────────────"
style 0-79 dim
22| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 53-79 dim
23| <blank>
9| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
10| "$ pnpm run test:coverage "
style 0-23 fg=cyan
11| "/workspace/project "
style 0-17 dim
12| "packages/ui/tui 100% "
13| "… +1 lines (Ctrl+O to expand) "
style 0-28 dim
14| "1 test skipped "
15| "coverage complete "
16| "[exit 0] "
style 0-7 dim
17| "Model wait 0.0s "
style 0-14 dim
18| <blank>
19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
20| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
21-23| <blank>

View File

@@ -1,74 +1,66 @@
terminal 100x34 buffer=normal length=36 base=2 viewport=2
terminal 100x34 buffer=normal length=34 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
cursor hidden column=100 viewportRow=33 bufferRow=35
cursor hidden column=0 viewportRow=33 bufferRow=33
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 1-60 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| " "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
style 0-2 fg=bright-blue bold underline
7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
8| <blank>
9| " Reasoning "
style 1-9 fg=bright-black italic
10| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-62 fg=bright-black italic
11| <blank>
12| " Assistant "
style 1-9 fg=bright-magenta bold
13| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
14| <blank>
15| " "
style 0-0 fg=green
16| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-61 bold
17| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-65 fg=bright-black
18| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-54 dim
19| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
20| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-0 fg=green
style 2-58 fg=red
21| "▌ "
style 0-0 fg=green
22| <blank>
23| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-62 dim
24| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-60 fg=bright-black
25| <blank>
26| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
27| <blank>
28| " "
29| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 0-81 fg=green
10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 fg=cyan
11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-52 dim
12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-56 fg=red
14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 "
style 0-46 dim
15| <blank>
16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-61 dim
17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-59 fg=bright-black
18| <blank>
19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-62 fg=red
20-21| <blank>
22| "Plan"
style 0-3 fg=bright-blue bold
23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 2-2 fg=yellow
24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
25| " dsh > "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 7-7 inverse
26| " "
27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 2-90 fg=bright-black
30| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
31| " "
32| " 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
29| " "
30| " 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
style 2-65 fg=bright-blue bold
style 67-97 fg=bright-black
33| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
style 2-64 dim
34| " "
35| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 73-99 dim
31| " Tab custom answer • Enter submit • Esc interrupt "
style 2-49 dim
32| " "
33| <blank>

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, ReasoningEffortId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -27,6 +27,8 @@ const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
const CHECKPOINTS = [
'conversation-streaming',
'shell-prompt-multiline',
'step-timing-completed',
'retry-scheduled',
'retry-recovered',
'retry-cancelled',
@@ -40,12 +42,12 @@ const CHECKPOINTS = [
'advanced-cards-expanded',
'untrusted-controls',
'question-dialog',
'question-dialog-single-option',
'question-dialog-validation',
'surface-before-compaction',
'surface-after-compaction-narrow',
'surface-after-compaction-wide',
'model-selector',
'model-effort-switching',
'model-switching',
'errors-and-help',
'disposed-terminal',
@@ -104,7 +106,7 @@ async function setupSnapshot(
cwd: options.cwd === undefined ? '/workspace/project' : options.cwd,
config: Object.assign({
welcome: 'Snapshot agent ready.',
color: true,
theme: { color: true },
title: 'DSH snapshot',
}, options.config),
})
@@ -195,13 +197,12 @@ const ADVANCED_CARD_TOOLS: Record<string, ToolDefinition> = {
),
edit: visualTool(
'edit',
() => ({ card: 'diff', title: 'Edit renderer', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }),
() => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }),
// The real edit/write tools produce exactly one diff whose path the title
// already names, so the card omits the redundant per-file header.
(): ToolResultView => ({
card: 'diff',
diffs: [
{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' },
{ path: 'tests/view.spec.ts', oldText: null, newText: 'expect(screen).toMatchSnapshot()' },
],
diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }],
}),
),
subagent: visualTool('subagent', args => ({
@@ -209,12 +210,19 @@ const ADVANCED_CARD_TOOLS: Record<string, ToolDefinition> = {
title: 'Delegate renderer audit',
rawInput: (args as { prompt: string }).prompt,
})),
task_output: visualTool('task_output', args => ({
card: 'generic',
kind: 'read',
title: `Read output from background task ${(args as { task_id: string }).task_id}`,
rawInput: (args as { task_id: string }).task_id,
})),
task_output: visualTool(
'task_output',
args => ({
card: 'generic',
kind: 'read',
title: `Read output from background task ${(args as { task_id: string }).task_id}`,
rawInput: (args as { task_id: string }).task_id,
}),
() => ({
card: 'generic',
content: [{ type: 'text', text: '```console\nstarted background task bash-5\n```' }],
}),
),
skill: visualTool('skill', args => ({
card: 'generic',
kind: 'read',
@@ -228,46 +236,64 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7
describe('TUI terminal-state snapshots', () => {
it('pins an in-flight reasoning and Markdown stream', async () => {
let clock = new Date(2026, 6, 21, 14, 30, 0).getTime()
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
const harness = await setupSnapshot()
// Freeze the loader's first animation interval so this semantic snapshot
// cannot select a different spinner frame under scheduler contention.
const frozenLoaderTimer = setInterval(() => {}, 60_000)
const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer)
try {
await renderAfter(harness, () => {
harness.agent.status = 'running'
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
})
await renderAfter(harness, () => {
harness.agent.status = 'running'
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
clock += 1_000
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
const loaderIntervalMs = intervals.mock.calls[0]?.[1]
if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval')
await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5))
await checkpoint('conversation-streaming', harness.terminal)
} finally {
intervals.mockRestore()
clearInterval(frozenLoaderTimer)
await disposeSnapshot(harness)
}
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
clock += 2_000
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…\n\n```ts\nconst visible = true\n```' },
})
})
await checkpoint('conversation-streaming', harness.terminal)
await disposeSnapshot(harness)
nowSpy.mockRestore()
})
it('pins a completed step timing summary', async () => {
let clock = new Date(2026, 6, 21, 14, 32, 6).getTime()
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
const harness = await setupSnapshot()
await renderAfter(harness, () => {
clock += 1_000
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Checking the result.' },
})
clock += 2_000
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'The result is ready.' },
})
clock += 3_000
harness.session.append('step/end', { turn: 1, step: 1 })
})
await checkpoint('step-timing-completed', harness.terminal, { includeScrollback: true })
nowSpy.mockRestore()
await disposeSnapshot(harness)
})
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
@@ -293,15 +319,13 @@ describe('TUI terminal-state snapshots', () => {
})
await checkpoint('retry-scheduled', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.session.append('assistant/message', {
turn: 1,
step: 2,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }],
}, { surfaceOp: 'append' })
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
harness.session.append('assistant/message', {
turn: 1,
step: 2,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }],
}, { surfaceOp: 'append' })
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
@@ -352,7 +376,7 @@ describe('TUI terminal-state snapshots', () => {
})
it('paints the startup banner product name in the DeepSeek brand gradient on truecolor terminals', async () => {
const harness = await setupSnapshot({ config: { truecolor: true } })
const harness = await setupSnapshot({ config: { theme: { truecolor: true } } })
await checkpoint('banner-gradient', harness.terminal, {}, true)
await disposeSnapshot(harness)
})
@@ -457,6 +481,7 @@ describe('TUI terminal-state snapshots', () => {
})
it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime())
const tools = {
unsafe: visualTool(
'unsafe',
@@ -518,14 +543,13 @@ describe('TUI terminal-state snapshots', () => {
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await harness.terminal.waitForFrame(beforeQuestion)
await renderAfter(harness, () => {
agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
})
agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true })
controller.abort()
await rejected
await disposeSnapshot(harness)
nowSpy.mockRestore()
})
it('pins a constrained multi-select question and its validation state', async () => {
@@ -568,7 +592,37 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('pins a single-option question', async () => {
const harness = await setupSnapshot({
config: {
questionDialogWidth: 200,
questionDialogMaxHeight: 16,
},
}, { columns: 56, rows: 20 })
const controller = new AbortController()
const beforeQuestion = harness.terminal.frames
const answer = harness.ctx.userInteraction.ask({
questions: [{
id: 'confirm',
header: 'Confirm',
question: 'Continue with this change?',
options: [{ label: 'Proceed', description: 'Apply the proposed change' }],
}],
signal: controller.signal,
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await harness.terminal.waitForFrame(beforeQuestion)
await checkpoint('question-dialog-single-option', harness.terminal)
controller.abort()
await rejected
await disposeSnapshot(harness)
})
it('pins compaction surface replacement and narrow-to-wide reflow', async () => {
// Freeze the clock: the timing header hides zero-duration buckets, so a
// real-clock millisecond tick between the fixture appends and the render
// would flip `Tools 0.0s` in and out of the pinned header.
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime())
let replacementStart = 0
let replacementEnd = 0
let replacementSources: number[] = []
@@ -602,8 +656,11 @@ describe('TUI terminal-state snapshots', () => {
await renderAfter(harness, () => {
harness.session.append('user/message', {
content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }],
source: { kind: 'plugin', plugin: 'compact' },
content: [{
type: 'text',
text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n</system-reminder>',
}],
source: { kind: 'plugin', plugin: 'workspace-context' },
}, {
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
sourceEventSeqs: replacementSources,
@@ -615,9 +672,22 @@ describe('TUI terminal-state snapshots', () => {
await renderAfter(harness, () => { harness.terminal.resize(104, 30) })
await checkpoint('surface-after-compaction-wide', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
nowSpy.mockRestore()
})
it('pins wrapped and explicit multiline shell-prompt input', async () => {
const harness = await setupSnapshot({}, { columns: 44, rows: 18 })
await renderAfter(harness, () => {
harness.terminal.send('Explain this implementation with enough detail to wrap across multiple full-width continuation rows without leaving a prompt-sized gap at the right edge.')
harness.terminal.send('\x1b[13;2u')
harness.terminal.send('Then suggest a simpler version.')
})
await checkpoint('shell-prompt-multiline', harness.terminal)
await disposeSnapshot(harness)
})
it('pins help, unknown commands, live errors, turn failures, and terminal restoration', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 5, 0).getTime())
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
harness.terminal.send('/help')
@@ -635,6 +705,12 @@ describe('TUI terminal-state snapshots', () => {
turn: 2,
reason: { kind: 'interrupted' },
})
harness.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
harness.session.append('turn/end', { turn: 3, reason: { kind: 'disposed' } })
harness.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
// A merge-extensible turn-end kind unknown to the TUI still surfaces its
// name so the agent never stops without a visible reason.
harness.session.append('turn/end', { turn: 4, reason: { kind: 'plugin-policy' } as never })
})
await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true })
@@ -643,31 +719,11 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('disposed-terminal', harness.terminal, { includeScrollback: true })
await harness.ctx.fiber.dispose()
await harness.terminal.dispose()
nowSpy.mockRestore()
})
it('pins the model selector, effort cycling, and provider-default selection', async () => {
const harness = await setupSnapshot({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
resolveModelInfo: (_provider, model) => Promise.resolve({
context: { contextWindow: 128_000 },
reasoning: {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
...model === 'deepseek-v4-flash'
? { defaultEffort: ReasoningEffortId('high') }
: {},
},
}),
},
}, { columns: 92, rows: 32 })
it('pins the model selector and selection notice', async () => {
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
harness.terminal.send('/model')
harness.terminal.send('\r')
@@ -675,13 +731,6 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('model-selector', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.terminal.send('\x1b[B')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
harness.terminal.send('\x1b[Z')
})
await checkpoint('model-effort-switching', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.terminal.send('\r')
})
await checkpoint('model-switching', harness.terminal, { includeScrollback: true })
@@ -727,6 +776,22 @@ describe('TUI terminal-state snapshots', () => {
contextWindow: 128_000,
contextTokens: 42_000,
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
tools: {
read: {
name: 'read',
description: 'Read a file',
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
},
write: {
name: 'write',
description: 'Write a file',
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
},
},
beforeMount(session) {
appendUser(session, 'inspect this session')
appendAssistant(session, [{ type: 'text', text: 'Session inspected.' }], {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { renderUnknownXml } from '../src/components/xml-tool-output.ts'
const render = (source: string, limit = 4, expanded = false): string[] | undefined => renderUnknownXml(
source,
limit,
expanded,
text => text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu, control =>
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`),
text => `[label]${text}[/label]`,
count => ` … +${count} lines`,
)
describe('unknown-tool XML rendering', () => {
it('renders nested elements and attributes as an indented tree', () => {
expect(render(`<result>
<path>/tmp/a.txt</path>
<type>file</type>
<content>
<line number="1">hello</line>
<line number="2">world</line>
</content>
</result>`)).toEqual([
'[label]result[/label]',
' [label]path:[/label] /tmp/a.txt',
' [label]type:[/label] file',
' [label]content[/label]',
' [label]line (number="1"):[/label] hello',
' [label]line (number="2"):[/label] world',
])
})
it('renders root text, CDATA, empty elements, and multiline nested text', () => {
expect(render(' <result>\nfirst\nsecond\n</result> ')).toEqual([
'[label]result[/label]',
' first',
' second',
])
expect(render('<result>\nfirst\nsecond\n</result>', 1, true)).toEqual([
'[label]result[/label]',
' first',
' second',
])
expect(render('<result><value><![CDATA[literal <xml>]]></value><empty /></result>')).toEqual([
'[label]result[/label]',
' [label]value:[/label] literal <xml>',
' [label]empty[/label]',
])
})
it('previews each top-level child independently and expands all rows', () => {
const xml = '<result><first>\na\nb\nc\nd\ne\nf\n</first><second>\ng\nh\ni\nj\nk\nl\n</second></result>'
expect(render(xml, 3)).toEqual([
'[label]result[/label]',
' [label]first[/label]',
' a',
' … +4 lines',
' f',
' [label]second[/label]',
' g',
' … +4 lines',
' l',
])
expect(render(xml, 3, true)).toHaveLength(15)
})
it('bounds the collapsed child count and counts the hidden lines', () => {
const xml = `<result>${Array.from({ length: 8 }, (_, index) => `<item>${index}</item>`).join('')}</result>`
expect(render(xml, 3)).toEqual([
'[label]result[/label]',
' [label]item:[/label] 0',
' [label]item:[/label] 1',
' … +5 lines',
' [label]item:[/label] 7',
])
expect(render(xml, 3, true)).toHaveLength(9)
})
it('escapes control characters expanded from character references', () => {
expect(render('<result attr="a&#155;b">tab&#9;csi&#155;</result>')).toEqual([
'[label]result (attr="a\\\\x9bb")[/label]',
' tab\\x09csi\\x9b',
])
expect(render('<result><value><![CDATA[del\u007f]]></value></result>')).toEqual([
'[label]result[/label]',
' [label]value:[/label] del\\x7f',
])
})
it.each([
'<result><path>missing close</result>',
'<first /><second />',
'<result /> <![CDATA[trailing]]>',
'prefix <result><path>/tmp/a</path></result>',
'<result><path>/tmp/a</path></result> suffix',
'<?xml version="1.0"?><result />',
'<result><?target value?></result>',
'<!DOCTYPE result><result />',
'<result><!-- comment --></result>',
'',
' \n ',
])('declines malformed or mixed text: %s', (source) => {
expect(render(source)).toBeUndefined()
})
})

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsdown'
import baseConfig from '../../../tsdown.config.ts'
export default defineConfig({
...baseConfig,
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/prompt.js'],
})