refactor(tui): split createTuiChat into chat/ sub-controllers
Extract model-command, questions, and resume sub-machines from the ~1600-line createTuiChat closure into src/chat/ factories that take explicit dependency bundles (shared ChatChannelDeps/ChannelNotice). Reorganize src/ so chat/ holds all chat-channel concerns (former input and session/ files move under it); xml-tool-output moves to components/; TuiRuntime/TuiResumeHost move to runtime.ts. index.ts drops 2067->~1530 lines. Behavior identical: 167 tests and all TUI snapshots pass unchanged.
This commit is contained in:
95
packages/ui/tui/src/chat/autocomplete.ts
Normal file
95
packages/ui/tui/src/chat/autocomplete.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Editor autocomplete provider merging path-only file candidates and optional
|
||||
* session-reference snapshots with the base slash-command completions.
|
||||
* @module @deepseek-ai/dsh-tui/chat/autocomplete
|
||||
*/
|
||||
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
type AutocompleteSuggestions,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
formatSessionReferenceMention,
|
||||
type SessionReferenceService,
|
||||
} from '@deepseek-ai/dsh-session-reference'
|
||||
import { displayInlineText } from '../components/text.ts'
|
||||
import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts'
|
||||
|
||||
/** Merge path-only file candidates and optional session snapshots with commands. */
|
||||
export class ReferenceAutocompleteProvider implements AutocompleteProvider {
|
||||
constructor(
|
||||
private readonly base: CombinedAutocompleteProvider,
|
||||
private readonly files: WorkspaceFileSearch,
|
||||
private readonly sessions: SessionReferenceService | undefined,
|
||||
private readonly agent: Agent,
|
||||
) {}
|
||||
|
||||
async getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
options: { signal: AbortSignal; force?: boolean },
|
||||
): Promise<AutocompleteSuggestions | null> {
|
||||
const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options)
|
||||
const currentLine = lines[cursorLine]
|
||||
/* v8 ignore next -- Editor always supplies its current state line. */
|
||||
if (currentLine === undefined) return basePromise
|
||||
const token = activeAtToken(currentLine, cursorCol)
|
||||
if (token === undefined) {
|
||||
this.files.invalidate()
|
||||
return basePromise
|
||||
}
|
||||
const filePromise = this.files.list(token.query, options.signal).catch(() => [])
|
||||
const sessionPromise = this.sessions === undefined || token.quoted
|
||||
? Promise.resolve([])
|
||||
: this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => [])
|
||||
const [base, fileCandidates, sessionCandidates] = await Promise.all([
|
||||
basePromise,
|
||||
filePromise,
|
||||
sessionPromise,
|
||||
])
|
||||
if (options.signal.aborted) return base
|
||||
const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => {
|
||||
const value = formatFileMention(candidate, token.quoted)
|
||||
if (value === undefined) return []
|
||||
const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
|
||||
const directory = candidate.kind === 'directory'
|
||||
return [{
|
||||
value,
|
||||
label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`,
|
||||
description: displayInlineText(candidate.path),
|
||||
}]
|
||||
})
|
||||
const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => {
|
||||
const mentionLabel = displayInlineText(candidate.label)
|
||||
const sessionId = displayInlineText(candidate.sessionId)
|
||||
const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
|
||||
const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
|
||||
return {
|
||||
value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }),
|
||||
label: `Session · ${mentionLabel}`,
|
||||
description,
|
||||
}
|
||||
})
|
||||
const items = [...fileItems, ...sessionItems]
|
||||
if (items.length === 0) return base
|
||||
return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
||||
return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
|
||||
}
|
||||
|
||||
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
||||
return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol)
|
||||
}
|
||||
}
|
||||
31
packages/ui/tui/src/chat/channel.ts
Normal file
31
packages/ui/tui/src/chat/channel.ts
Normal 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
|
||||
}
|
||||
346
packages/ui/tui/src/chat/file-autocomplete.ts
Normal file
346
packages/ui/tui/src/chat/file-autocomplete.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Host-workspace discovery for TUI `@file` completion. The index contains
|
||||
* paths only: selected values remain ordinary prompt text and file contents
|
||||
* stay behind the model-facing `read` tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tui/chat/file-autocomplete
|
||||
*/
|
||||
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
|
||||
/** Default maximum file and directory candidates rendered for one query. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
|
||||
/** Default maximum entries retained in one workspace search index. */
|
||||
export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000
|
||||
/** Directory basenames omitted from traversal unless the deployment overrides them. */
|
||||
export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const
|
||||
|
||||
/** Resolved limits and exclusions for one TUI workspace index. */
|
||||
export interface FileSearchConfig {
|
||||
/** Maximum ranked candidates returned for one query. */
|
||||
maxResults: number
|
||||
/** Maximum indexed files and directories. */
|
||||
maxEntries: number
|
||||
/** Directory basenames never traversed or offered. */
|
||||
excludedDirectories: readonly string[]
|
||||
}
|
||||
|
||||
/** One path-only completion candidate inside the session cwd. */
|
||||
export interface FileSearchCandidate {
|
||||
/** User-facing path accepted by the normal prompt and filesystem tools. */
|
||||
path: string
|
||||
/** Directories keep completion open; files finish the mention. */
|
||||
kind: 'file' | 'directory'
|
||||
}
|
||||
|
||||
/** Active `@` token ending at the editor cursor. */
|
||||
export interface ActiveAtToken {
|
||||
/** Complete token replaced when the user accepts a completion. */
|
||||
prefix: string
|
||||
/** Path query after `@` or `@"`. */
|
||||
query: string
|
||||
/** Whether the user opened a quoted path. */
|
||||
quoted: boolean
|
||||
}
|
||||
|
||||
interface IndexedPath extends FileSearchCandidate {}
|
||||
|
||||
interface RankedPath {
|
||||
candidate: FileSearchCandidate
|
||||
score: number
|
||||
}
|
||||
|
||||
interface IndexGeneration {
|
||||
controller: AbortController
|
||||
promise: Promise<IndexedPath[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
|
||||
* inside another token, such as an email address, is not a completion trigger.
|
||||
* @param line - current editor line.
|
||||
* @param cursorCol - cursor column within that line.
|
||||
* @returns the active token, or `undefined` outside an `@` token.
|
||||
*/
|
||||
export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined {
|
||||
const beforeCursor = line.slice(0, cursorCol)
|
||||
const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor)
|
||||
if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
|
||||
return { prefix: quoted[1], query: quoted[2], quoted: true }
|
||||
}
|
||||
const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor)
|
||||
if (plain?.[1] === undefined || plain[2] === undefined) return undefined
|
||||
return { prefix: plain[1], query: plain[2], quoted: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a selected path as prompt text. Whitespace uses Pi's quoted
|
||||
* `@"path"` grammar; directories retain a trailing slash so completion can
|
||||
* descend another level.
|
||||
* @param candidate - selected file or directory.
|
||||
* @param preserveQuote - retain an explicitly opened quote even when unnecessary.
|
||||
* @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely.
|
||||
*/
|
||||
export function formatFileMention(
|
||||
candidate: FileSearchCandidate,
|
||||
preserveQuote: boolean,
|
||||
): string | undefined {
|
||||
const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path
|
||||
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
|
||||
const quoted = preserveQuote || /\s/u.test(path)
|
||||
if (!quoted) return `@${path}`
|
||||
return `@"${path}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable, reusable fuzzy index rooted at one agent working directory.
|
||||
* Directory-scoped queries list live state; bare fuzzy queries share one
|
||||
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
|
||||
*/
|
||||
export class WorkspaceFileSearch {
|
||||
private readonly excludedDirectories: ReadonlySet<string>
|
||||
private generation: IndexGeneration | undefined
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly config: FileSearchConfig,
|
||||
) {
|
||||
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
|
||||
throw new Error('file search maxResults must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
|
||||
throw new Error('file search maxEntries must be a positive safe integer')
|
||||
}
|
||||
if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
|
||||
throw new Error('file search excludedDirectories entries must be non-empty directory basenames')
|
||||
}
|
||||
this.excludedDirectories = new Set(config.excludedDirectories)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return ranked path candidates for the current token.
|
||||
* @param rawQuery - path text following `@` or `@"`.
|
||||
* @param signal - cancels this caller's wait without killing an index shared by a newer query.
|
||||
* @returns at most `maxResults` deterministic candidates.
|
||||
*/
|
||||
async list(rawQuery: string, signal: AbortSignal): Promise<FileSearchCandidate[]> {
|
||||
signal.throwIfAborted()
|
||||
if (this.disposed) return []
|
||||
const query = rawQuery.replaceAll('\\', '/')
|
||||
const slash = query.lastIndexOf('/')
|
||||
if (query === '' || slash >= 0) {
|
||||
const directory = slash < 0 ? '' : query.slice(0, slash + 1)
|
||||
const fragment = slash < 0 ? '' : query.slice(slash + 1)
|
||||
return this.listDirectory(directory, fragment, signal)
|
||||
}
|
||||
const indexed = await waitForPromise(this.ensureIndex(), signal)
|
||||
return rankCandidates(
|
||||
indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)),
|
||||
query,
|
||||
this.config.maxResults,
|
||||
)
|
||||
}
|
||||
|
||||
/** Discard the current index so the next bare query observes a fresh tree. */
|
||||
invalidate(): void {
|
||||
this.generation?.controller.abort(new Error('file search index invalidated'))
|
||||
this.generation = undefined
|
||||
}
|
||||
|
||||
/** Abort traversal and make later queries return no candidates. */
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private ensureIndex(): Promise<IndexedPath[]> {
|
||||
if (this.generation !== undefined) return this.generation.promise
|
||||
const controller = new AbortController()
|
||||
const generation = {
|
||||
controller,
|
||||
promise: Promise.resolve([] as IndexedPath[]),
|
||||
} satisfies IndexGeneration
|
||||
generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => {
|
||||
/* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
|
||||
if (this.generation === generation) this.generation = undefined
|
||||
throw error
|
||||
})
|
||||
this.generation = generation
|
||||
return generation.promise
|
||||
}
|
||||
|
||||
private async scanWorkspace(signal: AbortSignal): Promise<IndexedPath[]> {
|
||||
const indexed: IndexedPath[] = []
|
||||
const directories: { absolute: string; relative: string }[] = [{ absolute: this.root, relative: '' }]
|
||||
for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) {
|
||||
signal.throwIfAborted()
|
||||
const directory = directories[cursor]
|
||||
/* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
|
||||
if (directory === undefined) {
|
||||
throw new Error('file search selected a missing directory')
|
||||
}
|
||||
const entries = await readDirectory(directory.absolute, signal)
|
||||
for (const entry of entries) {
|
||||
signal.throwIfAborted()
|
||||
const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`
|
||||
if (entry.isDirectory()) {
|
||||
if (this.excludedDirectories.has(entry.name)) continue
|
||||
indexed.push({ path, kind: 'directory' })
|
||||
directories.push({ absolute: join(directory.absolute, entry.name), relative: path })
|
||||
} else if (entry.isFile()) {
|
||||
indexed.push({ path, kind: 'file' })
|
||||
}
|
||||
if (indexed.length >= this.config.maxEntries) break
|
||||
}
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
private async listDirectory(
|
||||
displayDirectory: string,
|
||||
fragment: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FileSearchCandidate[]> {
|
||||
if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
|
||||
const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal)
|
||||
if (absolute === undefined) return []
|
||||
const entries = await readDirectory(absolute, signal)
|
||||
const candidates: FileSearchCandidate[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue
|
||||
if (entry.isDirectory()) {
|
||||
if (this.excludedDirectories.has(entry.name)) continue
|
||||
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' })
|
||||
} else if (entry.isFile()) {
|
||||
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' })
|
||||
}
|
||||
}
|
||||
return rankCandidates(candidates, fragment, this.config.maxResults)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDisplayDirectory(
|
||||
root: string,
|
||||
displayDirectory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
const resolvedRoot = resolve(root)
|
||||
const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory)
|
||||
const fromRoot = relative(resolvedRoot, absolute)
|
||||
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined
|
||||
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
|
||||
if (isAbsolute(fromRoot)) return undefined
|
||||
let current = resolvedRoot
|
||||
for (const segment of fromRoot.split(sep).filter(Boolean)) {
|
||||
signal.throwIfAborted()
|
||||
current = join(current, segment)
|
||||
try {
|
||||
const status = await lstat(current)
|
||||
signal.throwIfAborted()
|
||||
if (status.isSymbolicLink() || !status.isDirectory()) return undefined
|
||||
} catch (_error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
async function readDirectory(absolute: string, signal: AbortSignal) {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const entries = await readdir(absolute, { withFileTypes: true })
|
||||
signal.throwIfAborted()
|
||||
return entries.sort((left, right) => compareText(left.name, right.name))
|
||||
} catch (_error: unknown) {
|
||||
signal.throwIfAborted()
|
||||
// An unreadable/missing subtree contributes no candidates; other readable
|
||||
// branches remain useful and autocomplete is advisory.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function visibleForGlobalQuery(path: string, query: string): boolean {
|
||||
if (query.startsWith('.') || query.includes('/.')) return true
|
||||
return !path.split('/').some(segment => segment.startsWith('.'))
|
||||
}
|
||||
|
||||
function rankCandidates(
|
||||
candidates: readonly FileSearchCandidate[],
|
||||
query: string,
|
||||
limit: number,
|
||||
): FileSearchCandidate[] {
|
||||
const ranked: RankedPath[] = []
|
||||
for (const candidate of candidates) {
|
||||
const score = scoreCandidate(candidate, query)
|
||||
if (score !== undefined) ranked.push({ candidate, score })
|
||||
}
|
||||
ranked.sort((left, right) =>
|
||||
right.score - left.score
|
||||
|| kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
|
||||
|| (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
|
||||
|| compareText(left.candidate.path, right.candidate.path))
|
||||
return ranked.slice(0, limit).map(entry => entry.candidate)
|
||||
}
|
||||
|
||||
function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined {
|
||||
if (query === '') return 0
|
||||
const path = candidate.path.toLowerCase()
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
const needle = query.toLowerCase()
|
||||
const directoryBonus = candidate.kind === 'directory' ? 25 : 0
|
||||
if (name === needle) return 1_000 + directoryBonus
|
||||
if (name.startsWith(needle)) return 900 + directoryBonus
|
||||
if (name.includes(needle)) return 700 + directoryBonus
|
||||
if (path.includes(needle)) return 500 + directoryBonus
|
||||
const subsequence = subsequenceScore(path, needle)
|
||||
return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
|
||||
}
|
||||
|
||||
function subsequenceScore(target: string, query: string): number | undefined {
|
||||
let targetIndex = 0
|
||||
let gap = 0
|
||||
for (const character of query) {
|
||||
const found = target.indexOf(character, targetIndex)
|
||||
if (found < 0) return undefined
|
||||
gap += found - targetIndex
|
||||
targetIndex = found + 1
|
||||
}
|
||||
return Math.max(0, 100 - gap)
|
||||
}
|
||||
|
||||
function kindRank(kind: FileSearchCandidate['kind']): number {
|
||||
return kind === 'directory' ? 0 : 1
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
/* v8 ignore next -- entries and candidates are unique; host enumeration
|
||||
* order determines which comparison direction sort requests. */
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function waitForPromise<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
/* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */
|
||||
if (signal.aborted) return Promise.reject(errorReason(signal.reason, 'file search aborted'))
|
||||
return new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
const onAbort = (): void => { rejectPromise(errorReason(signal.reason, 'file search aborted')) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolvePromise(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
rejectPromise(errorReason(error, 'file search index failed'))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function errorReason(reason: unknown, fallback: string): Error {
|
||||
return reason instanceof Error ? reason : new Error(fallback, { cause: reason })
|
||||
}
|
||||
151
packages/ui/tui/src/chat/helpers.ts
Normal file
151
packages/ui/tui/src/chat/helpers.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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, SessionEvent } 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 event meta.
|
||||
* @param meta - envelope-context meta to inspect.
|
||||
* @returns per-reference labels, or `undefined` when meta is not a reference card.
|
||||
*/
|
||||
export function sessionReferenceCard(meta: unknown): string[] | undefined {
|
||||
if (typeof meta !== 'object' || meta === null) return undefined
|
||||
const record = meta 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-reference cards attached to a prompt or steering message envelope.
|
||||
* @param event - the user or steering message event to read.
|
||||
* @returns per-envelope-context reference-label lists, empty when none.
|
||||
*/
|
||||
export function promptReferenceCards(
|
||||
event: Extract<SessionEvent, { type: 'user/message' | 'steering/message' }>,
|
||||
): string[][] {
|
||||
return event.data.envelope?.prefixContexts.flatMap((context) => {
|
||||
const card = sessionReferenceCard(context.meta)
|
||||
return card === undefined ? [] : [card]
|
||||
}) ?? []
|
||||
}
|
||||
|
||||
/** Milliseconds between banner sweep-reveal frames (~60 fps). */
|
||||
export const BANNER_REVEAL_INTERVAL_MS = 15
|
||||
|
||||
/** Number of sweep frames the banner reveal spreads the terminal width over. */
|
||||
export const BANNER_REVEAL_STEPS = 24
|
||||
191
packages/ui/tui/src/chat/model-command.ts
Normal file
191
packages/ui/tui/src/chat/model-command.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Model-selection sub-controller for the interactive chat channel: the queued
|
||||
* `/model` command, the keyboard model selector overlay with reasoning-effort
|
||||
* selection, and resolution of the selected model's context window. Owns the
|
||||
* context-window cache the prompt and status views read; the caller owns the
|
||||
* shared {@link AgentLlmTargetRef}.
|
||||
* @module @deepseek-ai/dsh-tui/chat/model-command
|
||||
*/
|
||||
|
||||
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { displayText } from '../components/text.ts'
|
||||
import {
|
||||
ModelDialog,
|
||||
readModelChoices,
|
||||
targetLabel,
|
||||
targetReasoningLabel,
|
||||
type ModelChoice,
|
||||
type ModelDialogSelection,
|
||||
} from '../components/dialogs.ts'
|
||||
import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** Collaborators the model controller needs from the chat channel. */
|
||||
export interface ModelControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
/** Shared selected-target handle owned by the channel. */
|
||||
readonly target: AgentLlmTargetRef
|
||||
}
|
||||
|
||||
/** Model-selection controller for one chat channel. */
|
||||
export interface ModelController {
|
||||
/** Resolved context window of the selected model, or `undefined` if unknown. */
|
||||
contextWindow(): number | undefined
|
||||
/** Queue a `/model` command; empty argument opens the selector. */
|
||||
queueModelCommand(raw: string): void
|
||||
/** Drop the pending context-window resolution (shutdown). */
|
||||
resetContextResolution(): void
|
||||
/** Forget the tracked selector overlay (shutdown). */
|
||||
clearOverlay(): void
|
||||
}
|
||||
|
||||
type ContextResolution =
|
||||
| { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
|
||||
| { readonly kind: 'error'; readonly error: unknown }
|
||||
|
||||
/**
|
||||
* Build the model-selection controller for one chat channel.
|
||||
* @param deps - channel collaborators and shared target handle.
|
||||
* @returns the controller wired to the channel's overlay and prompt views.
|
||||
*/
|
||||
export function createModelController(deps: ModelControllerDeps): ModelController {
|
||||
const { ctx, resolved, palette, overlayManager, target } = deps
|
||||
let contextWindow: number | undefined
|
||||
let contextResolution: Promise<ContextResolution> | undefined
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
const resolution: Promise<ContextResolution> = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const),
|
||||
(error: unknown) => ({ kind: 'error', error } as const),
|
||||
)
|
||||
contextResolution = resolution
|
||||
void resolution.then((result) => {
|
||||
if (contextResolution !== resolution) return
|
||||
if (result.kind === 'error') {
|
||||
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
contextWindow = result.contextWindow
|
||||
deps.requestRender()
|
||||
})
|
||||
}
|
||||
resolveContextWindow(target.current)
|
||||
|
||||
const selectModel = (
|
||||
selected: ModelChoice,
|
||||
explicitReasoning?: { effort: ReasoningEffortId | undefined },
|
||||
): void => {
|
||||
const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model
|
||||
const reasoningEffort = explicitReasoning === undefined
|
||||
? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort)
|
||||
: explicitReasoning.effort
|
||||
if (sameRoute && target.current?.reasoningEffort === reasoningEffort) {
|
||||
const reasoning = targetReasoningLabel(selected, reasoningEffort)
|
||||
deps.appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`)
|
||||
return
|
||||
}
|
||||
target.current = {
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
}
|
||||
resolveContextWindow(target.current)
|
||||
const reasoning = targetReasoningLabel(selected, reasoningEffort)
|
||||
deps.appendNotice([
|
||||
`Model selected: ${targetLabel(selected)}.`,
|
||||
...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`],
|
||||
'New steps will use it.',
|
||||
].join(' '))
|
||||
}
|
||||
|
||||
const showModelSelector = (choices: readonly ModelChoice[]): void => {
|
||||
const current = target.current === undefined ? 'unset' : targetLabel(target.current)
|
||||
if (choices.length === 0) {
|
||||
deps.appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
|
||||
return
|
||||
}
|
||||
void modelOverlay?.close()
|
||||
const session = overlayManager.open({
|
||||
create: () => new ModelDialog(
|
||||
choices,
|
||||
target.current,
|
||||
resolved.maxModelOptions,
|
||||
palette,
|
||||
(selection: ModelDialogSelection) => {
|
||||
void session.close()
|
||||
selectModel(selection.choice, { effort: selection.reasoningEffort })
|
||||
},
|
||||
() => { void session.close() },
|
||||
),
|
||||
options: {
|
||||
width: resolved.modelDialogWidth,
|
||||
maxHeight: resolved.modelDialogMaxHeight,
|
||||
anchor: 'center',
|
||||
margin: 1,
|
||||
},
|
||||
})
|
||||
modelOverlay = session
|
||||
void session.closed.then(() => {
|
||||
if (modelOverlay === session) modelOverlay = undefined
|
||||
})
|
||||
deps.requestRender()
|
||||
}
|
||||
|
||||
const handleModelCommand = async (raw: string): Promise<void> => {
|
||||
const choices = await readModelChoices(ctx, target.current)
|
||||
if (deps.isDisposed()) return
|
||||
const argument = raw.trim()
|
||||
if (argument === '') {
|
||||
showModelSelector(choices)
|
||||
return
|
||||
}
|
||||
const parts = argument.split(/\s+/u)
|
||||
if (parts.length > 2) {
|
||||
deps.appendNotice('Usage: /model [provider/]model', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
let matches: ModelChoice[]
|
||||
if (parts.length === 2) {
|
||||
matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
|
||||
} else {
|
||||
const value = argument
|
||||
const qualified = choices.filter(choice => targetLabel(choice) === value)
|
||||
matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
deps.appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
|
||||
return
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
deps.appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
|
||||
return
|
||||
}
|
||||
const selected = matches[0]
|
||||
/* v8 ignore next -- a non-empty matches array always has index zero. */
|
||||
if (selected === undefined) return
|
||||
selectModel(selected)
|
||||
}
|
||||
|
||||
return {
|
||||
contextWindow: () => contextWindow,
|
||||
queueModelCommand(raw: string): void {
|
||||
modelCommands = modelCommands.then(async () => {
|
||||
await handleModelCommand(raw)
|
||||
}).catch((error: unknown) => {
|
||||
if (!deps.isDisposed()) deps.appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
|
||||
})
|
||||
},
|
||||
resetContextResolution(): void {
|
||||
contextResolution = undefined
|
||||
},
|
||||
clearOverlay(): void {
|
||||
modelOverlay = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
168
packages/ui/tui/src/chat/questions.ts
Normal file
168
packages/ui/tui/src/chat/questions.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Ask-user-question sub-machine for the interactive chat channel. Registers the
|
||||
* user-interaction provider, presents one question overlay at a time in FIFO
|
||||
* order, and settles each request on answer, abort, overlay error, or channel
|
||||
* shutdown.
|
||||
* @module @deepseek-ai/dsh-tui/chat/questions
|
||||
*/
|
||||
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionAnswerItem,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { QuestionDialog } from '../components/dialogs.ts'
|
||||
import type { ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** One queued or active ask-user-question request and its running answers. */
|
||||
interface PendingQuestion {
|
||||
request: AskUserQuestionRequest
|
||||
index: number
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
resolve(answer: AskUserQuestionAnswer): void
|
||||
reject(error: unknown): void
|
||||
onAbort: () => void
|
||||
overlay: TuiOverlaySession | undefined
|
||||
}
|
||||
|
||||
/** Collaborators the question queue needs from the chat channel. */
|
||||
export type QuestionQueueDeps = ChatChannelDeps
|
||||
|
||||
/** Ask-user-question controller for one chat channel. */
|
||||
export interface QuestionQueue {
|
||||
/** Reject the active and all queued questions (shutdown). */
|
||||
rejectAll(): void
|
||||
/** Remove the user-interaction provider registration. */
|
||||
unregister(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ask-user-question queue for one chat channel.
|
||||
* @param deps - channel collaborators and overlay host.
|
||||
* @returns the controller used at shutdown to drain and unregister.
|
||||
*/
|
||||
export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue {
|
||||
const { ctx, resolved, palette, overlayManager } = deps
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
|
||||
const removeAbortListener = (pending: PendingQuestion): void => {
|
||||
pending.request.signal?.removeEventListener('abort', pending.onAbort)
|
||||
}
|
||||
|
||||
const rejectQuestion = (pending: PendingQuestion): void => {
|
||||
void pending.overlay?.close()
|
||||
pending.overlay = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
'ask_user_question was interrupted before the user answered',
|
||||
'ASK_ABORTED',
|
||||
))
|
||||
}
|
||||
|
||||
const startNextQuestion = (): void => {
|
||||
if (activeQuestion !== undefined || deps.isDisposed()) return
|
||||
const pending = questionQueue.shift()
|
||||
if (pending === undefined) return
|
||||
activeQuestion = pending
|
||||
const show = (): void => {
|
||||
const question = pending.request.questions[pending.index]
|
||||
if (question === undefined) {
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.resolve({ answers: pending.answers })
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
const session = overlayManager.open({
|
||||
...pending.request.signal === undefined ? {} : { signal: pending.request.signal },
|
||||
create: () => new QuestionDialog(
|
||||
question,
|
||||
pending.index + 1,
|
||||
pending.request.questions.length,
|
||||
pending.request.questions.length - pending.answers.length,
|
||||
resolved.maxQuestionOptions,
|
||||
palette,
|
||||
(selection) => {
|
||||
pending.overlay = undefined
|
||||
void session.close()
|
||||
pending.answers.push({ id: question.id, ...selection })
|
||||
pending.index += 1
|
||||
show()
|
||||
},
|
||||
() => {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
},
|
||||
),
|
||||
options: {
|
||||
width: resolved.questionDialogWidth,
|
||||
maxHeight: resolved.questionDialogMaxHeight,
|
||||
anchor: 'bottom-left',
|
||||
margin: { bottom: 1 },
|
||||
},
|
||||
})
|
||||
pending.overlay = session
|
||||
void session.closed.then((result) => {
|
||||
if (pending.overlay !== session) return
|
||||
pending.overlay = undefined
|
||||
/* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */
|
||||
if (result.reason !== 'error') return
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
`ask_user_question TUI failed: ${errorChain(result.error)}`,
|
||||
'ASK_ABORTED',
|
||||
))
|
||||
startNextQuestion()
|
||||
})
|
||||
deps.requestRender()
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
const unregister = ctx.userInteraction.registerProvider({
|
||||
ask(request) {
|
||||
return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
|
||||
const pending: PendingQuestion = {
|
||||
request,
|
||||
index: 0,
|
||||
answers: [],
|
||||
resolve: resolveAnswer,
|
||||
reject,
|
||||
overlay: undefined,
|
||||
onAbort: () => {
|
||||
if (activeQuestion === pending) {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
// A non-active pending ask remains in the queue until this listener settles it.
|
||||
questionQueue.splice(questionQueue.indexOf(pending), 1)
|
||||
rejectQuestion(pending)
|
||||
},
|
||||
}
|
||||
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
|
||||
questionQueue.push(pending)
|
||||
startNextQuestion()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
rejectAll(): void {
|
||||
if (activeQuestion !== undefined) {
|
||||
const pending = activeQuestion
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
}
|
||||
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
|
||||
},
|
||||
unregister,
|
||||
}
|
||||
}
|
||||
245
packages/ui/tui/src/chat/resume.ts
Normal file
245
packages/ui/tui/src/chat/resume.ts
Normal 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')
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
67
packages/ui/tui/src/chat/skill-invocation.ts
Normal file
67
packages/ui/tui/src/chat/skill-invocation.ts
Normal 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}`
|
||||
}
|
||||
347
packages/ui/tui/src/chat/timing.ts
Normal file
347
packages/ui/tui/src/chat/timing.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Per-step timing model and running-status glyph animation for the terminal
|
||||
* front door. Timing buckets are replayed from the session event stream; the
|
||||
* running glyph fades in on turn start, throbs while the turn runs, and fades
|
||||
* out on turn end.
|
||||
* @module @deepseek-ai/dsh-tui/chat/timing
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Palette } from '../components/theme.ts'
|
||||
|
||||
/**
|
||||
* Render cadence of the running prompt while active, and while the glyph fades
|
||||
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
|
||||
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
|
||||
* changed terminal cells are re-emitted, so the faster tick stays cheap.
|
||||
*/
|
||||
export const STATUS_ANIMATION_INTERVAL_MS = 50
|
||||
|
||||
/**
|
||||
* Milliseconds over which the running glyph fades in when a turn starts and
|
||||
* fades out after it ends. The fade is an envelope over the running pulse:
|
||||
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
|
||||
*/
|
||||
export const STATUS_FADE_MS = 300
|
||||
|
||||
/** Milliseconds for one full brightness throb of the running glyph. */
|
||||
export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
|
||||
/**
|
||||
* Brightness floor of the running throb, as a fraction of the settled gray. At
|
||||
* 0 the pulse swells from the near-background trough up to full and back. The
|
||||
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
|
||||
* cosine breathes symmetrically bold→dim→bold.
|
||||
*/
|
||||
export const STATUS_PULSE_FLOOR = 0
|
||||
|
||||
/**
|
||||
* Muted-gray foreground the truecolor running glyph fades through, from the
|
||||
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
|
||||
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
|
||||
* appearing rather than a colored indicator. Foreground-only, matching the
|
||||
* brand gradient, so it stays legible on any terminal background.
|
||||
*/
|
||||
const STATUS_FADE_GRAY = {
|
||||
trough: [43, 43, 43],
|
||||
settled: [136, 136, 136],
|
||||
} as const
|
||||
|
||||
/** The active phase of a running step, one bucket of accumulated wall time. */
|
||||
export type TimingBucket = 'ttft' | 'thinking' | 'responding' | 'tools'
|
||||
|
||||
/** Turn/step coordinates of one assistant step. */
|
||||
export type StepPosition = { turn: number; step: number }
|
||||
|
||||
/** Accumulated wall time per phase for one step or session slice. */
|
||||
export interface TimingTotals {
|
||||
ttft: number
|
||||
thinking: number
|
||||
responding: number
|
||||
tools: number
|
||||
}
|
||||
|
||||
interface TimingState {
|
||||
totals: TimingTotals
|
||||
active: { bucket: TimingBucket; since: number } | undefined
|
||||
}
|
||||
|
||||
const TIMING_BUCKET_LABELS: Record<TimingBucket, string> = {
|
||||
ttft: 'Model wait',
|
||||
thinking: 'Thinking',
|
||||
responding: 'Response',
|
||||
tools: 'Tools',
|
||||
}
|
||||
|
||||
const TIMING_BUCKETS: readonly TimingBucket[] = ['ttft', 'thinking', 'responding', 'tools']
|
||||
|
||||
function emptyTimingTotals(): TimingTotals {
|
||||
return { ttft: 0, thinking: 0, responding: 0, tools: 0 }
|
||||
}
|
||||
|
||||
function timingState(startedAt?: number): TimingState {
|
||||
return {
|
||||
totals: emptyTimingTotals(),
|
||||
/* v8 ignore next -- production timing state always begins at a logged step timestamp. */
|
||||
active: startedAt === undefined ? undefined : { bucket: 'ttft', since: startedAt },
|
||||
}
|
||||
}
|
||||
|
||||
function sameStep(event: SessionEvent, position: StepPosition): boolean {
|
||||
return typeof event.data === 'object'
|
||||
&& 'turn' in event.data && 'step' in event.data
|
||||
&& event.data.turn === position.turn && event.data.step === position.step
|
||||
}
|
||||
|
||||
function closeTimingBucket(state: TimingState, at: number): void {
|
||||
if (state.active === undefined) return
|
||||
state.totals[state.active.bucket] += Math.max(0, at - state.active.since)
|
||||
state.active = undefined
|
||||
}
|
||||
|
||||
function enterTimingBucket(state: TimingState, bucket: TimingBucket | undefined, at: number): void {
|
||||
if (state.active?.bucket === bucket) return
|
||||
closeTimingBucket(state, at)
|
||||
if (bucket !== undefined) state.active = { bucket, since: at }
|
||||
}
|
||||
|
||||
function advanceStepTiming(
|
||||
state: TimingState,
|
||||
event: Extract<SessionEvent, { type: 'assistant/chunk' | 'tool/call' | 'step/end' }>,
|
||||
): void {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (state.active?.bucket === 'ttft') enterTimingBucket(state, undefined, event.time)
|
||||
if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) {
|
||||
enterTimingBucket(state, 'thinking', event.time)
|
||||
} else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) {
|
||||
enterTimingBucket(state, 'responding', event.time)
|
||||
}
|
||||
} else if (event.type === 'tool/call') {
|
||||
enterTimingBucket(state, 'tools', event.time)
|
||||
} else {
|
||||
closeTimingBucket(state, event.time)
|
||||
}
|
||||
}
|
||||
|
||||
function timingTotalsAt(state: TimingState, at?: number): TimingTotals {
|
||||
const totals = { ...state.totals }
|
||||
if (state.active !== undefined && at !== undefined) {
|
||||
totals[state.active.bucket] += Math.max(0, at - state.active.since)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one step's accumulated per-phase timing up to clock `at`.
|
||||
* @param events - Session events to replay.
|
||||
* @param position - Turn/step coordinates of the step.
|
||||
* @param at - Render clock to accumulate the open bucket up to.
|
||||
* @returns The step's per-phase totals.
|
||||
*/
|
||||
export function stepTimingAt(
|
||||
events: readonly SessionEvent[],
|
||||
position: StepPosition,
|
||||
at: number,
|
||||
): TimingTotals {
|
||||
const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position))
|
||||
if (startIndex < 0) return emptyTimingTotals()
|
||||
const start = events[startIndex] as Extract<SessionEvent, { type: 'step/start' }>
|
||||
const state = timingState(start.time)
|
||||
for (let index = startIndex + 1; index < events.length; index += 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.time > at) break
|
||||
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
|
||||
&& sameStep(event, position)) {
|
||||
advanceStepTiming(state, event)
|
||||
if (event.type === 'step/end') break
|
||||
}
|
||||
}
|
||||
return timingTotalsAt(state, at)
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn index of the currently open turn, or `undefined` when none is open.
|
||||
* @param events - Session events to scan from the tail.
|
||||
* @returns The open turn index, or `undefined`.
|
||||
*/
|
||||
export function openTurn(events: readonly SessionEvent[]): number | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'turn/end') return undefined
|
||||
if (event.type === 'turn/start') return event.data.turn
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-specific status glyph, keyed by the running step's active timing bucket.
|
||||
* `ttft` is the pre-first-token wait a running turn falls back to between steps.
|
||||
*/
|
||||
export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
|
||||
ttft: '◍',
|
||||
thinking: '✻',
|
||||
responding: '●',
|
||||
tools: '⚙',
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the currently open step's active timing bucket, or `undefined` when no
|
||||
* step is open. The open step is the last `step/start` with no later matching
|
||||
* `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}.
|
||||
* @param events - Session events to scan.
|
||||
* @returns The open step's active bucket, or `undefined`.
|
||||
*/
|
||||
export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | undefined {
|
||||
let startIndex = -1
|
||||
let start: Extract<SessionEvent, { type: 'step/start' }> | undefined
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'step/end') return undefined
|
||||
if (event.type === 'step/start') {
|
||||
startIndex = index
|
||||
start = event
|
||||
break
|
||||
}
|
||||
if (event.type === 'turn/end') return undefined
|
||||
}
|
||||
if (start === undefined) return undefined
|
||||
const position = start.data
|
||||
const state = timingState(start.time)
|
||||
for (let index = startIndex + 1; index < events.length; index += 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
|
||||
&& sameStep(event, position)) {
|
||||
advanceStepTiming(state, event)
|
||||
}
|
||||
}
|
||||
return state.active?.bucket
|
||||
}
|
||||
|
||||
/**
|
||||
* The running agent's phase glyph, or `undefined` when idle. A running turn
|
||||
* with no open step falls back to the pre-first-token wait so a glyph is always
|
||||
* available while the agent works; it fades in on turn start, throbs while the
|
||||
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
|
||||
* @param events - Session events to derive the phase from.
|
||||
* @param running - Whether the agent is currently running.
|
||||
* @returns The phase glyph, or `undefined` when idle.
|
||||
*/
|
||||
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
|
||||
if (!running) return undefined
|
||||
const bucket = openStepPhase(events) ?? 'ttft'
|
||||
return TIMING_BUCKET_GLYPHS[bucket]
|
||||
}
|
||||
|
||||
/**
|
||||
* The running throb's brightness at continuous clock `nowMs`: a cosine between
|
||||
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
|
||||
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
|
||||
* fade envelope, which alone drives appear/disappear at turn boundaries.
|
||||
*
|
||||
* @param nowMs - Monotonic render clock in milliseconds.
|
||||
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
|
||||
*/
|
||||
export function pulseLevel(nowMs: number): number {
|
||||
const phase = (nowMs % STATUS_PULSE_PERIOD_MS) / STATUS_PULSE_PERIOD_MS
|
||||
const wave = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase)
|
||||
return STATUS_PULSE_FLOOR + (1 - STATUS_PULSE_FLOOR) * wave
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of the running glyph at fade `opacity` (0 = near-background trough
|
||||
* gray, 1 = settled dim gray). The character and its width never change — only
|
||||
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
|
||||
* the caret dimly breathing, never a colored indicator.
|
||||
*
|
||||
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
|
||||
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
|
||||
* and the running throb render as a smooth, symmetric brightness swing with no
|
||||
* hard cutoff to clip the trough into a blank. Without truecolor there is no
|
||||
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
|
||||
* shows the glyph in the palette's muted role or leaves a blank column — a
|
||||
* single dim appear/disappear at fixed width, still dim rather than accent, and
|
||||
* no throb-driven blink. With color off entirely a visible glyph is bare,
|
||||
* holding the caret column on a monochrome terminal.
|
||||
*
|
||||
* @param glyph - The phase glyph to paint.
|
||||
* @param palette - Active palette supplying the muted (dim gray) role.
|
||||
* @param colorEnabled - Whether ANSI is emitted at all.
|
||||
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.
|
||||
* @param opacity - Brightness fraction in [0, 1] for the truecolor gray.
|
||||
* @param visible - Whether the non-truecolor fallback shows the glyph at all.
|
||||
* @returns The gray glyph at this opacity, or a single space when hidden.
|
||||
*/
|
||||
export function fadeGlyph(
|
||||
glyph: string,
|
||||
palette: Palette,
|
||||
colorEnabled: boolean,
|
||||
truecolor: boolean,
|
||||
opacity: number,
|
||||
visible: boolean,
|
||||
): string {
|
||||
if (truecolor && colorEnabled) {
|
||||
const o = Math.min(Math.max(opacity, 0), 1)
|
||||
const [tr, tg, tb] = STATUS_FADE_GRAY.trough
|
||||
const [sr, sg, sb] = STATUS_FADE_GRAY.settled
|
||||
const r = Math.round(tr + (sr - tr) * o)
|
||||
const g = Math.round(tg + (sg - tg) * o)
|
||||
const b = Math.round(tb + (sb - tb) * o)
|
||||
return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m`
|
||||
}
|
||||
if (!visible) return ' '
|
||||
return colorEnabled ? palette.muted(glyph) : glyph
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a non-negative elapsed span at 100 ms resolution.
|
||||
* @param elapsedMs - Elapsed milliseconds.
|
||||
* @returns The formatted duration (e.g. `1.5s`, `2m03.4s`).
|
||||
*/
|
||||
export function formatStatusDuration(elapsedMs: number): string {
|
||||
const tenths = Math.floor(Math.max(0, elapsedMs) / 100)
|
||||
const seconds = tenths / 10
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${minutes}m${(seconds - minutes * 60).toFixed(1).padStart(4, '0')}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the non-zero timing buckets of one step as a middot-joined summary.
|
||||
* @param totals - Per-phase totals to format.
|
||||
* @param includeModelWait - Whether to always include the model-wait bucket.
|
||||
* @returns The formatted timing summary.
|
||||
*/
|
||||
export function formatTimingTotals(totals: TimingTotals, includeModelWait = false): string {
|
||||
return TIMING_BUCKETS
|
||||
.filter(bucket => totals[bucket] > 0 || (includeModelWait && bucket === 'ttft'))
|
||||
.map(bucket => `${TIMING_BUCKET_LABELS[bucket]} ${formatStatusDuration(totals[bucket])}`)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the queued-steering badge shown on the running status line.
|
||||
* @param queued - Number of queued steering messages.
|
||||
* @returns The badge text, or `undefined` when nothing is queued.
|
||||
*/
|
||||
export function formatQueuedStatus(queued: number): string | undefined {
|
||||
return queued > 0 ? `${queued} queued` : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a completion timestamp as `YYYY-MM-DD HH:MM:SS` in local time.
|
||||
* @param time - Epoch milliseconds.
|
||||
* @returns The formatted local timestamp.
|
||||
*/
|
||||
export function formatCompletionTime(time: number): string {
|
||||
const date = new Date(time)
|
||||
const parts = [
|
||||
date.getFullYear().toString().padStart(4, '0'),
|
||||
(date.getMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getDate().toString().padStart(2, '0'),
|
||||
]
|
||||
const clock = [date.getHours(), date.getMinutes(), date.getSeconds()]
|
||||
.map(value => value.toString().padStart(2, '0'))
|
||||
.join(':')
|
||||
return `${parts.join('-')} ${clock}`
|
||||
}
|
||||
96
packages/ui/tui/src/chat/tokens.ts
Normal file
96
packages/ui/tui/src/chat/tokens.ts
Normal 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`
|
||||
}
|
||||
Reference in New Issue
Block a user