DSH / Atlas
2026-07-27implementedfeaturearchived 2026-08-04

Dim-gray pulse for the running prompt glyph

Dim-gray pulse for the running prompt glyph

While a turn runs, the TUI replaces the `>` prompt caret with a phase glyph (`◍`/`✻`/`●`/`⚙`). Earlier iterations animated its brightness in the accent blue (a discrete SGR wave, then a truecolor throb) — a colored, always-pulsing indicator. The desired effect keeps the continuous pulse to signal ongoing work, but as a quiet dim gray rather than a color, and with smooth fade-in and fade-out at its edges.

English

Problem

While a turn runs, the TUI replaces the > prompt caret with a phase glyph (///). Earlier iterations animated its brightness in the accent blue (a discrete SGR wave, then a truecolor throb) — a colored, always-pulsing indicator. The desired effect keeps the continuous pulse to signal ongoing work, but as a quiet dim gray rather than a color, and with smooth fade-in and fade-out at its edges.

Decision

The running glyph is a dim gray that fades in on turn start, throbs continuously while the turn runs, and fades out after it ends before the plain > caret returns. It is never the accent color.

Brightness is a fade envelope times a running throb. The envelope gates appear/disappear, linear in the render clock over STATUS_FADE_MS = 300: (now − startedAt)/FADE clamped for fade-in, 1 − (now − endedAt)/FADE for fade-out. pulseLevel is a cosine between STATUS_PULSE_FLOOR (0) and 1 over STATUS_PULSE_PERIOD_MS = 1400, so each breath swells from fully invisible to full and back. The truecolor opacity handed to fadeGlyph is envelope × pulse.

fadeGlyph renders at that opacity. With truecolor, below STATUS_FADE_MIN_OPACITY (0.12) the glyph is hidden entirely — a blank column — so the pulse trough disappears rather than lingering as a near-background gray; above it the glyph interpolates a 24-bit gray between STATUS_FADE_GRAY.trough and .settled (the same dim gray as the idle caret), emitting \x1b[38;2;r;g;bm, so both the fade and the throb are brightness. Without truecolor there is no per-frame gray, so a separate visible flag — driven by the envelope alone, not the pulsing opacity — shows the glyph in the palette's muted role or leaves a blank column; the throb never blinks the fallback. With color off entirely a visible glyph is bare, preserving the caret column on a monochrome terminal.

The running prompt refreshes at STATUS_ANIMATION_INTERVAL_MS = 50 (~20 fps) so the throb moves every frame; the same tick keeps the 0.1 s-resolution elapsed text current, so no separate timing timer exists.

Fade-out outlives the turn: on the running → non-running edge beginFadeOut hands the last rendered glyph to a FadingStatus whose own timer re-renders until the fade window elapses, then calls clearStatus and restores >. Teardown paths (dispose, agent-disposed, startup-failure) call clearStatus directly, stopping both the running and fading timers at once — no lingering fade. The glyph handed to the fade-out is the last live phase glyph (runningStatus.lastGlyph), not the ttft fallback the phase derivation returns once the closing turn's step has ended.

The glyph character and its cell never change — only the gray brightness — so the caret column stays fixed across frames and across the caret↔glyph transitions.

Alternatives considered

Keep the accent color. The pulse is wanted, but as a quiet gray matching the idle caret's tone, not a colored indicator; the accent is removed while the throb stays.

Hold steady while running (no throb). A steady dim glyph was tried and rejected: a continuous pulse better conveys that the agent is actively working. The throb returns, in gray.

A non-zero floor that keeps the trough faintly visible. Successive floors (0.45 → 0.15 → 0.02) each kept the dimmest point too visible to read as truly quiet; even 0.02 sat at gray ≈ 45, one step off the background. A floor of 0 with an explicit visibility threshold (STATUS_FADE_MIN_OPACITY) instead hides the glyph entirely at the bottom of each breath, so the trough is genuinely absent. Because the swell is a smooth cosine, the disappearance reads as a soft fade-out, not the hard on/off blink a low-but-nonzero gray toggle would give.

Pulse the non-truecolor fallback too. SGR exposes only three intensity levels, too coarse for a smooth throb, and toggling the glyph on/off across the pulse would blink it. The fallback instead shows a steady muted glyph gated by the envelope; only truecolor terminals get the throb.

Consequences

The running glyph reads as a quiet gray breath that swells from nothing to a dim mark and back the whole turn, matching the idle caret's tone, at the cost of a faster render tick (50 ms) while a turn is active or fading out; the diffing terminal only re-emits changed cells, so the extra frames are cheap. The fade-out means the indicator lingers ~300 ms after a turn completes. Snapshots run non-truecolor with a frozen clock, so they pin only the steady muted glyph (envelope-gated), not the throb; the truecolor invisible trough, the settled peak, a rising mid-frame, the fade-out, and the non-truecolor appear/disappear are pinned by unit tests in tui.spec.ts.

中文

Problem

回合运行时,TUI 会把 > 提示符替换为阶段字形(///)。此前的迭代用强调蓝为其亮度做动画(先是离散 SGR 波,后是 truecolor 呼吸)——一个持续脉动的彩色指示器。期望的效果保留持续脉动以示正在工作,但改为安静的暗灰而非颜色,并在两端做平滑的淡入淡出。

Decision

运行字形是一种暗灰色,在回合开始时淡入,运行期间持续脉动,回合结束后淡出,随后恢复为普通的 > 光标。它从不使用强调色。

亮度是淡入淡出包络乘以运行脉冲。包络控制出现/消失,随渲染时钟在 STATUS_FADE_MS = 300 内线性变化:淡入为 (now − startedAt)/FADE 并做钳制,淡出为 1 − (now − endedAt)/FADEpulseLevel 是在 STATUS_PULSE_FLOOR(0)与 1 之间、周期为 STATUS_PULSE_PERIOD_MS = 1400 的余弦,因此每次呼吸都从完全不可见涨到满亮再回落。交给 fadeGlyph 的 truecolor 不透明度为 envelope × pulse

fadeGlyph 以该不透明度渲染。在 truecolor 下,低于 STATUS_FADE_MIN_OPACITY(0.12)时字形被完全隐藏——留出空白列——因此脉冲谷值消失,而非停留为接近背景的灰;在其之上,字形在 STATUS_FADE_GRAY.trough.settled(与空闲光标相同的暗灰)之间插值出 24 位灰色,发出 \x1b[38;2;r;g;bm,因此淡入与脉冲都表现为亮度。没有 truecolor 时不存在逐帧灰度,因此用一个单独的 visible 标志——只由包络驱动,而非脉动的不透明度——以调色板 muted 角色显示字形或留出空白列;脉冲从不使回退闪烁。完全关闭颜色时,可见字形以裸字符呈现,在单色终端上保住光标列。

运行提示符以 STATUS_ANIMATION_INTERVAL_MS = 50(约 20 fps)刷新,使脉动逐帧移动;同一次 tick 也让 0.1 s 精度的耗时文本保持最新,因此不需要单独的计时器。

淡出会延续到回合之后:在运行 → 非运行的边沿,beginFadeOut 把最后渲染的字形交给一个 FadingStatus,其自有计时器持续重绘,直到渐变窗口结束,然后调用 clearStatus 并恢复 >。拆解路径(dispose、agent-disposed、启动失败)直接调用 clearStatus,一次性停止运行与淡出两个计时器——不会有残留的渐变。交给淡出的字形是最后一次的实时阶段字形(runningStatus.lastGlyph),而非收尾回合的步骤结束后阶段推导返回的 ttft 兜底字形。

字形字符及其单元格从不改变——只有灰色亮度变化——所以光标列在各帧之间以及光标↔字形的切换之间都保持固定。

Alternatives considered

保留强调色。 需要脉冲,但要用与空闲光标一致的安静灰色,而非彩色指示器;移除强调色,保留脉动。

运行时保持稳定(不脉动)。 曾试过稳定的暗色字形并被否决:持续脉动更能表明代理正在积极工作。脉动以灰色回归。

用非零下限让谷值保持微弱可见。 逐次下限(0.45 → 0.15 → 0.02)都让最暗点太可见,读不出真正的安静;即便 0.02 也停在灰度约 45,仅比背景高一档。改用下限 0 加显式可见阈值(STATUS_FADE_MIN_OPACITY),在每次呼吸的底部完全隐藏字形,使谷值真正缺席。由于涨落是平滑余弦,消失读作柔和的淡出,而非低而非零的灰度开关会带来的硬性开/关闪烁。

让非 truecolor 回退也脉动。 SGR 只暴露三个强度档位,做平滑脉动太粗糙,而按脉冲开关字形会使其闪烁。回退改为由包络控制的稳定 muted 字形;只有 truecolor 终端获得脉动。

Consequences

代价是运行或淡出期间渲染 tick 更快(50 ms),换来的是运行字形整段回合读作一种从无涨到暗记号再回落的安静灰色呼吸,与空闲光标的色调一致;差分终端只重发变化的单元格,因此额外帧开销很低。淡出意味着指示器在回合结束后残留约 300 ms。快照以非 truecolor、冻结时钟运行,因此只钉住由包络控制的稳定 muted 字形,而非脉动;truecolor 的不可见谷值、稳定峰值、上升中间帧、淡出、以及非 truecolor 的出现/消失均由 tui.spec.ts 的单元测试钉住。