Merge remote-tracking branch 'origin/master' into worktree/skill-invocation-controls
# Conflicts: # docs/cordis-catalog/services.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts # packages/ui/tui/README.i18n.yaml
This commit is contained in:
@@ -1,26 +1,23 @@
|
||||
/**
|
||||
* 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.
|
||||
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
|
||||
* @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 { SessionId } 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,
|
||||
@@ -31,9 +28,7 @@ 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
|
||||
@@ -43,47 +38,27 @@ export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
|
||||
/** Session-resume controller for one chat channel. */
|
||||
export interface ResumeController {
|
||||
/** Open the current-workspace searchable session selector. */
|
||||
/** Open the searchable session selector, scoped to this workspace until the user widens it. */
|
||||
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.
|
||||
* @returns the controller wired to the `/resume` command.
|
||||
*/
|
||||
export function createResumeController(deps: ResumeControllerDeps): ResumeController {
|
||||
const {
|
||||
ctx, agent, config, runtime, resolved, palette, overlayManager,
|
||||
persistence, sessionQuery, ui, editor,
|
||||
ctx, agent, runtime, resolved, palette, overlayManager,
|
||||
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)
|
||||
}
|
||||
/** Label any session's own workspace the way the prompt labels the current one. */
|
||||
const workspaceLabel = (cwd: string | undefined): string =>
|
||||
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
|
||||
|
||||
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
|
||||
const readResumeCandidate = async (
|
||||
@@ -109,6 +84,7 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
providers,
|
||||
workspaceLabel,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
@@ -116,13 +92,18 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
currentWorkspace: record.header.cwd === agent.session.header.cwd,
|
||||
workspaceLabel: workspaceLabel(record.header.cwd),
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read every mutable precondition immediately before terminal handoff. */
|
||||
const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => {
|
||||
/**
|
||||
* Re-read every mutable precondition immediately before terminal handoff and
|
||||
* resolve the exact identity and workspace the host will re-exec into.
|
||||
*/
|
||||
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
|
||||
/* 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()
|
||||
@@ -134,9 +115,12 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
new Set(ctx.llm.listProviders().map(provider => provider.id)),
|
||||
)
|
||||
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
|
||||
const cwd = candidate.record.header.cwd
|
||||
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
|
||||
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
|
||||
const finalStatus = deps.agentStatus()
|
||||
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
|
||||
return candidate
|
||||
return { id: candidate.record.header.id, cwd }
|
||||
}
|
||||
|
||||
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
|
||||
@@ -147,13 +131,9 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
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')
|
||||
deps.appendNotice('Session is resumable, but this host cannot hand it off in place.', 'warning')
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
|
||||
@@ -169,7 +149,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
if (deps.isDisposed()) return
|
||||
ui.stop()
|
||||
terminalReleased = true
|
||||
await hostHandoff(checked.record.header.id)
|
||||
// The host re-execs into the session's own workspace: process cwd, not the
|
||||
// restored session header, is what the filesystem and shell tools resolve
|
||||
// against.
|
||||
await hostHandoff(checked.id, checked.cwd)
|
||||
throw new Error('resume host returned without replacing the process')
|
||||
} catch (error: unknown) {
|
||||
if (!deps.isDisposed()) {
|
||||
@@ -189,12 +172,6 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -208,9 +185,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
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)
|
||||
// Every workspace in the store is summarized; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
|
||||
const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers)))
|
||||
const candidates = await Promise.all(records.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
|
||||
@@ -218,7 +196,7 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
create: host => new ResumePicker(
|
||||
candidates,
|
||||
resolved.maxResumeOptions,
|
||||
runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd),
|
||||
workspaceLabel(agent.session.header.cwd),
|
||||
() => host.viewport.rows,
|
||||
palette,
|
||||
(candidate) => { void handoffResume(candidate, session) },
|
||||
|
||||
@@ -290,7 +290,7 @@ export function fadeGlyph(
|
||||
return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m`
|
||||
}
|
||||
if (!visible) return ' '
|
||||
return colorEnabled ? palette.muted(glyph) : glyph
|
||||
return colorEnabled ? palette.dim(glyph) : glyph
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -205,7 +205,7 @@ export class StatusCardComponent implements Component {
|
||||
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 prefix = ` ${this.palette.dim(plainLabel.padEnd(labelWidth))} `
|
||||
const continuation = ' '.repeat(1 + labelWidth + 2)
|
||||
const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix))
|
||||
const wrapped = wrapTextWithAnsi(value, valueWidth)
|
||||
@@ -281,9 +281,10 @@ export function renderDialog(
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */
|
||||
/** Keyboard model selector rendered as a bordered overlay, with a filter box and per-model reasoning-effort cycling. */
|
||||
export class ModelDialog implements Component {
|
||||
private readonly list: SelectList
|
||||
private list: SelectList
|
||||
private readonly filter = new Input()
|
||||
private readonly items: Map<string, SelectItem>
|
||||
private readonly choices: Map<string, ModelChoice>
|
||||
private readonly efforts: Map<string, ReasoningEffortId | undefined>
|
||||
@@ -292,10 +293,10 @@ export class ModelDialog implements Component {
|
||||
constructor(
|
||||
choices: readonly ModelChoice[],
|
||||
current: AgentLlmTarget | undefined,
|
||||
maxVisible: number,
|
||||
private readonly maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
done: (selection: ModelDialogSelection) => void,
|
||||
cancel: () => void,
|
||||
private readonly done: (selection: ModelDialogSelection) => void,
|
||||
private readonly cancel: () => void,
|
||||
) {
|
||||
this.items = new Map()
|
||||
this.choices = new Map()
|
||||
@@ -317,18 +318,38 @@ export class ModelDialog implements Component {
|
||||
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
|
||||
this.list = this.buildList(this.currentValue)
|
||||
}
|
||||
|
||||
/** Build a SelectList over the currently filtered items, selecting `selectValue` when present. */
|
||||
private buildList(selectValue: string | undefined): SelectList {
|
||||
const items = this.filteredItems()
|
||||
const list = new SelectList(items, this.maxVisible, dialogSelectTheme(this.palette))
|
||||
const index = selectValue === undefined ? 0 : items.findIndex(item => item.value === selectValue)
|
||||
list.setSelectedIndex(Math.max(0, index))
|
||||
list.onSelect = (item) => { this.confirm(item) }
|
||||
list.onCancel = this.cancel
|
||||
return list
|
||||
}
|
||||
|
||||
/** Items matching the filter box, as a case-insensitive substring over the label, model name, and description. */
|
||||
private filteredItems(): SelectItem[] {
|
||||
const query = this.filter.getValue().trim().toLocaleLowerCase()
|
||||
if (query === '') return [...this.items.values()]
|
||||
return [...this.items.values()].filter((item) => {
|
||||
const choice = this.choices.get(item.value)
|
||||
/* v8 ignore next -- items and choices share the same keys. */
|
||||
if (choice === undefined) return false
|
||||
return [item.value, choice.modelName, choice.description ?? '']
|
||||
.some(field => field.toLocaleLowerCase().includes(query))
|
||||
})
|
||||
}
|
||||
|
||||
private confirm(item: SelectItem): void {
|
||||
const selected = this.choices.get(item.value)
|
||||
/* v8 ignore next -- SelectList only returns values built from `choices`. */
|
||||
if (selected === undefined) return
|
||||
this.done({ choice: selected, reasoningEffort: this.efforts.get(item.value) })
|
||||
}
|
||||
|
||||
private describeChoice(choice: ModelChoice, isCurrent: boolean): string {
|
||||
@@ -362,24 +383,50 @@ export class ModelDialog implements Component {
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.filter.invalidate()
|
||||
this.list.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.shift(Key.tab))) {
|
||||
this.cycleReasoningEffort()
|
||||
} else {
|
||||
} else if (matchesKey(data, Key.escape)) {
|
||||
if (this.filter.getValue() === '') this.cancel()
|
||||
else {
|
||||
this.filter.setValue('')
|
||||
this.list = this.buildList(undefined)
|
||||
}
|
||||
} else if (
|
||||
matchesKey(data, Key.up)
|
||||
|| matchesKey(data, Key.down)
|
||||
|| matchesKey(data, Key.enter)
|
||||
) {
|
||||
this.list.handleInput(data)
|
||||
} else {
|
||||
const previous = this.filter.getValue()
|
||||
this.filter.focused = true
|
||||
this.filter.handleInput(data)
|
||||
if (this.filter.getValue() !== previous) {
|
||||
const selected = this.list.getSelectedItem()
|
||||
this.list = this.buildList(selected?.value)
|
||||
}
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
this.filter.focused = true
|
||||
const results = this.filteredItems()
|
||||
const filterContent = truncateToWidth(this.filter.render(innerWidth).join(''), innerWidth, '')
|
||||
return renderDialog('Select model', [
|
||||
...this.list.render(innerWidth),
|
||||
filterContent,
|
||||
'',
|
||||
this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
|
||||
...results.length === 0
|
||||
? [this.palette.dim(' No models match the filter')]
|
||||
: this.list.render(innerWidth),
|
||||
'',
|
||||
this.palette.dim('type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc'),
|
||||
], width, this.palette)
|
||||
}
|
||||
}
|
||||
@@ -396,6 +443,10 @@ export interface ResumeCandidate {
|
||||
title: string
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
/** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */
|
||||
currentWorkspace: boolean
|
||||
/** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */
|
||||
workspaceLabel: string
|
||||
route?: ResumeRoute
|
||||
goalPhase?: GoalPhase
|
||||
disabledReason?: string
|
||||
@@ -429,12 +480,15 @@ function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | 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.
|
||||
* title, route, goal phase, workspace scope, and any reason the session cannot
|
||||
* be resumed here. A workspace other than the current one is a scope, not a
|
||||
* disabled reason: resuming it hands the process off into that directory.
|
||||
* @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 cwd - The CURRENT session's workspace, which decides the picker scope this row falls in.
|
||||
* @param availableProviders - Providers registered in this runtime.
|
||||
* @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label.
|
||||
* @returns The summarized resume candidate.
|
||||
*/
|
||||
export function summarizeResumeCandidate(
|
||||
@@ -443,6 +497,7 @@ export function summarizeResumeCandidate(
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
formatWorkspace: (cwd: string | undefined) => string,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
|
||||
const route = resumeRoute(snapshot)
|
||||
@@ -450,7 +505,7 @@ export function summarizeResumeCandidate(
|
||||
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 (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
@@ -459,6 +514,8 @@ export function summarizeResumeCandidate(
|
||||
title,
|
||||
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
|
||||
lastTurn: resumeTurnLabel(snapshot),
|
||||
currentWorkspace: record.header.cwd === cwd,
|
||||
workspaceLabel: formatWorkspace(record.header.cwd),
|
||||
...route === undefined ? {} : { route },
|
||||
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
|
||||
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
|
||||
@@ -466,12 +523,23 @@ export function summarizeResumeCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
|
||||
/** Which workspaces the resume picker currently lists. */
|
||||
export type ResumeScope = 'workspace' | 'all'
|
||||
|
||||
/**
|
||||
* Full-viewport keyboard selector over detached, preflighted resume summaries.
|
||||
*
|
||||
* Two scopes over one candidate set: `workspace` (the default) lists only the
|
||||
* current session's workspace, `all` lists every workspace and labels each row
|
||||
* with its own. Tab toggles between them; the search query and selection reset
|
||||
* on a scope change so the highlighted row always belongs to the visible list.
|
||||
*/
|
||||
export class ResumePicker implements Component, Focusable {
|
||||
private readonly search = new Input()
|
||||
private pasteBuffer: string | undefined
|
||||
private selectedIndex = 0
|
||||
private error = ''
|
||||
private scope: ResumeScope = 'workspace'
|
||||
focused = false
|
||||
|
||||
constructor(
|
||||
@@ -488,15 +556,29 @@ export class ResumePicker implements Component, Focusable {
|
||||
this.search.invalidate()
|
||||
}
|
||||
|
||||
/** Candidates in the active scope, before the search query narrows them. */
|
||||
private scoped(): ResumeCandidate[] {
|
||||
return this.scope === 'all'
|
||||
? [...this.candidates]
|
||||
: this.candidates.filter(candidate => candidate.currentWorkspace)
|
||||
}
|
||||
|
||||
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))
|
||||
const scoped = this.scoped()
|
||||
if (query === '') return scoped
|
||||
// The workspace label only distinguishes rows once it is on screen, so it
|
||||
// joins the searchable text exactly in the scope that shows it.
|
||||
return scoped.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|
||||
|| candidate.record.header.id.toLocaleLowerCase().includes(query)
|
||||
|| (this.scope === 'all' && candidate.workspaceLabel.toLocaleLowerCase().includes(query)))
|
||||
}
|
||||
|
||||
private visibleCandidateCount(): number {
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
|
||||
// The all-workspaces scope adds a per-row workspace line, so a row costs
|
||||
// one more terminal row there than in the single-workspace scope.
|
||||
const rowHeight = this.scope === 'all' ? 5 : 4
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight))
|
||||
return Math.min(this.maxVisible, candidateBudget)
|
||||
}
|
||||
|
||||
@@ -553,6 +635,11 @@ export class ResumePicker implements Component, Focusable {
|
||||
Math.max(0, filtered.length - 1),
|
||||
this.selectedIndex + this.visibleCandidateCount(),
|
||||
)
|
||||
} else if (matchesKey(data, Key.tab)) {
|
||||
this.scope = this.scope === 'workspace' ? 'all' : 'workspace'
|
||||
this.search.setValue('')
|
||||
this.selectedIndex = 0
|
||||
this.error = ''
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const selected = filtered[this.selectedIndex]
|
||||
if (selected === undefined) this.error = 'No session matches this search.'
|
||||
@@ -570,6 +657,21 @@ export class ResumePicker implements Component, Focusable {
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope line under the search box: the active scope with the current
|
||||
* workspace it means, and the inactive scope with the count Tab would reveal.
|
||||
*/
|
||||
private renderScopeLine(): string {
|
||||
const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length
|
||||
const active = this.scope === 'workspace'
|
||||
? `this workspace ${displayText(this.workspaceLabel)}`
|
||||
: `all workspaces (${this.candidates.length})`
|
||||
const other = this.scope === 'workspace'
|
||||
? `all workspaces (${this.candidates.length})`
|
||||
: `this workspace (${inWorkspace})`
|
||||
return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}`
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
this.search.focused = this.focused
|
||||
const height = Math.max(1, this.viewportRows())
|
||||
@@ -594,7 +696,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
`${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))}`,
|
||||
`${indent}${this.renderScopeLine()}`,
|
||||
'',
|
||||
)
|
||||
|
||||
@@ -620,8 +722,13 @@ export class ResumePicker implements Component, Focusable {
|
||||
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(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
||||
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
// Only the all-workspaces scope mixes directories, so the per-row
|
||||
// workspace is redundant in the scope that already names one.
|
||||
if (this.scope === 'all') {
|
||||
push(this.palette.dim(` workspace ${displayText(candidate.workspaceLabel)}`))
|
||||
}
|
||||
if (candidate.disabledReason !== undefined) {
|
||||
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
|
||||
}
|
||||
@@ -632,7 +739,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
push(this.palette.error(displayText(this.error)))
|
||||
}
|
||||
|
||||
const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}`
|
||||
const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel')}`
|
||||
while (lines.length < height - 2) lines.push('')
|
||||
lines.push(footer, '')
|
||||
return lines.slice(0, height)
|
||||
@@ -720,7 +827,7 @@ export class QuestionDialog implements Component, Focusable {
|
||||
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),
|
||||
this.palette.dim(header),
|
||||
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
|
||||
]
|
||||
const push = (line: string): void => { lines.push(line) }
|
||||
@@ -764,7 +871,7 @@ export class QuestionDialog implements Component, Focusable {
|
||||
: left
|
||||
const description = option.description === undefined
|
||||
? ''
|
||||
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
|
||||
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}`
|
||||
push(`${leftStyled}${description}`)
|
||||
}
|
||||
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
|
||||
|
||||
@@ -11,67 +11,149 @@ import type {
|
||||
TerminalColorScheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
|
||||
/** Theme-agnostic role colors and SGR attribute wrappers. */
|
||||
/**
|
||||
* Text carrying exactly one palette color. Branded so the compiler rejects
|
||||
* wrapping it in a second color: SGR has no color stack, so an inner span's
|
||||
* close reverts to the default foreground rather than the outer color, which
|
||||
* silently drops the outer color for the remainder of the line.
|
||||
*/
|
||||
export type Colored = string & { readonly __coloredBy: unique symbol }
|
||||
|
||||
/**
|
||||
* Text a color may still be applied to: a bare string, or one already carrying
|
||||
* SGR attributes. Attributes (bold, italic, underline, strike, reverse) occupy
|
||||
* independent SGR groups from the foreground color, so they compose in either
|
||||
* order without either side clobbering the other.
|
||||
*/
|
||||
export type Colorable = string & { readonly __coloredBy?: undefined }
|
||||
|
||||
/** Applies one color role; rejects input that already carries a color. */
|
||||
export type ColorRole = (text: Colorable) => Colored
|
||||
|
||||
/** Applies one SGR attribute; accepts colored or uncolored text and preserves its color. */
|
||||
export type AttributeRole = <T extends string>(text: T) => T
|
||||
|
||||
/**
|
||||
* Theme-agnostic role colors and SGR attribute wrappers.
|
||||
*
|
||||
* One role per visual meaning: `dim` is the single recessed tone, `accent` the
|
||||
* single emphasis color, and `success`/`error` double as a diff's added/removed
|
||||
* pair. Roles that resolved to the same escape were merged rather than kept as
|
||||
* aliases, so a reader cannot pick a name that silently renders as another.
|
||||
*
|
||||
* Colors and attributes are separately typed: `bold(accent(x))` and
|
||||
* `accent(bold(x))` both compile, while `accent(error(x))` does not.
|
||||
*/
|
||||
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
|
||||
accent: ColorRole
|
||||
/** The terminal's own default foreground; still a color, so it does not stack. */
|
||||
text: ColorRole
|
||||
/** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */
|
||||
dim: ColorRole
|
||||
success: ColorRole
|
||||
warning: ColorRole
|
||||
error: ColorRole
|
||||
code: ColorRole
|
||||
bold: AttributeRole
|
||||
italic: AttributeRole
|
||||
underline: AttributeRole
|
||||
strike: AttributeRole
|
||||
/** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
|
||||
selected: (text: string) => string
|
||||
selected: AttributeRole
|
||||
}
|
||||
|
||||
function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
|
||||
return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
|
||||
/** Names of the palette's color roles, in the order `/palette` prints them. */
|
||||
export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const
|
||||
|
||||
/** Names of the palette's attribute roles, in the order `/palette` prints them. */
|
||||
export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const
|
||||
|
||||
/** One role's SGR parameters and the reason it carries them. */
|
||||
export interface RoleSpec {
|
||||
/** SGR parameters that open the span, without the `ESC [` prefix or `m` suffix. */
|
||||
readonly open: string
|
||||
/** SGR parameters that close it; MUST reset every group `open` sets. */
|
||||
readonly close: string
|
||||
/** What the role means, shown by `/palette`. */
|
||||
readonly purpose: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every SGR code the TUI is allowed to emit, keyed by role. This table is the
|
||||
* single source: {@link createPalette} derives the wrappers from it and
|
||||
* `/palette` prints it, so a role cannot exist in one and not the other, and no
|
||||
* component hand-writes an escape.
|
||||
*
|
||||
* Only the standard 16-color set and SGR attributes appear here. Terminals remap
|
||||
* those to the user's active theme, so the TUI stays legible on any background;
|
||||
* a fixed 24-bit color would not. The brand gradient is the one deliberate
|
||||
* exception ({@link gradientText}).
|
||||
*
|
||||
* @param scheme - Active terminal color scheme; only `code` differs between them.
|
||||
* @returns The SGR spec for every color and attribute role.
|
||||
*/
|
||||
export function paletteSpec(scheme: TerminalColorScheme): {
|
||||
readonly colors: Readonly<Record<typeof COLOR_ROLES[number], RoleSpec>>
|
||||
readonly attributes: Readonly<Record<typeof ATTRIBUTE_ROLES[number], RoleSpec>>
|
||||
} {
|
||||
return {
|
||||
colors: {
|
||||
// The terminal's own foreground, emitted as no escape at all: ordinary body
|
||||
// text must inherit whatever the user's theme uses.
|
||||
text: { open: '', close: '', purpose: 'Body text, the terminal default foreground' },
|
||||
// SGR 2 over an explicit default foreground, closing both groups it sets.
|
||||
// The attribute fades relative to whatever the terminal's own foreground is,
|
||||
// which is the only way to land *below* `text` on both schemes: ANSI 90
|
||||
// (bright black) is a fixed hue that many light themes render heavier than
|
||||
// their default foreground, which made every "dim" surface the most
|
||||
// prominent text on screen.
|
||||
dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' },
|
||||
accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' },
|
||||
// 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'
|
||||
? { open: '34', close: '39', purpose: 'Inline code and code blocks in prose' }
|
||||
: { open: '36', close: '39', purpose: 'Inline code and code blocks in prose' },
|
||||
success: { open: '32', close: '39', purpose: 'Succeeded calls, and a diff\'s added lines' },
|
||||
warning: { open: '33', close: '39', purpose: 'Pending calls and warnings' },
|
||||
error: { open: '31', close: '39', purpose: 'Failures, signals, and a diff\'s removed lines' },
|
||||
},
|
||||
attributes: {
|
||||
bold: { open: '1', close: '22', purpose: 'Emphasis; composes with any color' },
|
||||
italic: { open: '3', close: '23', purpose: 'Reasoning text' },
|
||||
underline: { open: '4', close: '24', purpose: 'Role-header banding' },
|
||||
strike: { open: '9', close: '29', purpose: 'Struck-through Markdown' },
|
||||
selected: { open: '7', close: '27', purpose: 'Reverse video for the active selection' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text in an SGR pair, or pass it through when color is disabled.
|
||||
* An empty `open` emits nothing, so the `text` role costs no escape.
|
||||
*/
|
||||
function ansi(spec: RoleSpec, enabled: boolean): (text: string) => string {
|
||||
if (!enabled || spec.open === '') return text => text
|
||||
return text => `\x1b[${spec.open}m${text}\x1b[${spec.close}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette derived from {@link paletteSpec}. 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.
|
||||
* @param scheme - Active terminal color scheme; adjusts the code role.
|
||||
* @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),
|
||||
}
|
||||
const spec = paletteSpec(scheme)
|
||||
const roles = {} as Record<string, unknown>
|
||||
for (const name of COLOR_ROLES) roles[name] = ansi(spec.colors[name], enabled)
|
||||
for (const name of ATTRIBUTE_ROLES) roles[name] = ansi(spec.attributes[name], enabled)
|
||||
return roles as unknown as Palette
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,8 +227,8 @@ export function markdownTheme(palette: Palette): MarkdownTheme {
|
||||
// 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),
|
||||
quote: text => palette.dim(text),
|
||||
quoteBorder: text => palette.accent(text),
|
||||
hr: text => palette.dim(text),
|
||||
listBullet: text => palette.accent(text),
|
||||
bold: text => palette.bold(text),
|
||||
@@ -165,7 +247,7 @@ export function selectTheme(palette: Palette): SelectListTheme {
|
||||
return {
|
||||
selectedPrefix: palette.accent,
|
||||
selectedText: palette.accent,
|
||||
description: palette.muted,
|
||||
description: palette.dim,
|
||||
scrollInfo: palette.dim,
|
||||
noMatch: palette.warning,
|
||||
}
|
||||
@@ -182,3 +264,49 @@ export function dialogSelectTheme(palette: Palette): SelectListTheme {
|
||||
selectedText: text => palette.selected(palette.accent(text)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample text every `/palette` row renders, long enough to judge a tone against its neighbours. */
|
||||
const PALETTE_SAMPLE = 'The quick brown fox 0123'
|
||||
|
||||
/**
|
||||
* Render every palette role as a labelled sample row, each painted by the role
|
||||
* it names, so a reader compares the actual tones their terminal produces rather
|
||||
* than reading SGR numbers. Colors print first and attributes second because the
|
||||
* two groups compose in that order; every row shows its SGR pair so a mismatch
|
||||
* between the table and the screen is visible.
|
||||
*
|
||||
* @param palette - Active role palette, used to paint each sample.
|
||||
* @param scheme - Active color scheme, reported in the heading and selecting the spec.
|
||||
* @param colorEnabled - Whether ANSI is emitted; reported so an unstyled listing is not confusing.
|
||||
* @returns The rendered rows, without a trailing blank.
|
||||
*/
|
||||
export function renderPalette(
|
||||
palette: Palette,
|
||||
scheme: TerminalColorScheme,
|
||||
colorEnabled: boolean,
|
||||
): string[] {
|
||||
const spec = paletteSpec(scheme)
|
||||
const width = Math.max(...[...COLOR_ROLES, ...ATTRIBUTE_ROLES].map(name => name.length))
|
||||
// Two rows per role: the painted sample beside its name and SGR pair, then the
|
||||
// purpose indented under it. Splitting the purpose onto its own row keeps every
|
||||
// sample on one visual line at the narrow widths a side-by-side pane gives.
|
||||
const head = (name: string, role: RoleSpec, sample: string): string => {
|
||||
const pair = role.open === '' ? 'no escape' : `ESC[${role.open}m ESC[${role.close}m`
|
||||
return ` ${sample} ${palette.dim(`${name.padEnd(width)} ${pair}`)}`
|
||||
}
|
||||
const purpose = (role: RoleSpec): string => ` ${palette.dim(` ${role.purpose}`)}`
|
||||
const rows = [
|
||||
palette.bold(palette.accent('Palette')),
|
||||
palette.dim(`${scheme} scheme · color ${colorEnabled ? 'on' : 'off'}`),
|
||||
'',
|
||||
palette.dim('Colors — exactly one per span; they never nest inside each other.'),
|
||||
]
|
||||
for (const name of COLOR_ROLES) {
|
||||
rows.push(head(name, spec.colors[name], palette[name](PALETTE_SAMPLE)), purpose(spec.colors[name]))
|
||||
}
|
||||
rows.push('', palette.dim('Attributes — compose with any color, in either order.'))
|
||||
for (const name of ATTRIBUTE_ROLES) {
|
||||
rows.push(head(name, spec.attributes[name], palette[name](PALETTE_SAMPLE)), purpose(spec.attributes[name]))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
ToolResultView,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import type { FileDiff } from '@deepseek-ai/dsh-tools'
|
||||
import { renderUnknownXml } from './xml-tool-output.ts'
|
||||
import { preview, 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'
|
||||
@@ -58,9 +58,9 @@ function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
// 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.oldText).split('\n')) lines.push(palette.error(`- ${line}`))
|
||||
}
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export class HeaderComponent implements Component {
|
||||
const subtitle = this.subtitle()
|
||||
const lines = [
|
||||
title,
|
||||
...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
|
||||
...subtitle === undefined ? [] : [this.palette.dim(displayText(subtitle))],
|
||||
this.palette.dim(detail),
|
||||
]
|
||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||
@@ -147,12 +147,12 @@ function assistantMessageChildren(
|
||||
const text = displayText(textBlocks(content, 'text').trim())
|
||||
const children: Component[] = [
|
||||
new Spacer(1),
|
||||
new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0),
|
||||
new Text(messageHeader('Assistant', palette.accent, 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 }),
|
||||
new Text(palette.italic(palette.dim('Reasoning')), 0, 0),
|
||||
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }),
|
||||
)
|
||||
}
|
||||
if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }))
|
||||
@@ -301,10 +301,27 @@ export class StreamingAssistantComponent extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool card's body split at the Markdown boundary. `prelude` rows are already
|
||||
* styled and render verbatim (a terminal `$` command, its cwd, a diff's hunks);
|
||||
* `lines` is the tool's own text. A generic card renders both as one Markdown
|
||||
* document under the dim body tone.
|
||||
*/
|
||||
interface CardBody {
|
||||
readonly prelude: readonly string[]
|
||||
readonly lines: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl+O card-visibility cycle: `hidden` drops tool cards from the transcript,
|
||||
* `collapsed` previews the first body lines, `expanded` shows everything.
|
||||
*/
|
||||
export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded'
|
||||
|
||||
/** 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 visibility: ToolCardVisibility = 'collapsed'
|
||||
private callView: ToolCallView
|
||||
private resultView: ToolResultView | undefined
|
||||
|
||||
@@ -353,16 +370,19 @@ export class ToolCardComponent implements Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand or collapse the card's body preview.
|
||||
* @param expanded - Whether the full body is shown.
|
||||
* Set the card's visibility state.
|
||||
* @param visibility - Hidden, collapsed preview, or full body.
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded
|
||||
setVisibility(visibility: ToolCardVisibility): void {
|
||||
this.visibility = visibility
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Hidden renders nothing — not even the leading gap — so the transcript
|
||||
// keeps only the conversation, the way Codex hides tool calls.
|
||||
if (this.visibility === 'hidden') return []
|
||||
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.
|
||||
@@ -374,25 +394,23 @@ export class ToolCardComponent implements Component {
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(genericContent)),
|
||||
this.maxOutputLines,
|
||||
this.expanded,
|
||||
this.visibility === 'expanded',
|
||||
displayText,
|
||||
text => this.palette.muted(text),
|
||||
text => this.palette.dim(text),
|
||||
text => this.palette.dim(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
|
||||
// A generic card renders title and result as one Markdown document, so the
|
||||
// document's own block spacing is preserved, then dims every row — the whole
|
||||
// card body reads as one dim block under the status-colored header.
|
||||
const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0
|
||||
? this.dimBody(rawBody, width)
|
||||
: [...rawBody.prelude, ...rawBody.lines])
|
||||
const visibleBody = unknownXml !== undefined || this.visibility === 'expanded'
|
||||
? body
|
||||
: [
|
||||
...body.slice(0, headLines),
|
||||
this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
|
||||
...body.slice(body.length - tailLines),
|
||||
]
|
||||
: preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`))
|
||||
// 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
|
||||
@@ -409,7 +427,9 @@ export class ToolCardComponent implements Component {
|
||||
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)]
|
||||
// The blank first row is the card's own paragraph gap (no external Spacer),
|
||||
// so the hidden state removes the gap together with the card.
|
||||
const lines: string[] = ['', statusColor(header)]
|
||||
if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width))
|
||||
return lines
|
||||
}
|
||||
@@ -438,10 +458,11 @@ export class ToolCardComponent implements Component {
|
||||
return this.resultView?.title ?? this.callView.title
|
||||
}
|
||||
|
||||
private renderBody(): string[] {
|
||||
private renderBody(): CardBody {
|
||||
const view = this.resultView ?? this.callView
|
||||
if (view.card === 'terminal') {
|
||||
const pending = this.terminalPending()
|
||||
const prelude: string[] = []
|
||||
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
|
||||
@@ -452,18 +473,18 @@ export class ToolCardComponent implements Component {
|
||||
// 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 (commandInBody) prelude.push(this.palette.dim(`$ ${displayInlineText(pending.title)}`))
|
||||
if (pending?.cwd) prelude.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.output) lines.push(...this.dimOutput(this.resultView.output))
|
||||
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'))
|
||||
lines.push(...this.dimOutput(contentText(this.result.content)))
|
||||
}
|
||||
return lines.filter(Boolean)
|
||||
return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) }
|
||||
}
|
||||
if (view.card === 'diff') {
|
||||
// The header no longer names the file, so each diff keeps its own path
|
||||
@@ -477,22 +498,138 @@ export class ToolCardComponent implements Component {
|
||||
})
|
||||
const files = view.diffs.length
|
||||
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
|
||||
return [...hunks, footer]
|
||||
// A diff's own `+`/`-` colors carry its meaning, so it renders verbatim
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
}
|
||||
const content = view.content ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
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 (bodyTitle !== displayText(this.name)) prelude.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))
|
||||
// Blank-line trimming spans the whole body, so the title counts as a row:
|
||||
// interior blanks (a result's own paragraph break) survive while the body's
|
||||
// leading and trailing ones are dropped.
|
||||
const total = prelude.length + lines.length
|
||||
return {
|
||||
prelude,
|
||||
lines: lines.filter((line, index) => {
|
||||
const row = prelude.length + index
|
||||
return line.length > 0 || (row > 0 && row < total - 1)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's own output text as dim rows — the card's result-output color, which
|
||||
* separates what the tool produced from the card's own framing. A blank row
|
||||
* stays the empty string so the terminal branch's blank-row filter still reads
|
||||
* it as blank instead of as an ANSI-wrapped value.
|
||||
*/
|
||||
private dimOutput(text: string): string[] {
|
||||
return displayText(text).split('\n').map(line => line === '' ? line : this.palette.dim(line))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a generic card's prelude and result as one Markdown document under the
|
||||
* dim body tone. Rendering both together preserves the document's own block
|
||||
* spacing (Markdown's blank row before a heading); dimming every row keeps the
|
||||
* card body one uniform tone, so only the status-colored header carries color.
|
||||
*/
|
||||
private dimBody(body: CardBody, width: number): string[] {
|
||||
const rows = new Markdown([...body.prelude, ...body.lines].join('\n'), 0, 0, this.mdTheme, {
|
||||
color: value => this.palette.text(value),
|
||||
}).render(width)
|
||||
// A whitespace-only row carries no output to dim; leaving it unwrapped keeps
|
||||
// Markdown's padding out of the styled ranges.
|
||||
return rows.map(row => row.trim() === '' ? row : this.palette.dim(row))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a lone reminder-frame tag on its own line, capturing the element name.
|
||||
* Producers emit the frame as whole lines (`workspace-context`, `dsh-tool-skill`),
|
||||
* so anchoring the whole line keeps a tag mentioned inside prose from matching.
|
||||
*/
|
||||
const REMINDER_FRAME_LINE = /^<(\/?)([a-zA-Z][\w:.-]*)>$/u
|
||||
|
||||
/**
|
||||
* Drop a producer's outer reminder frame, keeping the instruction body verbatim.
|
||||
* The card header already names the source, so the frame lines carry nothing.
|
||||
* Only a matched open/close pair on the first and last lines is removed, so a
|
||||
* body that merely starts with a tag-like line is left intact.
|
||||
* @param text - Complete model-facing context text.
|
||||
* @returns The body without its outer frame lines, trimmed of the blank lines they leave.
|
||||
*/
|
||||
function stripReminderFrame(text: string): string {
|
||||
// A frame needs an open line and a distinct close line, so anything shorter than
|
||||
// two lines is already frameless.
|
||||
const [first = '', ...rest] = text.split('\n')
|
||||
const last = rest.at(-1)
|
||||
if (last === undefined) return text
|
||||
const open = REMINDER_FRAME_LINE.exec(first.trim())
|
||||
const close = REMINDER_FRAME_LINE.exec(last.trim())
|
||||
if (open?.[1] !== '' || close?.[1] !== '/' || open[2] !== close[2]) return text
|
||||
return rest.slice(0, -1).join('\n').replace(/^\n+|\n+$/gu, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected context (plugin/goal source, e.g. `workspace-context`), rendered as a
|
||||
* collapsible dim card that shares the tool-card `Ctrl+O` toggle. The header is
|
||||
* `Context · <label>`; the body is the message text as dim prose, one tone with
|
||||
* the header and the fold marker, folded to `maxOutputLines`, with a surrounding
|
||||
* reminder frame stripped because the source label already names the context.
|
||||
*
|
||||
* Injected context is prose, not markup, so this card does not parse it. The
|
||||
* `<system-reminder>` frame is a prompting convention no model is trained on
|
||||
* ([envelope rationale](../../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)),
|
||||
* and instruction bodies legitimately contain a raw `&` or angle-bracket
|
||||
* placeholders (`packages/<group>/<pkg>/`, `-t <name>`) that are prose rather than
|
||||
* elements. Tree-rendering such a payload depended on whether it happened to be
|
||||
* well-formed XML, which made both the fold and the frame-line suppression
|
||||
* content-dependent.
|
||||
*/
|
||||
export class ContextCardComponent implements Component {
|
||||
private expanded = false
|
||||
|
||||
constructor(
|
||||
private readonly label: string,
|
||||
private readonly text: string,
|
||||
private readonly maxOutputLines: number,
|
||||
private readonly palette: Palette,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Expand or collapse the card body.
|
||||
* @param expanded - Whether the full body is shown.
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const header = this.palette.dim(`Context · ${displayText(this.label)}`)
|
||||
// Emptiness is decided on the stripped text: styling a blank body would yield
|
||||
// one escape-only row, which reads as a stray blank line under the header.
|
||||
const stripped = stripReminderFrame(this.text)
|
||||
if (stripped === '') return [header]
|
||||
const body = stripped.split('\n')
|
||||
.map(line => line === '' ? line : this.palette.dim(displayText(line)))
|
||||
const visibleBody = this.expanded
|
||||
? body
|
||||
: preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`))
|
||||
return [header, ...new Text(visibleBody.join('\n'), 0, 0).render(width)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +651,7 @@ export class TodoComponent implements Component {
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.todos.length === 0) return []
|
||||
const lines = [this.palette.bold(this.palette.accent('Plan'))]
|
||||
const lines: string[] = [this.palette.bold(this.palette.accent('Plan'))]
|
||||
for (const todo of this.todos) {
|
||||
const prefix = todo.status === 'completed'
|
||||
? this.palette.success('✓')
|
||||
@@ -522,7 +659,7 @@ export class TodoComponent implements Component {
|
||||
? this.palette.warning('●')
|
||||
: this.palette.dim('○')
|
||||
const content = displayText(todo.content)
|
||||
const text = todo.status === 'completed' ? this.palette.muted(content) : content
|
||||
const text: string = todo.status === 'completed' ? this.palette.dim(content) : content
|
||||
lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
|
||||
}
|
||||
return ['', ...lines]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Conservative readable-tree rendering for model-facing text containing one XML
|
||||
* document, used by the transcript's tool and context cards.
|
||||
* document, used by the transcript's tool cards for unknown tool results. Injected
|
||||
* context is prose and is not parsed; only {@link preview} is shared with its card.
|
||||
* @module @deepseek-ai/dsh-tui/components/xml-tool-output
|
||||
*/
|
||||
|
||||
@@ -76,26 +77,42 @@ 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 textBlock(text: string, depth: number, body: (text: string) => string): string[] {
|
||||
return text.replace(/^\n|\n$/gu, '').split('\n')
|
||||
.map(line => line === '' ? line : `${' '.repeat(depth)}${body(line)}`)
|
||||
}
|
||||
|
||||
function treeLines(element: XmlElement, depth: number, label: (text: string) => string): string[] {
|
||||
function treeLines(
|
||||
element: XmlElement,
|
||||
depth: number,
|
||||
label: (text: string) => string,
|
||||
body: (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()}`]
|
||||
return [`${indent}${label(`${elementLabel(element)}:`)} ${body(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))
|
||||
if (typeof child === 'string') lines.push(...textBlock(child, depth + 1, body))
|
||||
else lines.push(...treeLines(child, depth + 1, label, body))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] {
|
||||
/**
|
||||
* Collapse `lines` to a head/tail preview around one omitted-count marker.
|
||||
* The single fold rule for every transcript card, so a card's fold never depends
|
||||
* on how its body was rendered: tool cards share it with their tree output and
|
||||
* context cards apply it to prose rows.
|
||||
* @param lines - Fully rendered body rows.
|
||||
* @param limit - Maximum retained rows, excluding the marker.
|
||||
* @param omitted - Renders the marker for the omitted row count.
|
||||
* @returns `lines` unchanged when within `limit`, else head rows, the marker, and tail rows.
|
||||
*/
|
||||
export 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
|
||||
@@ -104,13 +121,15 @@ function preview(lines: readonly string[], limit: number, omitted: (count: numbe
|
||||
|
||||
/**
|
||||
* 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 source - Raw model-facing text from an 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 body - Styles the text content under those elements; the card's body tone, so tree
|
||||
* content matches the surrounding card rows instead of falling back to the default foreground.
|
||||
* @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.
|
||||
*/
|
||||
@@ -120,12 +139,13 @@ export function renderUnknownXml(
|
||||
expanded: boolean,
|
||||
display: (text: string) => string,
|
||||
label: (text: string) => string,
|
||||
body: (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))
|
||||
typeof child === 'string' ? textBlock(child, 1, body) : treeLines(child, 1, label, body))
|
||||
const rootLine = label(elementLabel(root))
|
||||
if (expanded) return [rootLine, ...blocks.flat()]
|
||||
const previewed = blocks.map(block => preview(block, maxChildLines, omitted))
|
||||
|
||||
@@ -79,7 +79,7 @@ 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_RIGHT_PROMPT = '${queued}'
|
||||
const DEFAULT_INPUT_PROMPT = '${symbol} ${indicator}'
|
||||
const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel'
|
||||
const TuiThemeConfigSchema: z<TuiThemeConfig> = z.object({
|
||||
@@ -120,19 +120,19 @@ export interface Config extends TuiConfig {
|
||||
/** 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.
|
||||
* Skill name auto-invoked as this session's first user turn, exactly as if
|
||||
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
|
||||
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first
|
||||
* turn to the user.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
initialSkill?: 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(),
|
||||
initialSkill: z.string(),
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
|
||||
@@ -37,9 +37,7 @@ export interface TuiFocusable {
|
||||
export interface TuiTheme {
|
||||
/** Render ordinary foreground text. */
|
||||
readonly text: (value: string) => string
|
||||
/** Render secondary information. */
|
||||
readonly muted: (value: string) => string
|
||||
/** Render low-emphasis hints. */
|
||||
/** Render secondary information and low-emphasis hints, the one tone below `text`. */
|
||||
readonly dim: (value: string) => string
|
||||
/** Render the active accent role. */
|
||||
readonly accent: (value: string) => string
|
||||
|
||||
@@ -32,7 +32,6 @@ import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import { renderUnknownXml } from './components/xml-tool-output.ts'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
@@ -67,7 +66,7 @@ import type {
|
||||
TuiTheme,
|
||||
} from './extension/types.ts'
|
||||
import { displayInlineText, displayText } from './components/text.ts'
|
||||
import { createPalette, markdownTheme, selectTheme } from './components/theme.ts'
|
||||
import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
|
||||
import { contentText, parseArguments } from './components/content.ts'
|
||||
import {
|
||||
cacheHitRate,
|
||||
@@ -92,6 +91,8 @@ import {
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import {
|
||||
ContextCardComponent,
|
||||
type ToolCardVisibility,
|
||||
HeaderComponent,
|
||||
StreamingAssistantComponent,
|
||||
ToolCardComponent,
|
||||
@@ -179,9 +180,68 @@ declare module 'cordis' {
|
||||
tui: TuiExtensionService
|
||||
/** Optional process host that can replace this TUI with a resumed session. */
|
||||
tuiResumeHost: TuiResumeHost
|
||||
/** Launcher-owned `main` session identity; absent lets the app mint one. */
|
||||
mainSessionId: MainSessionIdentity | undefined
|
||||
/** Line the launcher wants printed on exit; absent prints nothing. */
|
||||
tuiGoodbyeMessage: string | undefined
|
||||
/** Skill the launcher wants auto-invoked as the fresh session's first turn; absent leaves it to the user. */
|
||||
tuiInitialSkill: string | undefined
|
||||
/** Launcher-owned session-store root the app bundle defaults to; absent keeps the bundle's project-local default. */
|
||||
launcherSessionsRoot: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Launcher-chosen identity for the app's `main` session. */
|
||||
export interface MainSessionIdentity {
|
||||
/** Exact session id `main` binds to. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Whether that session already has persisted history to load. `true` requires
|
||||
* an existing log and fails loud when absent; `false` creates it fresh.
|
||||
*/
|
||||
readonly resume: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(MAIN_SESSION_ID_KEY, identity)`) to fix the `main` agent's
|
||||
* session identity, so an app bundle mounted from a `cordis.yml` binds a
|
||||
* launcher-selected session without a config key. `ctx.provide` is the only
|
||||
* channel from launcher argv into a Loader-mounted plugin, because config
|
||||
* `!!js` expressions evaluate against the entry's context. Absent leaves the
|
||||
* choice to the app.
|
||||
*/
|
||||
export const MAIN_SESSION_ID_KEY = 'mainSessionId'
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(TUI_GOODBYE_MESSAGE_KEY, line)`) to supply the line the TUI
|
||||
* prints once the terminal is released on exit — for the shipped CLI, the
|
||||
* command that resumes this session. The launcher owns the wording because only
|
||||
* it knows how it was invoked; the TUI escapes terminal controls before
|
||||
* rendering. Absent prints nothing.
|
||||
*/
|
||||
export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user
|
||||
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade` guided-session
|
||||
* entry. The launcher sets it only when minting a fresh session, so it never
|
||||
* re-fires on a resumed one. Absent leaves the first turn to the user.
|
||||
*/
|
||||
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(SESSIONS_ROOT_KEY, root)`) to supply its session-store root as
|
||||
* the app bundle's default persistence root. Shared-store policy (one store
|
||||
* across every cwd) belongs to the launcher — the dsh CLI resolves it under the
|
||||
* Harness home — never to a plugin; a bundle without this slot keeps its own
|
||||
* project-local default, and an explicit `persistenceRoot` config still wins.
|
||||
*/
|
||||
export const SESSIONS_ROOT_KEY = 'launcherSessionsRoot'
|
||||
|
||||
/**
|
||||
* Optional terminal-local interaction service provided by one mounted TUI.
|
||||
*
|
||||
@@ -250,7 +310,6 @@ export function createTuiChat(
|
||||
const sessionId = SessionId(config.sessionId ?? 'main')
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
const sessionQuery = ctx.get('sessionQuery')
|
||||
const resolved = resolveTuiConfig(config)
|
||||
const palette = createPalette(resolved.theme.color)
|
||||
@@ -275,7 +334,9 @@ export function createTuiChat(
|
||||
editor.hintPrefix = initialInputPrompt
|
||||
const todo = new TodoComponent(palette)
|
||||
let showReasoning = resolved.showReasoning
|
||||
let toolsExpanded = false
|
||||
// Ctrl+O cycles collapsed -> expanded -> hidden. Codex-style: hidden drops
|
||||
// tool cards entirely, collapsed previews, expanded shows full bodies.
|
||||
let toolsVisibility: ToolCardVisibility = 'collapsed'
|
||||
let streaming: StreamingAssistantComponent | undefined
|
||||
let completedStreaming: StreamingAssistantComponent | undefined
|
||||
let runningStatus: RunningStatus | undefined
|
||||
@@ -299,6 +360,7 @@ export function createTuiChat(
|
||||
const tokens = sessionTokens(agent.session)
|
||||
const toolCards = new Map<string, ToolCardComponent>()
|
||||
const allToolCards = new Set<ToolCardComponent>()
|
||||
const contextCards = new Set<ContextCardComponent>()
|
||||
const liveErrors = new Set<string>()
|
||||
const commandControllers = new Set<AbortController>()
|
||||
const referenceControllers = new Set<AbortController>()
|
||||
@@ -327,33 +389,33 @@ export function createTuiChat(
|
||||
const branch = runtime.gitBranch?.(cwd) ?? gitBranch(cwd)
|
||||
const promptValues: TuiPromptValueHandle[] = [
|
||||
ctx.tuiPrompt.register('cwd', palette.bold(palette.accent(formattedCwd))),
|
||||
ctx.tuiPrompt.register('git/worktree', branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`)),
|
||||
ctx.tuiPrompt.register('git/worktree', branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`)),
|
||||
ctx.tuiPrompt.register('token_meter/cache_hit_rate'),
|
||||
ctx.tuiPrompt.register('model'),
|
||||
ctx.tuiPrompt.register('context'),
|
||||
ctx.tuiPrompt.register('timing'),
|
||||
ctx.tuiPrompt.register('queued'),
|
||||
ctx.tuiPrompt.register('symbol', palette.bold(palette.accent('dsh'))),
|
||||
ctx.tuiPrompt.register('indicator', palette.muted('> ')),
|
||||
ctx.tuiPrompt.register('indicator', palette.dim('> ')),
|
||||
]
|
||||
const [cwdValue, gitValue, tokenValue, modelValue, contextValue, timingValue, symbolValue, indicatorValue] = promptValues
|
||||
const [cwdValue, gitValue, tokenValue, modelValue, contextValue, queuedValue, symbolValue, indicatorValue] = promptValues
|
||||
/* v8 ignore next -- the fixed built-in registration list always supplies each handle. */
|
||||
if (cwdValue === undefined || gitValue === undefined || tokenValue === undefined || modelValue === undefined
|
||||
|| contextValue === undefined || timingValue === undefined || symbolValue === undefined || indicatorValue === undefined) {
|
||||
|| contextValue === undefined || queuedValue === undefined || symbolValue === undefined || indicatorValue === undefined) {
|
||||
throw new Error('TUI prompt built-ins failed to initialize')
|
||||
}
|
||||
const updatePromptValues = (): void => {
|
||||
cwdValue.set(palette.bold(palette.accent(formattedCwd)))
|
||||
gitValue.set(branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`))
|
||||
gitValue.set(branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`))
|
||||
const rate = cacheHitRate(tokens)
|
||||
const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}`
|
||||
modelValue.set(` ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`)
|
||||
tokenValue.set(` ${palette.muted(rate === undefined ? usage : `${usage} cache ${rate}%`)}`)
|
||||
modelValue.set(` ${palette.dim(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`)
|
||||
tokenValue.set(` ${palette.dim(rate === undefined ? usage : `${usage} cache ${rate}%`)}`)
|
||||
const contextWindow = modelController.contextWindow()
|
||||
contextValue.set(contextWindow === undefined ? undefined : ` ${palette.muted(
|
||||
contextValue.set(contextWindow === undefined ? undefined : ` ${palette.dim(
|
||||
`${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`,
|
||||
)}`)
|
||||
const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size)
|
||||
timingValue.set(queued === undefined ? undefined : palette.dim(queued))
|
||||
queuedValue.set(queued === undefined ? undefined : palette.dim(queued))
|
||||
symbolValue.set(palette.bold(palette.accent('dsh')))
|
||||
// `${indicator}` owns the caret column and its trailing gap before the
|
||||
// cursor. The phase glyph replaces the `>` caret in place — same width
|
||||
@@ -374,7 +436,7 @@ export function createTuiChat(
|
||||
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) }
|
||||
: undefined
|
||||
const caret = envelope === undefined
|
||||
? palette.muted('>')
|
||||
? palette.dim('>')
|
||||
: fadeGlyph(
|
||||
envelope.glyph,
|
||||
palette,
|
||||
@@ -383,7 +445,7 @@ export function createTuiChat(
|
||||
envelope.level * pulseLevel(now()),
|
||||
envelope.level >= 0.5,
|
||||
)
|
||||
indicatorValue.set(`${caret}${palette.muted(' ')}`)
|
||||
indicatorValue.set(`${caret}${palette.dim(' ')}`)
|
||||
}
|
||||
const promptContext = new PromptContextComponent(
|
||||
parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)),
|
||||
@@ -420,7 +482,7 @@ export function createTuiChat(
|
||||
const disposePromptChanges = ctx.tuiPrompt.subscribe(requestRender)
|
||||
|
||||
const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => {
|
||||
const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted
|
||||
const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.dim
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(color(displayText(message)), 0, 0))
|
||||
requestRender()
|
||||
@@ -428,7 +490,6 @@ export function createTuiChat(
|
||||
|
||||
const extensionTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => palette.text(value),
|
||||
muted: (value: string) => palette.muted(value),
|
||||
dim: (value: string) => palette.dim(value),
|
||||
accent: (value: string) => palette.accent(value),
|
||||
success: (value: string) => palette.success(value),
|
||||
@@ -551,7 +612,7 @@ export function createTuiChat(
|
||||
palette,
|
||||
mdTheme,
|
||||
)
|
||||
card.setExpanded(toolsExpanded)
|
||||
card.setVisibility(toolsVisibility)
|
||||
toolCards.set(event.data.callId, card)
|
||||
allToolCards.add(card)
|
||||
return card
|
||||
@@ -630,22 +691,18 @@ export function createTuiChat(
|
||||
/* v8 ignore next -- context events with empty content are rejected by their owning producers. */
|
||||
if (text) {
|
||||
// The tui type view lacks plugin-augmented source kinds (e.g. goal),
|
||||
// so read the display label without narrowing on `kind`.
|
||||
const labelled = source as { kind: string; plugin?: string }
|
||||
/* v8 ignore next -- current plugin-augmented context sources always carry their display label. */
|
||||
const label = labelled.plugin ?? labelled.kind
|
||||
const xml = renderUnknownXml(
|
||||
text,
|
||||
resolved.maxToolOutputLines,
|
||||
true,
|
||||
displayText,
|
||||
value => palette.muted(value),
|
||||
/* v8 ignore next -- expanded context XML never asks renderUnknownXml for a collapsed summary. */
|
||||
() => '',
|
||||
)
|
||||
// so read the display label without narrowing on `kind`. The session
|
||||
// log is a durable/replay boundary: a corrupt or foreign injected
|
||||
// source may not match the typed shape, so fall back to `context`.
|
||||
const labelled = source as { kind?: unknown; plugin?: unknown }
|
||||
const label = typeof labelled.plugin === 'string' ? labelled.plugin
|
||||
: typeof labelled.kind === 'string' ? labelled.kind
|
||||
: 'context'
|
||||
const card = new ContextCardComponent(label, text, resolved.maxToolOutputLines, palette)
|
||||
card.setExpanded(toolsVisibility === 'expanded')
|
||||
contextCards.add(card)
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 0, 0))
|
||||
chat.addChild(new Text(xml?.join('\n') ?? palette.muted(displayText(text)), 0, 0))
|
||||
chat.addChild(card)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -685,8 +742,9 @@ export function createTuiChat(
|
||||
)
|
||||
break
|
||||
}
|
||||
// No external Spacer for tool cards: the card renders its own leading
|
||||
// gap, so the hidden state removes the row and the gap together.
|
||||
case 'tool/call':
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(parsedTool(event))
|
||||
trailStreamingTiming()
|
||||
break
|
||||
@@ -695,7 +753,7 @@ export function createTuiChat(
|
||||
let card = toolCards.get(callId)
|
||||
if (card === undefined) {
|
||||
card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme)
|
||||
chat.addChild(new Spacer(1))
|
||||
card.setVisibility(toolsVisibility)
|
||||
chat.addChild(card)
|
||||
allToolCards.add(card)
|
||||
}
|
||||
@@ -766,6 +824,7 @@ export function createTuiChat(
|
||||
chat.clear()
|
||||
toolCards.clear()
|
||||
allToolCards.clear()
|
||||
contextCards.clear()
|
||||
streaming = undefined
|
||||
todo.update([])
|
||||
const active = activeSurfaceSeqs(agent.session)
|
||||
@@ -794,12 +853,10 @@ export function createTuiChat(
|
||||
const resume = createResumeController({
|
||||
ctx,
|
||||
agent,
|
||||
config,
|
||||
runtime,
|
||||
resolved,
|
||||
palette,
|
||||
overlayManager,
|
||||
persistence,
|
||||
sessionQuery,
|
||||
ui,
|
||||
editor,
|
||||
@@ -828,9 +885,8 @@ export function createTuiChat(
|
||||
await runtime.terminal.drainInput(100, 20)
|
||||
ui.stop()
|
||||
if (exitProcess) {
|
||||
const command = await resume.currentResumeCommand()
|
||||
if (command !== undefined) {
|
||||
runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`)
|
||||
if (runtime.goodbyeMessage !== undefined) {
|
||||
runtime.terminal.write(`${palette.dim(displayText(runtime.goodbyeMessage))}\n`)
|
||||
}
|
||||
runtime.exit(0)
|
||||
}
|
||||
@@ -874,9 +930,15 @@ export function createTuiChat(
|
||||
ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
|
||||
|
||||
const toggleTools = (): void => {
|
||||
toolsExpanded = !toolsExpanded
|
||||
for (const card of allToolCards) card.setExpanded(toolsExpanded)
|
||||
appendNotice(`Tool cards ${toolsExpanded ? 'expanded' : 'collapsed'}.`)
|
||||
// The cycle order puts the two common reading modes adjacent: preview ->
|
||||
// full detail -> conversation-only, then back to the preview default.
|
||||
toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded'
|
||||
: toolsVisibility === 'expanded' ? 'hidden' : 'collapsed'
|
||||
for (const card of allToolCards) card.setVisibility(toolsVisibility)
|
||||
// Context cards carry injected instructions rather than tool traffic, so
|
||||
// they never hide: the hidden phase reads as their collapsed preview.
|
||||
for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded')
|
||||
appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`)
|
||||
}
|
||||
|
||||
const toggleReasoning = (): void => {
|
||||
@@ -902,12 +964,20 @@ export function createTuiChat(
|
||||
chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 0, 0))
|
||||
chat.addChild(new Text([
|
||||
'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
|
||||
'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning',
|
||||
'Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L redraw',
|
||||
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
|
||||
'',
|
||||
...commandLines,
|
||||
'/skill:<name> [instructions] — load a skill into the conversation',
|
||||
].map(line => palette.muted(line)).join('\n'), 0, 0))
|
||||
].map(line => palette.dim(line)).join('\n'), 0, 0))
|
||||
requestRender()
|
||||
}
|
||||
|
||||
const showPalette = (): void => {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(
|
||||
renderPalette(palette, currentScheme, resolved.theme.color).join('\n'), 0, 0,
|
||||
))
|
||||
requestRender()
|
||||
}
|
||||
|
||||
@@ -1053,19 +1123,9 @@ export function createTuiChat(
|
||||
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'reasoning',
|
||||
description: 'Toggle reasoning blocks',
|
||||
handler: () => { toggleReasoning(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'tools',
|
||||
description: 'Expand or collapse all tool cards',
|
||||
handler: () => { toggleTools(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'redraw',
|
||||
description: 'Invalidate components and redraw the terminal',
|
||||
handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
|
||||
name: 'palette',
|
||||
description: 'Show every color and attribute role this terminal renders',
|
||||
handler: () => { showPalette(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'reload',
|
||||
@@ -1511,6 +1571,13 @@ export function createTuiChat(
|
||||
})
|
||||
startBannerReveal()
|
||||
|
||||
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`): invoke the
|
||||
// named skill exactly as a typed `/skill:<name>` would, once the chat is live
|
||||
// and the agent is idle. The launcher sets this only for a fresh session, so
|
||||
// there is no prior turn to collide with; invokeSkill reports an unknown skill
|
||||
// as a notice.
|
||||
if (config.initialSkill !== undefined) invokeSkill(config.initialSkill, '')
|
||||
|
||||
return {
|
||||
async dispose(): Promise<void> {
|
||||
detachListeners()
|
||||
@@ -1574,10 +1641,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// boundary from COLORTERM; an explicit theme value still wins.
|
||||
const truecolor = config.theme?.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '')
|
||||
const resumeHost = ctx.get('tuiResumeHost')
|
||||
mountTui(ctx, Object.assign({}, config, { theme: Object.assign({}, config.theme, { truecolor }) }), {
|
||||
const goodbyeMessage = ctx.get('tuiGoodbyeMessage')
|
||||
// The launcher seeds a guided fresh session's first turn through this key; a
|
||||
// config value still wins. Consumed in createTuiChat via config.initialSkill.
|
||||
const initialSkill = config.initialSkill ?? ctx.get('tuiInitialSkill')
|
||||
mountTui(ctx, Object.assign(
|
||||
{},
|
||||
config,
|
||||
{ theme: Object.assign({}, config.theme, { truecolor }) },
|
||||
initialSkill === undefined ? {} : { initialSkill },
|
||||
), {
|
||||
terminal: new ProcessTerminal(),
|
||||
exit: code => process.exit(code),
|
||||
...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) },
|
||||
...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) },
|
||||
...goodbyeMessage === undefined ? {} : { goodbyeMessage },
|
||||
})
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -12,12 +12,17 @@ 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.
|
||||
* Dispose the current app and replace it with a runtime for `sessionId` in
|
||||
* `cwd`. 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.
|
||||
* @param cwd - the selected session's own workspace, which the replacement
|
||||
* process must run in: process cwd, not the restored session header, is what
|
||||
* filesystem and shell tools resolve against. It may differ from the current
|
||||
* workspace, so a host that cannot enter it must reject before committing
|
||||
* teardown.
|
||||
*/
|
||||
handoff(sessionId: SessionId): Promise<never>
|
||||
handoff(sessionId: SessionId, cwd: string): Promise<never>
|
||||
}
|
||||
|
||||
/** Runtime boundary used by the interactive TUI. */
|
||||
@@ -40,6 +45,13 @@ export interface TuiRuntime {
|
||||
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. */
|
||||
/** Host-owned process handoff; absent leaves the session selectable but not resumable in place. */
|
||||
handoffResume?: TuiResumeHost['handoff']
|
||||
/**
|
||||
* Line the host wants printed once the terminal is released on exit, such as
|
||||
* the command that resumes this session. Absent prints nothing. The host owns
|
||||
* the wording; the TUI owns rendering and escapes terminal controls, so
|
||||
* embedded ANSI is shown literally rather than applied.
|
||||
*/
|
||||
goodbyeMessage?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user