refactor(cli): defer tool-call commit to prevent live-to-static flicker (#778)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-01 19:45:17 -08:00
committed by GitHub
parent ac2950b2a2
commit 790eb2278a
2 changed files with 175 additions and 105 deletions

View File

@@ -215,6 +215,7 @@ import { useTerminalRows, useTerminalWidth } from "./hooks/useTerminalWidth";
// Used only for terminal resize, not for dialog dismissal (see PR for details)
const CLEAR_SCREEN_AND_HOME = "\u001B[2J\u001B[H";
const MIN_RESIZE_DELTA = 2;
const TOOL_CALL_COMMIT_DEFER_MS = 50;
// Eager approval checking is now CONDITIONAL (LET-7101):
// - Enabled when resuming a session (--resume, --continue, or startupApprovals exist)
@@ -1558,110 +1559,152 @@ export default function App({
lastClearedColumnsRef.current = pendingColumns;
}, [columns, streaming]);
const deferredToolCallCommitsRef = useRef<Map<string, number>>(new Map());
const [deferredCommitAt, setDeferredCommitAt] = useState<number | null>(null);
const resetDeferredToolCallCommits = useCallback(() => {
deferredToolCallCommitsRef.current.clear();
setDeferredCommitAt(null);
}, []);
// Commit immutable/finished lines into the historical log
const commitEligibleLines = useCallback((b: Buffers) => {
const newlyCommitted: StaticItem[] = [];
let firstTaskIndex = -1;
// Check if there are any in-progress Task tool_calls
const hasInProgress = hasInProgressTaskToolCalls(
b.order,
b.byId,
emittedIdsRef.current,
);
// Collect finished Task tool_calls for grouping
const finishedTaskToolCalls = collectFinishedTaskToolCalls(
b.order,
b.byId,
emittedIdsRef.current,
hasInProgress,
);
// Commit regular lines (non-Task tools)
for (const id of b.order) {
if (emittedIdsRef.current.has(id)) continue;
const ln = b.byId.get(id);
if (!ln) continue;
if (
ln.kind === "user" ||
ln.kind === "error" ||
ln.kind === "status" ||
ln.kind === "trajectory_summary"
) {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
continue;
const commitEligibleLines = useCallback(
(b: Buffers, opts?: { deferToolCalls?: boolean }) => {
const deferToolCalls = opts?.deferToolCalls !== false;
const newlyCommitted: StaticItem[] = [];
let firstTaskIndex = -1;
const deferredCommits = deferredToolCallCommitsRef.current;
const now = Date.now();
let blockedByDeferred = false;
if (!deferToolCalls && deferredCommits.size > 0) {
deferredCommits.clear();
setDeferredCommitAt(null);
}
// Events only commit when finished (they have running/finished phases)
if (ln.kind === "event" && ln.phase === "finished") {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
continue;
}
// Commands with phase should only commit when finished
if (ln.kind === "command" || ln.kind === "bash_command") {
if (!ln.phase || ln.phase === "finished") {
// Check if there are any in-progress Task tool_calls
const hasInProgress = hasInProgressTaskToolCalls(
b.order,
b.byId,
emittedIdsRef.current,
);
// Collect finished Task tool_calls for grouping
const finishedTaskToolCalls = collectFinishedTaskToolCalls(
b.order,
b.byId,
emittedIdsRef.current,
hasInProgress,
);
// Commit regular lines (non-Task tools)
for (const id of b.order) {
if (emittedIdsRef.current.has(id)) continue;
const ln = b.byId.get(id);
if (!ln) continue;
if (
ln.kind === "user" ||
ln.kind === "error" ||
ln.kind === "status" ||
ln.kind === "trajectory_summary"
) {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
continue;
}
continue;
}
// Handle Task tool_calls specially - track position but don't add individually
// (unless there's no subagent data, in which case commit as regular tool call)
if (ln.kind === "tool_call" && ln.name && isTaskTool(ln.name)) {
// Check if this specific Task tool has subagent data (will be grouped)
const hasSubagentData = finishedTaskToolCalls.some(
(tc) => tc.lineId === id,
);
if (hasSubagentData) {
// Has subagent data - will be grouped later
if (firstTaskIndex === -1) {
firstTaskIndex = newlyCommitted.length;
// Events only commit when finished (they have running/finished phases)
if (ln.kind === "event" && ln.phase === "finished") {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
continue;
}
// Commands with phase should only commit when finished
if (ln.kind === "command" || ln.kind === "bash_command") {
if (!ln.phase || ln.phase === "finished") {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
}
continue;
}
// No subagent data (e.g., backfilled from history) - commit as regular tool call
if (ln.phase === "finished") {
// Handle Task tool_calls specially - track position but don't add individually
// (unless there's no subagent data, in which case commit as regular tool call)
if (ln.kind === "tool_call" && ln.name && isTaskTool(ln.name)) {
// Check if this specific Task tool has subagent data (will be grouped)
const hasSubagentData = finishedTaskToolCalls.some(
(tc) => tc.lineId === id,
);
if (hasSubagentData) {
// Has subagent data - will be grouped later
if (firstTaskIndex === -1) {
firstTaskIndex = newlyCommitted.length;
}
continue;
}
// No subagent data (e.g., backfilled from history) - commit as regular tool call
if (ln.phase === "finished") {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
}
continue;
}
if ("phase" in ln && ln.phase === "finished") {
if (
deferToolCalls &&
ln.kind === "tool_call" &&
(!ln.name || !isTaskTool(ln.name))
) {
const commitAt = deferredCommits.get(id);
if (commitAt === undefined) {
const nextCommitAt = now + TOOL_CALL_COMMIT_DEFER_MS;
deferredCommits.set(id, nextCommitAt);
setDeferredCommitAt(nextCommitAt);
blockedByDeferred = true;
break;
}
if (commitAt > now) {
setDeferredCommitAt(commitAt);
blockedByDeferred = true;
break;
}
deferredCommits.delete(id);
}
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
// Note: We intentionally don't cleanup precomputedDiffs here because
// the Static area renders AFTER this function returns (on next React tick),
// and the diff needs to be available for ToolCallMessage to render.
// The diffs will be cleaned up when the session ends or on next session start.
}
continue;
}
if ("phase" in ln && ln.phase === "finished") {
emittedIdsRef.current.add(id);
newlyCommitted.push({ ...ln });
// Note: We intentionally don't cleanup precomputedDiffs here because
// the Static area renders AFTER this function returns (on next React tick),
// and the diff needs to be available for ToolCallMessage to render.
// The diffs will be cleaned up when the session ends or on next session start.
}
}
// If we collected Task tool_calls (all are finished), create a subagent_group
if (finishedTaskToolCalls.length > 0) {
// Mark all as emitted
for (const tc of finishedTaskToolCalls) {
emittedIdsRef.current.add(tc.lineId);
}
const groupItem = createSubagentGroupItem(finishedTaskToolCalls);
// If we collected Task tool_calls (all are finished), create a subagent_group
if (!blockedByDeferred && finishedTaskToolCalls.length > 0) {
// Mark all as emitted
for (const tc of finishedTaskToolCalls) {
emittedIdsRef.current.add(tc.lineId);
}
// Insert at the position of the first Task tool_call
newlyCommitted.splice(
firstTaskIndex >= 0 ? firstTaskIndex : newlyCommitted.length,
0,
groupItem,
);
const groupItem = createSubagentGroupItem(finishedTaskToolCalls);
// Clear these agents from the subagent store
clearSubagentsByIds(groupItem.agents.map((a) => a.id));
}
// Insert at the position of the first Task tool_call
newlyCommitted.splice(
firstTaskIndex >= 0 ? firstTaskIndex : newlyCommitted.length,
0,
groupItem,
);
if (newlyCommitted.length > 0) {
setStaticItems((prev) => [...prev, ...newlyCommitted]);
}
}, []);
// Clear these agents from the subagent store
clearSubagentsByIds(groupItem.agents.map((a) => a.id));
}
if (deferredCommits.size === 0) {
setDeferredCommitAt(null);
}
if (newlyCommitted.length > 0) {
setStaticItems((prev) => [...prev, ...newlyCommitted]);
}
},
[],
);
// Render-ready transcript
const [lines, setLines] = useState<Line[]>([]);
@@ -1862,6 +1905,16 @@ export default function App({
commitEligibleLines(b);
}, [commitEligibleLines]);
useEffect(() => {
if (deferredCommitAt === null) return;
const delay = Math.max(0, deferredCommitAt - Date.now());
const timer = setTimeout(() => {
setDeferredCommitAt(null);
refreshDerived();
}, delay);
return () => clearTimeout(timer);
}, [deferredCommitAt, refreshDerived]);
// Trailing-edge debounce for bash streaming output (100ms = max 10 updates/sec)
// Unlike refreshDerivedThrottled, this REPLACES pending updates to always show latest state
const streamingRefreshTimeoutRef = useRef<ReturnType<
@@ -2149,7 +2202,7 @@ export default function App({
buffersRef.current.order.push(statusId);
refreshDerived();
commitEligibleLines(buffersRef.current);
commitEligibleLines(buffersRef.current, { deferToolCalls: false });
}
}, [
loadingState,
@@ -4639,6 +4692,7 @@ export default function App({
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
resetDeferredToolCallCommits();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
resetTrajectoryBases();
@@ -4706,6 +4760,7 @@ export default function App({
agentName,
setCommandRunning,
isAgentBusy,
resetDeferredToolCallCommits,
resetTrajectoryBases,
],
);
@@ -4745,6 +4800,7 @@ export default function App({
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
resetDeferredToolCallCommits();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
resetTrajectoryBases();
@@ -4797,7 +4853,13 @@ export default function App({
setCommandRunning(false);
}
},
[refreshDerived, agentId, setCommandRunning, resetTrajectoryBases],
[
refreshDerived,
agentId,
setCommandRunning,
resetDeferredToolCallCommits,
resetTrajectoryBases,
],
);
// Handle bash mode command submission
@@ -6295,6 +6357,7 @@ export default function App({
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
resetDeferredToolCallCommits();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
resetTrajectoryBases();
@@ -9818,9 +9881,11 @@ Plan file path: ${planFilePath}`;
}, [pendingApprovals, approvalResults, sendAllResults]);
// Live area shows only in-progress items
// biome-ignore lint/correctness/useExhaustiveDependencies: staticItems.length and deferredCommitAt are intentional triggers to recompute when items are promoted to static or deferred commits complete
const liveItems = useMemo(() => {
return lines.filter((ln) => {
if (!("phase" in ln)) return false;
if (emittedIdsRef.current.has(ln.id)) return false;
if (ln.kind === "command" || ln.kind === "bash_command") {
return ln.phase === "running";
}
@@ -9833,7 +9898,10 @@ Plan file path: ${planFilePath}`;
return ln.phase === "ready" || ln.phase === "streaming";
}
// Always show other tool calls in progress
return ln.phase !== "finished";
return (
ln.phase !== "finished" ||
deferredToolCallCommitsRef.current.has(ln.id)
);
}
// Events (like compaction) show while running
if (ln.kind === "event") {
@@ -9842,7 +9910,7 @@ Plan file path: ${planFilePath}`;
if (!tokenStreamingEnabled && ln.phase === "streaming") return false;
return ln.phase === "streaming";
});
}, [lines, tokenStreamingEnabled]);
}, [lines, tokenStreamingEnabled, staticItems.length, deferredCommitAt]);
// Subscribe to subagent state for reactive overflow detection
const { agents: subagents } = useSyncExternalStore(
@@ -9978,7 +10046,7 @@ Plan file path: ${planFilePath}`;
});
buffersRef.current.order.push(statusId);
refreshDerived();
commitEligibleLines(buffersRef.current);
commitEligibleLines(buffersRef.current, { deferToolCalls: false });
}
}, [
loadingState,
@@ -10517,6 +10585,7 @@ Plan file path: ${planFilePath}`;
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
resetDeferredToolCallCommits();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
resetTrajectoryBases();
@@ -10682,6 +10751,7 @@ Plan file path: ${planFilePath}`;
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
resetDeferredToolCallCommits();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
resetTrajectoryBases();
@@ -10808,6 +10878,7 @@ Plan file path: ${planFilePath}`;
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
resetDeferredToolCallCommits();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
resetTrajectoryBases();

View File

@@ -182,24 +182,23 @@ export const ToolCallMessage = memo(
// If name exceeds available width, fall back to simple wrapped rendering
const fallback = displayName.length >= rightWidth;
// Determine dot state based on phase
const getDotElement = () => {
const dotColor = (() => {
switch (line.phase) {
case "streaming":
return <Text color={colors.tool.streaming}></Text>;
return colors.tool.streaming;
case "ready":
return <BlinkDot color={colors.tool.pending} />;
return colors.tool.pending;
case "running":
return <BlinkDot color={colors.tool.running} />;
return colors.tool.running;
case "finished":
if (line.resultOk === false) {
return <Text color={colors.tool.error}></Text>;
}
return <Text color={colors.tool.completed}></Text>;
return line.resultOk === false
? colors.tool.error
: colors.tool.completed;
default:
return <Text></Text>;
return undefined;
}
};
})();
const dotShouldAnimate = line.phase === "ready" || line.phase === "running";
// Format result for display
const getResultElement = () => {
@@ -770,7 +769,7 @@ export const ToolCallMessage = memo(
{/* Tool call with exact wrapping logic from old codebase */}
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
{getDotElement()}
<BlinkDot color={dotColor} shouldAnimate={dotShouldAnimate} />
<Text></Text>
</Box>
<Box flexGrow={1} width={rightWidth}>