Merge remote-tracking branch 'origin/master' into pr/tui-details-fold

# Conflicts:
#	docs/cordis-catalog/services.md
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/README.md
#	packages/ui/tui/README.zh.md
#	packages/ui/tui/src/index.ts
This commit is contained in:
Turtle
2026-08-03 15:55:22 +08:00
1329 changed files with 57136 additions and 10085 deletions

View File

@@ -1,8 +1,8 @@
/**
* Per-step timing model and running-status glyph animation for the terminal
* Per-step timing model and prompt-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.
* active glyph fades in when work starts, throbs while work runs, and fades out
* when it ends.
* @module @deepseek-ai/dsh-tui/chat/timing
*/
@@ -10,25 +10,25 @@ 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;
* Render cadence of the status prompt while active, and while the glyph fades
* out after work 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:
* Milliseconds over which the status glyph fades in when work starts and fades
* out after it ends. The fade is an envelope over the active 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. */
/** Milliseconds for one full brightness throb of the active status glyph. */
export const STATUS_PULSE_PERIOD_MS = 1400
/**
* Brightness floor of the running throb, as a fraction of the settled gray. At
* Brightness floor of the status 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.
@@ -36,7 +36,7 @@ export const STATUS_PULSE_PERIOD_MS = 1400
export const STATUS_PULSE_FLOOR = 0
/**
* Muted-gray foreground the truecolor running glyph fades through, from the
* Muted-gray foreground the truecolor status 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
@@ -185,6 +185,9 @@ export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
tools: '⚙',
}
/** Status glyph for a live standalone compaction bracket. */
const COMPACTING_GLYPH = '⊙'
/**
* 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
@@ -219,25 +222,32 @@ export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | u
}
/**
* 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}).
* The active status glyph, or `undefined` when idle. A running turn takes
* precedence over standalone compaction and falls back to the pre-first-token
* wait when no step is open. The caller applies the shared fade and throb
* animation (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.
* @param compacting - Whether a live standalone compaction bracket is open.
* @returns The active status 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]
export function runningPhaseGlyph(
events: readonly SessionEvent[],
running: boolean,
compacting: boolean,
): string | undefined {
if (running) {
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
}
return compacting ? COMPACTING_GLYPH : undefined
}
/**
* The running throb's brightness at continuous clock `nowMs`: a cosine between
* The status 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.
* fade envelope, which alone drives appear/disappear at work boundaries.
*
* @param nowMs - Monotonic render clock in milliseconds.
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
@@ -249,14 +259,14 @@ export function pulseLevel(nowMs: number): number {
}
/**
* One frame of the running glyph at fade `opacity` (0 = near-background trough
* One frame of the status 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
* and the status 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
@@ -264,7 +274,7 @@ export function pulseLevel(nowMs: number): number {
* 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 glyph - The status 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.

View File

@@ -46,6 +46,8 @@ export type AttributeRole = <T extends string>(text: T) => T
*/
export interface Palette {
accent: ColorRole
/** DeepSeek brand ink; exact gradient callers may override it on truecolor terminals. */
brand: 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. */
@@ -63,7 +65,7 @@ export interface Palette {
}
/** 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
export const COLOR_ROLES = ['text', 'dim', 'accent', 'brand', '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
@@ -86,8 +88,9 @@ export interface RoleSpec {
*
* 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}).
* a fixed 24-bit color would not. The startup gradient and exact official mark
* color are the two deliberate brand exceptions ({@link gradientText},
* {@link brandText}).
*
* @param scheme - Active terminal color scheme; only `code` differs between them.
* @returns The SGR spec for every color and attribute role.
@@ -109,6 +112,7 @@ export function paletteSpec(scheme: TerminalColorScheme): {
// 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' },
brand: { open: '34', close: '39', purpose: 'DeepSeek brand art when truecolor is unavailable' },
// 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'
@@ -168,6 +172,19 @@ const BRAND_GRADIENT = [
[36, 152, 255], // #2498FF
] as const
/** Official DeepSeek icon ink from the shipped 24x24 SVG. */
const DEEPSEEK_BRAND_RGB = BRAND_GRADIENT[0]
/**
* Paint trusted static DeepSeek brand art with the official `#4D6BFE` ink.
* @param text - Static brand text or raster cells.
* @returns text wrapped in the official truecolor foreground and a foreground reset.
*/
export function brandText(text: string): string {
const [r, g, b] = DEEPSEEK_BRAND_RGB
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`
}
/**
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
* interpolation across its stops.
@@ -199,13 +216,12 @@ function brandColorAt(t: number): readonly [number, number, number] {
* @returns `text` wrapped in truecolor SGR foreground codes.
*/
export function gradientText(text: string): string {
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
// samples exactly one color per visible letter.
const last = Math.max(1, text.length - 1)
const glyphs = Array.from(text)
const last = Math.max(1, glyphs.length - 1)
let painted = ''
for (let index = 0; index < text.length; index += 1) {
for (let index = 0; index < glyphs.length; index += 1) {
const [r, g, b] = brandColorAt(index / last)
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
painted += `\x1b[38;2;${r};${g};${b}m${glyphs[index]}`
}
return `${painted}\x1b[39m`
}

View File

@@ -126,8 +126,8 @@ export interface Config extends TuiConfig {
/**
* 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.
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent
* leaves the first turn to the user.
*/
initialSkill?: string
}

View File

@@ -37,6 +37,8 @@ export interface TuiFocusable {
export interface TuiTheme {
/** Render ordinary foreground text. */
readonly text: (value: string) => string
/** Render trusted static brand art with the host's configured brand treatment. */
readonly brand: (value: string) => string
/** Render secondary information and low-emphasis hints, the one tone below `text`. */
readonly dim: (value: string) => string
/** Render the active accent role. */

View File

@@ -69,7 +69,7 @@ import type {
TuiTheme,
} from './extension/types.ts'
import { displayInlineText, displayText } from './components/text.ts'
import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
import { brandText, createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
import { contentText, parseArguments } from './components/content.ts'
import {
cacheHitRate,
@@ -80,6 +80,7 @@ import {
import {
fadeGlyph,
formatQueuedStatus,
formatStatusDuration,
openStepPhase,
openTurn,
pulseLevel,
@@ -228,9 +229,9 @@ 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.
* 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'
@@ -331,6 +332,7 @@ export function createTuiChat(
})
editor.hintPrefix = initialInputPrompt
const todo = new TodoComponent(palette)
const compactionStatusLine = new Text('', 0, 0)
let showReasoning = resolved.showReasoning
// Ctrl+O cycles collapsed -> expanded -> hidden. Codex-style: hidden drops
// tool cards entirely, collapsed previews, expanded shows full bodies.
@@ -343,6 +345,14 @@ export function createTuiChat(
const assistantSteps = new Map<number, StreamingAssistantComponent[]>()
let runningStatus: RunningStatus | undefined
let fadingStatus: FadingStatus | undefined
/**
* Live standalone compaction observed by this process. Never derive this
* state from history: a resumed log may contain a stale orphaned start.
*/
let compacting: {
startedAt: number
timer: ReturnType<typeof setInterval>
} | undefined
// TUI steering submissions that the inbox has not yet claimed or discarded.
// Correlation ids avoid guessing whether a running-state submission actually
// joined steering or fell back to the queued-turn FIFO during turn close.
@@ -406,6 +416,7 @@ export function createTuiChat(
throw new Error('TUI prompt built-ins failed to initialize')
}
const updatePromptValues = (): void => {
const renderTime = now()
cwdValue.set(palette.bold(palette.accent(formattedCwd)))
gitValue.set(branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`))
const rate = cacheHitRate(tokens)
@@ -419,23 +430,31 @@ export function createTuiChat(
const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size)
queuedValue.set(queued === undefined ? undefined : palette.dim(queued))
symbolValue.set(palette.bold(palette.accent('dsh')))
compactionStatusLine.setText(compacting === undefined
? ''
: palette.dim(`Context being compacted ${formatStatusDuration(renderTime - compacting.startedAt)}`))
// `${indicator}` owns the caret column and its trailing gap before the
// cursor. The phase glyph replaces the `>` caret in place — same width
// every frame — fading in as a turn starts, throbbing while it runs, and
// fading out after it ends before the plain `>` returns. Only the gray
// cursor. The active status glyph replaces the `>` caret in place — same
// width every frame — fading in when work starts, throbbing while it runs,
// and fading out after it ends before the plain `>` returns. Only the gray
// brightness changes, so the cursor never shifts.
const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined)
const statusGlyph = runningPhaseGlyph(
agent.session.events,
runningStatus !== undefined,
compacting !== undefined,
)
// Remember the live phase glyph so the fade-out shows it, not the ttft
// fallback the derivation returns once the closing turn's step has ended.
if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph
// The fade envelope gates appear/disappear; the running throb breathes the
// glyph the whole turn. Truecolor opacity is envelope × throb; the
if (runningStatus !== undefined && statusGlyph !== undefined) runningStatus.lastGlyph = statusGlyph
// The fade envelope gates appear/disappear; the active throb breathes the
// glyph throughout the operation. Truecolor opacity is envelope × throb; the
// non-truecolor fallback keys visibility off the envelope alone, so the
// throb never blinks it. `envelope` clamps to [0, 1].
const envelope = runningStatus !== undefined && runningGlyph !== undefined
? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) }
const activeSince = runningStatus?.startedAt ?? compacting?.startedAt
const envelope = activeSince !== undefined && statusGlyph !== undefined
? { glyph: statusGlyph, level: Math.min(1, (renderTime - activeSince) / STATUS_FADE_MS) }
: fadingStatus !== undefined
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) }
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (renderTime - fadingStatus.endedAt) / STATUS_FADE_MS) }
: undefined
const caret = envelope === undefined
? palette.dim('>')
@@ -444,7 +463,7 @@ export function createTuiChat(
palette,
resolved.theme.color,
resolved.theme.color && resolved.theme.truecolor,
envelope.level * pulseLevel(now()),
envelope.level * pulseLevel(renderTime),
envelope.level >= 0.5,
)
indicatorValue.set(`${caret}${palette.dim(' ')}`)
@@ -459,6 +478,7 @@ export function createTuiChat(
ui.addChild(new Spacer(1))
todoContainer.addChild(todo)
ui.addChild(todoContainer)
ui.addChild(compactionStatusLine)
ui.addChild(promptContext)
ui.addChild(editor)
ui.setFocus(editor)
@@ -492,6 +512,9 @@ export function createTuiChat(
const extensionTheme: TuiTheme = Object.freeze({
text: (value: string) => palette.text(value),
brand: (value: string) => resolved.theme.color
? resolved.theme.truecolor ? brandText(value) : palette.brand(value)
: value,
dim: (value: string) => palette.dim(value),
accent: (value: string) => palette.accent(value),
success: (value: string) => palette.success(value),
@@ -543,8 +566,8 @@ export function createTuiChat(
requestRender()
}
/** Stop the running and fade-out timers and drop both states at once. */
const clearStatus = (): void => {
/** Stop the turn-phase running and fade-out timers and drop both states. */
const clearTurnStatus = (): void => {
if (runningStatus !== undefined) {
clearInterval(runningStatus.timer)
runningStatus = undefined
@@ -553,21 +576,30 @@ export function createTuiChat(
clearInterval(fadingStatus.timer)
fadingStatus = undefined
}
runtime.terminal.setProgress(false)
runtime.terminal.setProgress(compacting !== undefined)
}
/** Hard clear: drop every indicator, including a live compaction bracket. */
const clearStatus = (): void => {
if (compacting !== undefined) {
clearInterval(compacting.timer)
compacting = undefined
}
clearTurnStatus()
}
/**
* On the running → non-running edge, hand the last rendered glyph to a
* fade-out that re-renders until it settles on the `>` caret, then stops its
* own timer. A hard clear (teardown) skips this via {@link clearStatus}.
* Hand the last active glyph to a fade-out that re-renders until it settles
* on the `>` caret, then stops its own timer. A hard clear (teardown) skips
* this via {@link clearStatus}.
*/
const beginFadeOut = (glyph: string): void => {
clearStatus()
clearTurnStatus()
const fading: FadingStatus = {
glyph,
endedAt: now(),
timer: setInterval(() => {
if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus()
if (now() - fading.endedAt >= STATUS_FADE_MS) clearTurnStatus()
renderStatus()
}, STATUS_ANIMATION_INTERVAL_MS),
}
@@ -577,9 +609,9 @@ export function createTuiChat(
const setStatus = (status: AgentStatus): void => {
const priorTurn = runningStatus?.turn
const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined
if (status === 'running') clearStatus()
if (status === 'running') clearTurnStatus()
else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
else clearStatus()
else clearTurnStatus()
editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined
if (status === 'running') {
@@ -1626,6 +1658,32 @@ export function createTuiChat(
if (event.type === 'tool/result') fileSearch.invalidate()
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
// Track live standalone compaction state.
if (event.type === 'compact/start' && event.data.turn === null) {
if (compacting === undefined) {
const startedAt = now()
compacting = {
startedAt,
timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS),
}
runtime.terminal.setProgress(true)
}
requestRender()
return
}
if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) {
const fadeOutGlyph = runningPhaseGlyph(agent.session.events, false, true)
clearInterval(compacting.timer)
compacting = undefined
if (event.data.error !== undefined) {
appendNotice(`Compaction failed: ${event.data.error}`, 'warning')
}
// A concurrently running turn owns the indicator. Keep its timer and
// progress bit instead of letting the compaction fade clear that state.
if (runningStatus === undefined && fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
requestRender()
return
}
// A replacement mutates only the model surface, so the rendered transcript
// keeps what it already showed; a landed summary checkpoint adds its marker.
if (isReplacementSurfaceEvent(event)) {
@@ -1669,6 +1727,9 @@ export function createTuiChat(
// TUI stays mounted. Retained agents accept deliveries after detachment, so
// without this a later send would drive a zombie agent/session; mark
// disposed so dispatchMessage reports it instead.
// The hard clear also retires live compaction. A later compact/end is
// intentionally presentation-silent: this disposal notice owns the
// terminal outcome, and no animation may survive agent detachment.
clearStatus()
appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
disposed = true
@@ -1757,11 +1818,11 @@ 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.
// 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 {