fix(tui): unify compaction progress presentation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -32,14 +32,10 @@ import { contentText, type ParsedArguments } from './content.ts'
|
||||
import {
|
||||
formatCompletionTime,
|
||||
formatTimingTotals,
|
||||
STATUS_ANIMATION_INTERVAL_MS,
|
||||
stepTimingAt,
|
||||
type StepPosition,
|
||||
} from '../chat/timing.ts'
|
||||
|
||||
const COMPACTION_PROGRESS_FRAMES = ['◐', '◓', '◑', '◒'] as const
|
||||
const COMPACTION_PROGRESS_LABEL = 'Compaction in progress…'
|
||||
|
||||
/** Concatenate the text of every block of one type, separated by blank lines. */
|
||||
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
|
||||
return content
|
||||
@@ -137,29 +133,6 @@ export class HeaderComponent implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-local transcript tail announcing a live standalone compaction.
|
||||
* The leading blank belongs to the component so removing it leaves no gap.
|
||||
*/
|
||||
export class CompactionProgressComponent implements Component {
|
||||
constructor(
|
||||
private readonly startedAt: number,
|
||||
private readonly now: () => number,
|
||||
private readonly palette: Palette,
|
||||
) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const elapsed = Math.max(0, this.now() - this.startedAt)
|
||||
const frameIndex = Math.floor(elapsed / STATUS_ANIMATION_INTERVAL_MS)
|
||||
% COMPACTION_PROGRESS_FRAMES.length
|
||||
const frame = COMPACTION_PROGRESS_FRAMES[frameIndex] as string
|
||||
const marker = `${this.palette.accent(frame)} ${this.palette.dim(COMPACTION_PROGRESS_LABEL)}`
|
||||
return ['', truncateToWidth(marker, Math.max(1, width), '')]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A user or steering prompt in the transcript. An underlined accent role header
|
||||
* plus blank-line spacing separate it from surrounding blocks; body lines carry
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
import {
|
||||
fadeGlyph,
|
||||
formatQueuedStatus,
|
||||
formatStatusDuration,
|
||||
openStepPhase,
|
||||
openTurn,
|
||||
pulseLevel,
|
||||
@@ -94,7 +95,6 @@ import {
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import {
|
||||
CompactionProgressComponent,
|
||||
ContextCardComponent,
|
||||
type ToolCardVisibility,
|
||||
HeaderComponent,
|
||||
@@ -270,12 +270,6 @@ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly
|
||||
*/
|
||||
const COMPACTION_MARKER = '… earlier context was compacted …'
|
||||
|
||||
/**
|
||||
* Status glyph for a live standalone compaction bracket. Compaction is not a
|
||||
* step phase, so the glyph stays local to the TUI indicator.
|
||||
*/
|
||||
const COMPACTING_GLYPH = '⊙'
|
||||
|
||||
interface RunningStatus {
|
||||
turn: number | undefined
|
||||
timer: ReturnType<typeof setInterval>
|
||||
@@ -336,6 +330,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.
|
||||
@@ -351,7 +346,6 @@ export function createTuiChat(
|
||||
let compacting: {
|
||||
startedAt: number
|
||||
timer: ReturnType<typeof setInterval>
|
||||
progress: CompactionProgressComponent
|
||||
} | undefined
|
||||
// TUI steering submissions that the inbox has not yet claimed or discarded.
|
||||
// Correlation ids avoid guessing whether a running-state submission actually
|
||||
@@ -416,6 +410,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)
|
||||
@@ -429,25 +424,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)
|
||||
?? (compacting === undefined ? undefined : COMPACTING_GLYPH)
|
||||
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 activeSince = runningStatus?.startedAt ?? compacting?.startedAt
|
||||
const envelope = activeSince !== undefined && runningGlyph !== undefined
|
||||
? { glyph: runningGlyph, level: Math.min(1, (now() - activeSince) / STATUS_FADE_MS) }
|
||||
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('>')
|
||||
@@ -456,7 +457,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(' ')}`)
|
||||
@@ -471,6 +472,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)
|
||||
@@ -483,11 +485,6 @@ export function createTuiChat(
|
||||
|
||||
const requestRender = (): void => {
|
||||
if (disposed) return
|
||||
if (compacting !== undefined) {
|
||||
compacting.progress.invalidate()
|
||||
chat.removeChild(compacting.progress)
|
||||
chat.addChild(compacting.progress)
|
||||
}
|
||||
updatePromptValues()
|
||||
const inputPrompt = renderInputPrompt()
|
||||
editor.setPrompt({ first: inputPrompt, continuation: ' '.repeat(visibleWidth(inputPrompt)) })
|
||||
@@ -577,16 +574,15 @@ export function createTuiChat(
|
||||
const clearStatus = (): void => {
|
||||
if (compacting !== undefined) {
|
||||
clearInterval(compacting.timer)
|
||||
chat.removeChild(compacting.progress)
|
||||
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 => {
|
||||
clearTurnStatus()
|
||||
@@ -1538,7 +1534,6 @@ export function createTuiChat(
|
||||
compacting = {
|
||||
startedAt,
|
||||
timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS),
|
||||
progress: new CompactionProgressComponent(startedAt, now, palette),
|
||||
}
|
||||
runtime.terminal.setProgress(true)
|
||||
}
|
||||
@@ -1546,15 +1541,15 @@ export function createTuiChat(
|
||||
return
|
||||
}
|
||||
if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) {
|
||||
const fadeOutGlyph = runningPhaseGlyph(agent.session.events, false, true)
|
||||
clearInterval(compacting.timer)
|
||||
chat.removeChild(compacting.progress)
|
||||
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) beginFadeOut(COMPACTING_GLYPH)
|
||||
if (runningStatus === undefined && fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user