Files
deepseek-harness/packages/ui/tui/src/chat/timing.ts

348 lines
13 KiB
TypeScript

/**
* Per-step timing model and running-status glyph animation for the terminal
* front door. Timing buckets are replayed from the session event stream; the
* running glyph fades in on turn start, throbs while the turn runs, and fades
* out on turn end.
* @module @deepseek-ai/dsh-tui/chat/timing
*/
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Palette } from '../components/theme.ts'
/**
* Render cadence of the running prompt while active, and while the glyph fades
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
* changed terminal cells are re-emitted, so the faster tick stays cheap.
*/
export const STATUS_ANIMATION_INTERVAL_MS = 50
/**
* Milliseconds over which the running glyph fades in when a turn starts and
* fades out after it ends. The fade is an envelope over the running pulse:
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
*/
export const STATUS_FADE_MS = 300
/** Milliseconds for one full brightness throb of the running glyph. */
export const STATUS_PULSE_PERIOD_MS = 1400
/**
* Brightness floor of the running throb, as a fraction of the settled gray. At
* 0 the pulse swells from the near-background trough up to full and back. The
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
* cosine breathes symmetrically bold→dim→bold.
*/
export const STATUS_PULSE_FLOOR = 0
/**
* Muted-gray foreground the truecolor running glyph fades through, from the
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
* appearing rather than a colored indicator. Foreground-only, matching the
* brand gradient, so it stays legible on any terminal background.
*/
const STATUS_FADE_GRAY = {
trough: [43, 43, 43],
settled: [136, 136, 136],
} as const
/** The active phase of a running step, one bucket of accumulated wall time. */
export type TimingBucket = 'ttft' | 'thinking' | 'responding' | 'tools'
/** Turn/step coordinates of one assistant step. */
export type StepPosition = { turn: number; step: number }
/** Accumulated wall time per phase for one step or session slice. */
export interface TimingTotals {
ttft: number
thinking: number
responding: number
tools: number
}
interface TimingState {
totals: TimingTotals
active: { bucket: TimingBucket; since: number } | undefined
}
const TIMING_BUCKET_LABELS: Record<TimingBucket, string> = {
ttft: 'Model wait',
thinking: 'Thinking',
responding: 'Response',
tools: 'Tools',
}
const TIMING_BUCKETS: readonly TimingBucket[] = ['ttft', 'thinking', 'responding', 'tools']
function emptyTimingTotals(): TimingTotals {
return { ttft: 0, thinking: 0, responding: 0, tools: 0 }
}
function timingState(startedAt?: number): TimingState {
return {
totals: emptyTimingTotals(),
/* v8 ignore next -- production timing state always begins at a logged step timestamp. */
active: startedAt === undefined ? undefined : { bucket: 'ttft', since: startedAt },
}
}
function sameStep(event: SessionEvent, position: StepPosition): boolean {
return typeof event.data === 'object'
&& 'turn' in event.data && 'step' in event.data
&& event.data.turn === position.turn && event.data.step === position.step
}
function closeTimingBucket(state: TimingState, at: number): void {
if (state.active === undefined) return
state.totals[state.active.bucket] += Math.max(0, at - state.active.since)
state.active = undefined
}
function enterTimingBucket(state: TimingState, bucket: TimingBucket | undefined, at: number): void {
if (state.active?.bucket === bucket) return
closeTimingBucket(state, at)
if (bucket !== undefined) state.active = { bucket, since: at }
}
function advanceStepTiming(
state: TimingState,
event: Extract<SessionEvent, { type: 'assistant/chunk' | 'tool/call' | 'step/end' }>,
): void {
if (event.type === 'assistant/chunk') {
const chunk = event.data.chunk
if (state.active?.bucket === 'ttft') enterTimingBucket(state, undefined, event.time)
if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) {
enterTimingBucket(state, 'thinking', event.time)
} else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) {
enterTimingBucket(state, 'responding', event.time)
}
} else if (event.type === 'tool/call') {
enterTimingBucket(state, 'tools', event.time)
} else {
closeTimingBucket(state, event.time)
}
}
function timingTotalsAt(state: TimingState, at?: number): TimingTotals {
const totals = { ...state.totals }
if (state.active !== undefined && at !== undefined) {
totals[state.active.bucket] += Math.max(0, at - state.active.since)
}
return totals
}
/**
* Replay one step's accumulated per-phase timing up to clock `at`.
* @param events - Session events to replay.
* @param position - Turn/step coordinates of the step.
* @param at - Render clock to accumulate the open bucket up to.
* @returns The step's per-phase totals.
*/
export function stepTimingAt(
events: readonly SessionEvent[],
position: StepPosition,
at: number,
): TimingTotals {
const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position))
if (startIndex < 0) return emptyTimingTotals()
const start = events[startIndex] as Extract<SessionEvent, { type: 'step/start' }>
const state = timingState(start.time)
for (let index = startIndex + 1; index < events.length; index += 1) {
const event = events[index] as SessionEvent
if (event.time > at) break
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
&& sameStep(event, position)) {
advanceStepTiming(state, event)
if (event.type === 'step/end') break
}
}
return timingTotalsAt(state, at)
}
/**
* The turn index of the currently open turn, or `undefined` when none is open.
* @param events - Session events to scan from the tail.
* @returns The open turn index, or `undefined`.
*/
export function openTurn(events: readonly SessionEvent[]): number | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'turn/end') return undefined
if (event.type === 'turn/start') return event.data.turn
}
return undefined
}
/**
* Phase-specific status glyph, keyed by the running step's active timing bucket.
* `ttft` is the pre-first-token wait a running turn falls back to between steps.
*/
export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
ttft: '◍',
thinking: '✻',
responding: '●',
tools: '⚙',
}
/**
* Derive the currently open step's active timing bucket, or `undefined` when no
* step is open. The open step is the last `step/start` with no later matching
* `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}.
* @param events - Session events to scan.
* @returns The open step's active bucket, or `undefined`.
*/
export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | undefined {
let startIndex = -1
let start: Extract<SessionEvent, { type: 'step/start' }> | undefined
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'step/end') return undefined
if (event.type === 'step/start') {
startIndex = index
start = event
break
}
if (event.type === 'turn/end') return undefined
}
if (start === undefined) return undefined
const position = start.data
const state = timingState(start.time)
for (let index = startIndex + 1; index < events.length; index += 1) {
const event = events[index] as SessionEvent
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
&& sameStep(event, position)) {
advanceStepTiming(state, event)
}
}
return state.active?.bucket
}
/**
* The running agent's phase glyph, or `undefined` when idle. A running turn
* with no open step falls back to the pre-first-token wait so a glyph is always
* available while the agent works; it fades in on turn start, throbs while the
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
* @param events - Session events to derive the phase from.
* @param running - Whether the agent is currently running.
* @returns The phase glyph, or `undefined` when idle.
*/
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
if (!running) return undefined
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
}
/**
* The running throb's brightness at continuous clock `nowMs`: a cosine between
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
* fade envelope, which alone drives appear/disappear at turn boundaries.
*
* @param nowMs - Monotonic render clock in milliseconds.
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
*/
export function pulseLevel(nowMs: number): number {
const phase = (nowMs % STATUS_PULSE_PERIOD_MS) / STATUS_PULSE_PERIOD_MS
const wave = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase)
return STATUS_PULSE_FLOOR + (1 - STATUS_PULSE_FLOOR) * wave
}
/**
* One frame of the running glyph at fade `opacity` (0 = near-background trough
* gray, 1 = settled dim gray). The character and its width never change — only
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
* the caret dimly breathing, never a colored indicator.
*
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
* and the running throb render as a smooth, symmetric brightness swing with no
* hard cutoff to clip the trough into a blank. Without truecolor there is no
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
* shows the glyph in the palette's muted role or leaves a blank column — a
* single dim appear/disappear at fixed width, still dim rather than accent, and
* no throb-driven blink. With color off entirely a visible glyph is bare,
* holding the caret column on a monochrome terminal.
*
* @param glyph - The phase glyph to paint.
* @param palette - Active palette supplying the muted (dim gray) role.
* @param colorEnabled - Whether ANSI is emitted at all.
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.
* @param opacity - Brightness fraction in [0, 1] for the truecolor gray.
* @param visible - Whether the non-truecolor fallback shows the glyph at all.
* @returns The gray glyph at this opacity, or a single space when hidden.
*/
export function fadeGlyph(
glyph: string,
palette: Palette,
colorEnabled: boolean,
truecolor: boolean,
opacity: number,
visible: boolean,
): string {
if (truecolor && colorEnabled) {
const o = Math.min(Math.max(opacity, 0), 1)
const [tr, tg, tb] = STATUS_FADE_GRAY.trough
const [sr, sg, sb] = STATUS_FADE_GRAY.settled
const r = Math.round(tr + (sr - tr) * o)
const g = Math.round(tg + (sg - tg) * o)
const b = Math.round(tb + (sb - tb) * o)
return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m`
}
if (!visible) return ' '
return colorEnabled ? palette.dim(glyph) : glyph
}
/**
* Format a non-negative elapsed span at 100 ms resolution.
* @param elapsedMs - Elapsed milliseconds.
* @returns The formatted duration (e.g. `1.5s`, `2m03.4s`).
*/
export function formatStatusDuration(elapsedMs: number): string {
const tenths = Math.floor(Math.max(0, elapsedMs) / 100)
const seconds = tenths / 10
if (seconds < 60) return `${seconds.toFixed(1)}s`
const minutes = Math.floor(seconds / 60)
return `${minutes}m${(seconds - minutes * 60).toFixed(1).padStart(4, '0')}s`
}
/**
* Format the non-zero timing buckets of one step as a middot-joined summary.
* @param totals - Per-phase totals to format.
* @param includeModelWait - Whether to always include the model-wait bucket.
* @returns The formatted timing summary.
*/
export function formatTimingTotals(totals: TimingTotals, includeModelWait = false): string {
return TIMING_BUCKETS
.filter(bucket => totals[bucket] > 0 || (includeModelWait && bucket === 'ttft'))
.map(bucket => `${TIMING_BUCKET_LABELS[bucket]} ${formatStatusDuration(totals[bucket])}`)
.join(' · ')
}
/**
* Format the queued-steering badge shown on the running status line.
* @param queued - Number of queued steering messages.
* @returns The badge text, or `undefined` when nothing is queued.
*/
export function formatQueuedStatus(queued: number): string | undefined {
return queued > 0 ? `${queued} queued` : undefined
}
/**
* Format a completion timestamp as `YYYY-MM-DD HH:MM:SS` in local time.
* @param time - Epoch milliseconds.
* @returns The formatted local timestamp.
*/
export function formatCompletionTime(time: number): string {
const date = new Date(time)
const parts = [
date.getFullYear().toString().padStart(4, '0'),
(date.getMonth() + 1).toString().padStart(2, '0'),
date.getDate().toString().padStart(2, '0'),
]
const clock = [date.getHours(), date.getMinutes(), date.getSeconds()]
.map(value => value.toString().padStart(2, '0'))
.join(':')
return `${parts.join('-')} ${clock}`
}