Merge remote-tracking branch 'origin/master' into worktree/pr1018-todo-retarget-20260731

# Conflicts:
#	apps/cli/README.i18n.yaml
#	packages/ui/tui/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-31 19:23:34 +08:00
53 changed files with 1113 additions and 150 deletions

View File

@@ -80,6 +80,7 @@ import {
import {
fadeGlyph,
formatQueuedStatus,
formatStatusDuration,
openStepPhase,
openTurn,
pulseLevel,
@@ -329,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.
@@ -337,6 +339,14 @@ export function createTuiChat(
let completedStreaming: StreamingAssistantComponent | undefined
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.
@@ -400,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)
@@ -413,23 +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)
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('>')
@@ -438,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(' ')}`)
@@ -453,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)
@@ -540,8 +560,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
@@ -550,21 +570,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),
}
@@ -574,9 +603,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') {
@@ -1501,6 +1530,32 @@ export function createTuiChat(
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
// 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)) {
@@ -1544,6 +1599,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