Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/subagents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Prevented the async subagent status widget from briefly unmounting during reset-and-hydrate cycles when active background runs are still present, including Atomic host updates that deliver fresh UI context wrappers for the same logical session ([#1517](https://github.com/bastani-inc/atomic/issues/1517)).
- Fixed live subagent result animation cleanup to register a host-row disposer, so terminal workflow cleanup evicts animation registry entries instead of only clearing intervals ([#1518](https://github.com/bastani-inc/atomic/issues/1518)).
- Synced recent upstream subagent hardening so compact delegated tool-call summaries are preserved, fanout children keep their live nested subagent call/result history, duplicate concurrent subagent dispatches are rejected, provider-hostile chain schema conditionals are removed, and failed foreground runs include captured child output for diagnostics ([#1527](https://github.com/bastani-inc/atomic/issues/1527)).
- Eliminated the foreground subagent widget flicker that appeared once a running subagent panel grew tall enough to reach or exceed the terminal viewport. The live compact result no longer animates its spinner on an 80ms wall-clock timer; instead it shows an activity "pulse" glyph that advances exactly once per real progress update. Because the panel renders into chat scrollback, a timer-driven spinner cell that scrolled above pi-tui's viewport fold forced a destructive full-screen + scrollback clear on every tick — driving the indicator off genuine updates keeps line diffs tied to content that actually changed, so the differential renderer repaints only when progress does and never strobes.

## [0.9.3-alpha.1] - 2026-06-25

Expand Down
8 changes: 5 additions & 3 deletions packages/subagents/src/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { discoverAgents } from "../agents/agents.ts";
import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "../shared/artifacts.ts";
import { resolveCurrentSessionId } from "../shared/session-identity.ts";
import { cleanupOldChainDirs } from "../shared/settings.ts";
import { renderLiveSubagentResult, renderSubagentResult, stopResultAnimations, stopWidgetAnimation, type SubagentResultRenderState } from "../tui/render.ts";
import { advanceResultPulseFrame, renderLiveSubagentResult, renderSubagentResult, stopResultAnimations, stopWidgetAnimation, type SubagentResultRenderState } from "../tui/render.ts";
import { SubagentParams } from "./schemas.ts";
import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
Expand Down Expand Up @@ -69,7 +69,7 @@ type SubagentToolRenderState = SubagentResultRenderState;
function rebuildSlashResultContainer(
container: Container,
result: AgentToolResult<Details>,
options: { expanded: boolean; now?: number },
options: { expanded: boolean; now?: number; pulseFrame?: number },
theme: ExtensionContext["ui"]["theme"],
): void {
container.clear();
Expand All @@ -87,12 +87,14 @@ function createSlashResultComponent(
const container = new Container();
let lastVersion = -1;
let lastSnapshotNow = 0;
let pulseFrame = 0;
container.render = (width: number): string[] => {
const snapshot = getSlashRenderableSnapshot(details);
if (snapshot.version !== lastVersion) {
lastVersion = snapshot.version;
lastSnapshotNow = Date.now();
rebuildSlashResultContainer(container, snapshot.result, { ...options, now: lastSnapshotNow }, theme);
pulseFrame = advanceResultPulseFrame(pulseFrame);
rebuildSlashResultContainer(container, snapshot.result, { ...options, now: lastSnapshotNow, pulseFrame }, theme);
}
return Container.prototype.render.call(container, width);
};
Expand Down
31 changes: 27 additions & 4 deletions packages/subagents/src/tui/render-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,22 @@ export function truncLine(text: string, maxWidth: number): string {
return result + activeStyles.join("") + "…";
}

const RUNNING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export const RUNNING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

/**
* Spinner cadence (ms per frame). The running glyph is derived from wall-clock
* time so every active spinner advances smoothly and in lockstep, independent
* of how often (or how irregularly) progress data updates arrive. The animation
* timers below only schedule re-renders; the displayed frame always comes from
* the clock. This fixes the frozen/stuttering spinner from issue #1084 while
* keeping per-frame diffs to a single glyph cell so the differential renderer
* never needs a full-clear (no flicker).
* the clock. This fixes the frozen/stuttering spinner from issue #1084.
*
* IMPORTANT: a wall-clock spinner only stays flicker-free for widgets pinned to
* the bottom of the buffer (e.g. the below-editor async widget), where every
* tick stays inside the viewport. Content rendered into chat scrollback (live
* foreground subagent results) can scroll above the viewport fold; there, even
* a single-cell spinner diff forces pi-tui into a destructive full-screen +
* scrollback clear on every tick. Such surfaces must NOT animate on a timer —
* see pulseGlyph(), which is advanced once per real progress update instead.
*/
export const RUNNING_ANIMATION_MS = 80;

Expand Down Expand Up @@ -106,6 +112,23 @@ export function runningGlyph(seed?: number, now?: number): string {
return RUNNING_FRAMES[Math.abs(animatedSeed) % RUNNING_FRAMES.length]!;
}

export const PULSE_FRAMES = ["·", "•", "●", "•"];

/**
* Activity "heartbeat" glyph for live foreground subagent results. Unlike
* runningGlyph(), the frame is NOT derived from wall-clock time: the caller
* advances `frame` exactly once per real progress update (see
* renderLiveSubagentResult). With no animation timer, the only line diffs this
* produces coincide with progress data that genuinely changed, so the pulse can
* live in chat scrollback (above or below the fold) without ever triggering
* pi-tui's full-screen/scrollback clear. Returns a steady breathing dot that
* grows and settles as the subagent reports activity.
*/
export function pulseGlyph(frame?: number): string {
const index = Number.isFinite(frame) ? Math.abs(Math.trunc(frame as number)) : 0;
return PULSE_FRAMES[index % PULSE_FRAMES.length]!;
}

export function progressRunningSeed(progress: ProgressSeedSource | undefined): number | undefined {
if (!progress) return undefined;
return runningSeed(
Expand Down
48 changes: 14 additions & 34 deletions packages/subagents/src/tui/render-result-animation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { RUNNING_ANIMATION_MS } from "./render-layout.ts";

type ResultAnimationTimer = ReturnType<typeof setInterval>;

export interface SubagentResultRenderState {
Expand All @@ -8,8 +6,8 @@ export interface SubagentResultRenderState {
subagentResultSnapshotKey?: string;
/** Stable semantic/content timestamp used for durations and activity text. */
subagentResultSnapshotNow?: number;
/** Timer-driven timestamp used only for spinner glyph frames. */
subagentResultSpinnerFrameNow?: number;
/** Monotonic pulse frame, advanced once per progress update (no timer). */
subagentResultPulseFrame?: number;
}

export type ResultAnimationContext = {
Expand All @@ -23,45 +21,27 @@ type LegacyResultAnimationContext = {
};
};

const activeResultAnimationTimers = new Map<ResultAnimationTimer, SubagentResultRenderState>();

/**
* Legacy safety net for render state objects created by earlier timer-driven
* foreground result rendering. New code never schedules result timers, but
* clearing the field prevents a stale interval from surviving across upgrades.
*/
export function clearResultAnimationTimer(context: LegacyResultAnimationContext): void {
const timer = context.state.subagentResultAnimationTimer;
if (timer) {
clearInterval(timer);
activeResultAnimationTimers.delete(timer);
}
if (timer) clearInterval(timer);
context.state.subagentResultAnimationTimer = undefined;
context.state.subagentResultAnimationCleanup = undefined;
}

export function clearLegacyResultAnimationTimer(context: LegacyResultAnimationContext): void {
clearResultAnimationTimer(context);
export function advanceResultPulseFrame(frame: number | undefined): number {
return (frame ?? 0) + 1;
}

export function ensureResultAnimation(context: ResultAnimationContext): void {
if (context.state.subagentResultAnimationTimer) return;
const timer = setInterval(() => {
context.state.subagentResultSpinnerFrameNow = Date.now();
try {
context.invalidate();
} catch {
clearResultAnimationTimer(context);
}
}, RUNNING_ANIMATION_MS);
timer.unref?.();
context.state.subagentResultAnimationTimer = timer;
context.state.subagentResultAnimationCleanup = () => clearResultAnimationTimer(context);
activeResultAnimationTimers.set(timer, context.state);
export function clearLegacyResultAnimationTimer(context: LegacyResultAnimationContext): void {
clearResultAnimationTimer(context);
}

export function stopResultAnimations(): void {
for (const [timer, state] of activeResultAnimationTimers) {
clearInterval(timer);
if (state.subagentResultAnimationTimer === timer) {
state.subagentResultAnimationTimer = undefined;
state.subagentResultAnimationCleanup = undefined;
}
}
activeResultAnimationTimers.clear();
// Retained for extension teardown compatibility; result rendering no longer
// registers global animation timers.
}
12 changes: 6 additions & 6 deletions packages/subagents/src/tui/render-result-compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Container, Text, type Component } from "@earendil-works/pi-tui";
import type { AgentProgress, AsyncJobStep, Details } from "../shared/types.ts";
import { shortenPath } from "../shared/formatters.ts";
import { getSingleResultOutput } from "../shared/utils.ts";
import { getTermWidth, progressRunningSeed, runningGlyph, runningSeed, truncLine, type Theme } from "./render-layout.ts";
import { getTermWidth, pulseGlyph, truncLine, type Theme } from "./render-layout.ts";
import {
buildLiveStatusLine,
compactCurrentActivity,
Expand All @@ -25,7 +25,7 @@ import {
} from "./render-chain-graph.ts";
import { modelThinkingBadge, widgetStepGlyph, widgetStepStatus } from "./render-event-formatting.ts";

export function renderSingleCompact(d: Details, r: Details["results"][number], theme: Theme, now?: number, spinnerNow?: number): Component {
export function renderSingleCompact(d: Details, r: Details["results"][number], theme: Theme, now?: number, pulseFrame?: number): Component {
const output = r.truncation?.text || getSingleResultOutput(r);
const progress = r.progress || r.progressSummary;
const isRunning = r.progress?.status === "running";
Expand All @@ -37,7 +37,7 @@ export function renderSingleCompact(d: Details, r: Details["results"][number], t
const c = new Container();
const width = getTermWidth() - 4;
const modelDisplay = modelThinkingBadge(theme, r.model, undefined, r.fastMode);
c.addChild(new Text(truncLine(`${resultGlyph(r, output, theme, isRunning, progressRunningSeed(r.progress ?? r.progressSummary), spinnerNow ?? now)} ${theme.fg("toolTitle", theme.bold(r.agent))}${modelDisplay}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));
c.addChild(new Text(truncLine(`${resultGlyph(r, output, theme, isRunning, pulseFrame)} ${theme.fg("toolTitle", theme.bold(r.agent))}${modelDisplay}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0));

if (isRunning && r.progress) {
const progressSnapshotNow = snapshotNowForProgress(r.progress, now);
Expand All @@ -61,7 +61,7 @@ export function renderSingleCompact(d: Details, r: Details["results"][number], t
return c;
}

export function renderMultiCompact(d: Details, theme: Theme, now?: number, spinnerNow?: number): Component {
export function renderMultiCompact(d: Details, theme: Theme, now?: number, pulseFrame?: number): Component {
const hasRunning = d.progress?.some((p) => p.status === "running")
|| d.results.some((r) => r.progress?.status === "running")
|| workflowGraphHasStatus(d, ["running"]);
Expand All @@ -87,7 +87,7 @@ export function renderMultiCompact(d: Details, theme: Theme, now?: number, spinn
const itemTitle = multiLabel.itemTitle;
const stats = statJoin(theme, [multiLabel.headerLabel, formatProgressStats(theme, totalSummary, true, now)]);
const glyph = hasRunning
? theme.fg("accent", runningGlyph(runningSeed(progressRunningSeed(totalSummary), d.currentStepIndex), spinnerNow ?? now))
? theme.fg("accent", pulseGlyph(pulseFrame))
: failed
? theme.fg("error", "✗")
: paused
Expand Down Expand Up @@ -133,7 +133,7 @@ export function renderMultiCompact(d: Details, theme: Theme, now?: number, spinn
const rPending = rProg && "status" in rProg && rProg.status === "pending";
const stepNumber = r.progress?.index !== undefined ? r.progress.index + 1 : progressFromArray?.index !== undefined ? progressFromArray.index + 1 : i + 1;
const stepStats = formatProgressStats(theme, rProg, true, now);
const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning, progressRunningSeed(rProg), spinnerNow ?? now);
const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning, pulseFrame);
const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : "";
const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber);
const line = `${glyph} ${stepLabel}: ${themeBold(theme, agentName)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`;
Expand Down
39 changes: 20 additions & 19 deletions packages/subagents/src/tui/render-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import type { AgentProgress, AsyncJobStep, Details } from "../shared/types.ts";
import { formatDuration, formatTokens, formatUsage, shortenPath } from "../shared/formatters.ts";
import { getSingleResultOutput } from "../shared/utils.ts";
import { getTermWidth, truncLine, type Theme } from "./render-layout.ts";
import { clearResultAnimationTimer, ensureResultAnimation, type ResultAnimationContext } from "./render-result-animation.ts";
import { advanceResultPulseFrame, clearResultAnimationTimer, type ResultAnimationContext } from "./render-result-animation.ts";
import { renderMultiCompact, renderSingleCompact } from "./render-result-compact.ts";
import { buildChainRenderEntries, buildMultiProgressLabel, resultRowLabel, workflowGraphHasStatus, type ChainRenderEntry } from "./render-chain-graph.ts";
import { isRunningSubagentResult, subagentResultRenderKey } from "./render-stable-output.ts";
import { subagentResultRenderKey } from "./render-stable-output.ts";
import { modelThinkingBadge, widgetStepStatus } from "./render-event-formatting.ts";
import {
buildLiveStatusLine,
Expand All @@ -28,26 +28,27 @@ export function renderLiveSubagentResult(
): Component {
const nextKey = subagentResultRenderKey(result, options);
if (context.state.subagentResultSnapshotKey !== nextKey) {
const frameNow = Date.now();
context.state.subagentResultSnapshotKey = nextKey;
context.state.subagentResultSnapshotNow = frameNow;
context.state.subagentResultSpinnerFrameNow = frameNow;
context.state.subagentResultSnapshotNow = Date.now();
// Advance the activity pulse exactly once per real progress update.
// Foreground subagent results render into chat scrollback, which can sit
// above the viewport fold. Animating on a timer there forces pi-tui into a
// destructive full-screen/scrollback clear on every tick (the flicker that
// scaled with widget height). Driving the pulse off genuine updates keeps
// the only line diffs tied to content that actually changed, so the
// differential renderer repaints exactly as it would for any progress
// update — no extra above-fold churn between updates.
context.state.subagentResultPulseFrame = advanceResultPulseFrame(context.state.subagentResultPulseFrame);
}
context.state.subagentResultSnapshotNow ??= Date.now();
context.state.subagentResultSpinnerFrameNow ??= context.state.subagentResultSnapshotNow;
// Foreground subagent results render inside chat scrollback. Keep semantic
// content time stable between tool/progress updates, but let the spinner tick
// independently. That limits timer-driven diffs to spinner glyph cells instead
// of updating elapsed/tool/activity text and causing broad chatbox churn.
if (options.isPartial && isRunningSubagentResult(result)) {
ensureResultAnimation(context);
} else {
clearResultAnimationTimer(context);
}
context.state.subagentResultPulseFrame ??= 0;
// Never schedule timer-driven re-renders for scrollback content; clear any
// stale timer a previous version may have installed for this render slot.
clearResultAnimationTimer(context);
return renderSubagentResult(result, {
...options,
now: context.state.subagentResultSnapshotNow,
spinnerNow: context.state.subagentResultSpinnerFrameNow,
pulseFrame: context.state.subagentResultPulseFrame,
}, theme);
}

Expand All @@ -56,7 +57,7 @@ export function renderLiveSubagentResult(
*/
export function renderSubagentResult(
result: AgentToolResult<Details>,
options: { expanded: boolean; now?: number; spinnerNow?: number },
options: { expanded: boolean; now?: number; pulseFrame?: number },
theme: Theme,
): Component {
const d = result.details;
Expand All @@ -72,7 +73,7 @@ export function renderSubagentResult(

if (d.mode === "single" && d.results.length === 1) {
const r = d.results[0];
if (!expanded) return renderSingleCompact(d, r, theme, options.now, options.spinnerNow);
if (!expanded) return renderSingleCompact(d, r, theme, options.now, options.pulseFrame);
const isRunning = r.progress?.status === "running";
const icon = isRunning
? theme.fg("warning", "running")
Expand Down Expand Up @@ -166,7 +167,7 @@ export function renderSubagentResult(
return c;
}

if (!expanded) return renderMultiCompact(d, theme, options.now, options.spinnerNow);
if (!expanded) return renderMultiCompact(d, theme, options.now, options.pulseFrame);

const hasRunning = d.progress?.some((p) => p.status === "running")
|| d.results.some((r) => r.progress?.status === "running")
Expand Down
6 changes: 3 additions & 3 deletions packages/subagents/src/tui/render-status-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AgentProgress, Details } from "../shared/types.ts";
import { formatDuration, formatTokens, formatToolCall } from "../shared/formatters.ts";
import { getDisplayItems } from "../shared/utils.ts";
import { formatActivityLabel } from "../shared/status-format.ts";
import { getTermWidth, progressRunningSeed, runningGlyph, type Theme } from "./render-layout.ts";
import { getTermWidth, pulseGlyph, type Theme } from "./render-layout.ts";

export function extractOutputTarget(task: string): string | undefined {
const writeToMatch = task.match(/\[Write to:\s*([^\]\n]+)\]/i);
Expand Down Expand Up @@ -118,8 +118,8 @@ export function resultStatusLine(result: Details["results"][number], output: str
return "Done";
}

export function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running", seed = progressRunningSeed(result.progress ?? result.progressSummary), now?: number): string {
if (running) return theme.fg("accent", runningGlyph(seed, now));
export function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running", pulseFrame?: number): string {
if (running) return theme.fg("accent", pulseGlyph(pulseFrame));
if (result.detached) return theme.fg("warning", "■");
if (result.interrupted) return theme.fg("warning", "■");
if (result.exitCode !== 0) return theme.fg("error", "✗");
Expand Down
4 changes: 2 additions & 2 deletions packages/subagents/src/tui/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
* rendering responsibility across sibling modules.
*/

export { RUNNING_ANIMATION_MS, currentRunningFrame } from "./render-layout.ts";
export { PULSE_FRAMES, RUNNING_ANIMATION_MS, RUNNING_FRAMES, currentRunningFrame, pulseGlyph } from "./render-layout.ts";
export {
advanceResultPulseFrame,
clearLegacyResultAnimationTimer,
clearResultAnimationTimer,
ensureResultAnimation,
stopResultAnimations,
} from "./render-result-animation.ts";
export type { SubagentResultRenderState } from "./render-result-animation.ts";
Expand Down
Loading
Loading