diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 648d3ad..3cd0ad3 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -2742,6 +2742,45 @@ export default function App({ initialInput: Array, options?: { allowReentry?: boolean; submissionGeneration?: number }, ): Promise => { + // Reset per-run approval tracking used by streaming UI. + buffersRef.current.approvalsPending = false; + if (buffersRef.current.serverToolCalls.size > 0) { + let didPromote = false; + for (const [toolCallId, toolInfo] of buffersRef.current + .serverToolCalls) { + const lineId = buffersRef.current.toolCallIdToLineId.get(toolCallId); + if (!lineId) continue; + const line = buffersRef.current.byId.get(lineId); + if (!line || line.kind !== "tool_call" || line.phase === "finished") { + continue; + } + const argsCandidate = toolInfo.toolArgs ?? ""; + const trimmed = argsCandidate.trim(); + let argsComplete = false; + if (trimmed.length === 0) { + argsComplete = true; + } else { + try { + JSON.parse(argsCandidate); + argsComplete = true; + } catch { + // Args still incomplete. + } + } + if (argsComplete && line.phase !== "running") { + const nextLine = { + ...line, + phase: "running" as const, + argsText: line.argsText ?? argsCandidate, + }; + buffersRef.current.byId.set(lineId, nextLine); + didPromote = true; + } + } + if (didPromote) { + refreshDerived(); + } + } // Helper function for Ralph Wiggum mode continuation // Defined here to have access to buffersRef, processConversation via closure const handleRalphContinuation = () => { @@ -8737,7 +8776,7 @@ ${SYSTEM_REMINDER_CLOSE} refreshDerived(); } toolResultsInFlightRef.current = true; - await processConversation(input); + await processConversation(input, { allowReentry: true }); toolResultsInFlightRef.current = false; // Clear any stale queued results from previous interrupts. diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index 19443f4..85e10b6 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -104,7 +104,12 @@ export const ToolCallMessage = memo( // Parse and format the tool call const rawName = line.name ?? "?"; - const argsText = line.argsText ?? "..."; + const argsText = + typeof line.argsText === "string" + ? line.argsText + : line.argsText == null + ? "" + : JSON.stringify(line.argsText); // Task tool rendering decision: // - Cancelled/rejected: render as error tool call (won't appear in SubagentGroupDisplay) @@ -165,16 +170,35 @@ export const ToolCallMessage = memo( // - Phase "running"/"finished" or stream done: args complete, show formatted let args = ""; if (!isQuestionTool(rawName)) { - // Args are complete once running, finished, or stream is done + const parseArgs = (): { + formatted: ReturnType | null; + parseable: boolean; + } => { + if (!argsText.trim()) { + return { formatted: null, parseable: true }; + } + try { + const formatted = formatArgsDisplay(argsText, rawName); + return { formatted, parseable: true }; + } catch { + return { formatted: null, parseable: false }; + } + }; + + // Args are complete once running/finished, stream done, or JSON is parseable. + const { formatted, parseable } = parseArgs(); const argsComplete = - line.phase === "running" || line.phase === "finished" || !isStreaming; + parseable || + line.phase === "running" || + line.phase === "finished" || + !isStreaming; if (!argsComplete) { args = "(…)"; } else { - const formatted = formatArgsDisplay(argsText, rawName); + const formattedArgs = formatted ?? formatArgsDisplay(argsText, rawName); // Normalize newlines to spaces to prevent forced line breaks - const normalizedDisplay = formatted.display.replace(/\n/g, " "); + const normalizedDisplay = formattedArgs.display.replace(/\n/g, " "); // For max 2 lines: boxWidth * 2, minus parens (2) and margin (2) const argsBoxWidth = rightWidth - displayName.length; const maxArgsChars = Math.max(0, argsBoxWidth * 2 - 4); @@ -206,7 +230,8 @@ export const ToolCallMessage = memo( return undefined; } })(); - const dotShouldAnimate = line.phase === "ready" || line.phase === "running"; + const dotShouldAnimate = + line.phase === "running" || (line.phase === "ready" && !isStreaming); // Format result for display const getResultElement = () => { diff --git a/src/cli/components/colors.ts b/src/cli/components/colors.ts index 2e41ccd..afa3dba 100644 --- a/src/cli/components/colors.ts +++ b/src/cli/components/colors.ts @@ -47,7 +47,7 @@ export const brandColors = { textDisabled: "#46484A", // dark grey // status colors statusSuccess: "#64CF64", // green - statusWarning: "FEE19C", // yellow + statusWarning: "#FEE19C", // yellow statusError: "#F1689F", // red } as const; @@ -126,8 +126,8 @@ const _colors = { tool: { pending: brandColors.textSecondary, // blinking dot (ready/waiting for approval) completed: brandColors.statusSuccess, // solid green dot (finished successfully) - streaming: brandColors.textDisabled, // solid gray dot (streaming/in progress) - running: brandColors.statusWarning, // blinking yellow dot (executing) + streaming: brandColors.textSecondary, // solid gray dot (streaming/in progress) + running: brandColors.textSecondary, // blinking gray dot (executing) error: brandColors.statusError, // solid red dot (failed) memoryName: brandColors.primaryAccent, // memory tool name highlight (matches thinking spinner) }, diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 6d98621..529cfc3 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -224,6 +224,8 @@ export type Buffers = { splitCounters: Map; // tracks split count per original otid // Track server-side tool calls for hook triggering (toolCallId -> info) serverToolCalls: Map; + // Track if this run has pending approvals (used to gate server tool phases) + approvalsPending: boolean; // Agent ID for passing to hooks (needed for server-side tools like memory) agentId?: string; }; @@ -250,6 +252,7 @@ export function createBuffers(agentId?: string): Buffers { tokenStreamingEnabled: false, splitCounters: new Map(), serverToolCalls: new Map(), + approvalsPending: false, agentId, }; } @@ -587,8 +590,10 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { b.toolCallIdToLineId.set(toolCallId, id); } - // Tool calls should be "ready" (blinking) while pending execution - const desiredPhase = "ready"; + // Tool calls start in "streaming" (static grey) while args stream in. + // Approval requests move to "ready" (blinking), server tools move to + // "running" once args are complete. + const desiredPhase = "streaming"; let line = ensure(b, id, () => ({ kind: "tool_call", id, @@ -612,7 +617,23 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { chunk.message_type === "approval_request_message" && line.phase !== "finished" ) { - b.byId.set(id, { ...line, phase: "ready" }); + b.approvalsPending = true; + line = { ...line, phase: "ready" }; + b.byId.set(id, line); + + // Downgrade any server tools to streaming while approvals are pending. + for (const [toolCallId] of b.serverToolCalls) { + const serverLineId = b.toolCallIdToLineId.get(toolCallId); + if (!serverLineId) continue; + const serverLine = b.byId.get(serverLineId); + if ( + serverLine && + serverLine.kind === "tool_call" && + serverLine.phase === "running" + ) { + b.byId.set(serverLineId, { ...serverLine, phase: "streaming" }); + } + } } // if argsText is not empty, add it to the line (immutable update) @@ -622,6 +643,7 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { ...line, argsText: (line.argsText || "") + argsText, }; + line = updatedLine; b.byId.set(id, updatedLine); // Count tool call arguments as LLM output tokens b.tokenCount += argsText.length;