diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index a76d0dd4ef..97f951667c 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -25,7 +25,9 @@ import { useResizableSidebar } from "@/browser/hooks/useResizableSidebar"; import { shouldShowInterruptedBarrier, mergeConsecutiveStreamErrors, + computeBashOutputGroupInfo, } from "@/browser/utils/messages/messageUtils"; +import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator"; import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility"; import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; import { ModeProvider } from "@/browser/contexts/ModeContext"; @@ -173,23 +175,28 @@ const AIViewInner: React.FC = ({ undefined ); + // Track which bash_output groups are expanded (keyed by first message ID) + const [expandedBashGroups, setExpandedBashGroups] = useState>(new Set()); + // Extract state from workspace state const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState; - // Merge consecutive identical stream errors. + // Apply message transformations: + // 1. Merge consecutive identical stream errors + // (bash_output grouping is done at render-time, not as a transformation) // Use useDeferredValue to allow React to defer the heavy message list rendering // during rapid updates (streaming), keeping the UI responsive. // Must be defined before any early returns to satisfy React Hooks rules. - const mergedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]); - const deferredMergedMessages = useDeferredValue(mergedMessages); + const transformedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]); + const deferredTransformedMessages = useDeferredValue(transformedMessages); // CRITICAL: When message count changes (new message sent/received), show immediately. // Only defer content changes within existing messages (streaming deltas). // This ensures user messages appear instantly while keeping streaming performant. const deferredMessages = - mergedMessages.length !== deferredMergedMessages.length - ? mergedMessages - : deferredMergedMessages; + transformedMessages.length !== deferredTransformedMessages.length + ? transformedMessages + : deferredTransformedMessages; // Get active stream message ID for token counting const activeStreamMessageId = aggregator?.getActiveStreamMessageId(); @@ -309,8 +316,8 @@ const AIViewInner: React.FC = ({ } // Otherwise, edit last user message - const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages); - const lastUserMessage = [...mergedMessages] + const transformedMessages = mergeConsecutiveStreamErrors(workspaceState.messages); + const lastUserMessage = [...transformedMessages] .reverse() .find((msg): msg is Extract => msg.type === "user"); if (lastUserMessage) { @@ -424,8 +431,8 @@ const AIViewInner: React.FC = ({ useEffect(() => { if (!workspaceState || !editingMessage) return; - const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages); - const editCutoffHistoryId = mergedMessages.find( + const transformedMessages = mergeConsecutiveStreamErrors(workspaceState.messages); + const editCutoffHistoryId = transformedMessages.find( (msg): msg is Exclude => msg.type !== "history-hidden" && msg.type !== "workspace-init" && @@ -461,7 +468,7 @@ const AIViewInner: React.FC = ({ // When editing, find the cutoff point const editCutoffHistoryId = editingMessage - ? mergedMessages.find( + ? transformedMessages.find( (msg): msg is Exclude => msg.type !== "history-hidden" && msg.type !== "workspace-init" && @@ -472,8 +479,8 @@ const AIViewInner: React.FC = ({ // Find the ID of the latest propose_plan tool call for external edit detection // Only the latest plan should fetch fresh content from disk let latestProposePlanId: string | null = null; - for (let i = mergedMessages.length - 1; i >= 0; i--) { - const msg = mergedMessages[i]; + for (let i = transformedMessages.length - 1; i >= 0; i--) { + const msg = transformedMessages[i]; if (msg.type === "tool" && msg.toolName === "propose_plan") { latestProposePlanId = msg.id; break; @@ -569,7 +576,21 @@ const AIViewInner: React.FC = ({ ) : ( <> - {deferredMessages.map((msg) => { + {deferredMessages.map((msg, index) => { + // Compute bash_output grouping at render-time + const bashOutputGroup = computeBashOutputGroupInfo(deferredMessages, index); + + // For bash_output groups, use first message ID as expansion key + const groupKey = bashOutputGroup + ? deferredMessages[bashOutputGroup.firstIndex]?.id + : undefined; + const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false; + + // Skip rendering middle items in a bash_output group (unless expanded) + if (bashOutputGroup?.position === "middle" && !isGroupExpanded) { + return null; + } + const isAtCutoff = editCutoffHistoryId !== undefined && msg.type !== "history-hidden" && @@ -599,8 +620,28 @@ const AIViewInner: React.FC = ({ } foregroundBashToolCallIds={foregroundToolCallIds} onSendBashToBackground={handleSendBashToBackground} + bashOutputGroup={bashOutputGroup} /> + {/* Show collapsed indicator after the first item in a bash_output group */} + {bashOutputGroup?.position === "first" && groupKey && ( + { + setExpandedBashGroups((prev) => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }} + /> + )} {isAtCutoff && (
⚠️ Messages below this line will be removed when you submit the edit diff --git a/src/browser/components/Messages/MessageRenderer.tsx b/src/browser/components/Messages/MessageRenderer.tsx index 8e3e29425d..7387873c8a 100644 --- a/src/browser/components/Messages/MessageRenderer.tsx +++ b/src/browser/components/Messages/MessageRenderer.tsx @@ -1,5 +1,6 @@ import React from "react"; import type { DisplayedMessage } from "@/common/types/message"; +import type { BashOutputGroupInfo } from "@/browser/utils/messages/messageUtils"; import type { ReviewNoteData } from "@/common/types/review"; import { UserMessage } from "./UserMessage"; import { AssistantMessage } from "./AssistantMessage"; @@ -26,6 +27,8 @@ interface MessageRendererProps { foregroundBashToolCallIds?: Set; /** Callback to send a foreground bash to background */ onSendBashToBackground?: (toolCallId: string) => void; + /** Optional bash_output grouping info (computed at render-time) */ + bashOutputGroup?: BashOutputGroupInfo; } // Memoized to prevent unnecessary re-renders when parent (AIView) updates @@ -40,6 +43,7 @@ export const MessageRenderer = React.memo( isLatestProposePlan, foregroundBashToolCallIds, onSendBashToBackground, + bashOutputGroup, }) => { // Route based on message type switch (message.type) { @@ -71,6 +75,7 @@ export const MessageRenderer = React.memo( isLatestProposePlan={isLatestProposePlan} foregroundBashToolCallIds={foregroundBashToolCallIds} onSendBashToBackground={onSendBashToBackground} + bashOutputGroup={bashOutputGroup} /> ); case "reasoning": diff --git a/src/browser/components/Messages/ToolMessage.tsx b/src/browser/components/Messages/ToolMessage.tsx index b0356f2461..2d4e68948f 100644 --- a/src/browser/components/Messages/ToolMessage.tsx +++ b/src/browser/components/Messages/ToolMessage.tsx @@ -39,6 +39,7 @@ import type { WebFetchToolResult, } from "@/common/types/tools"; import type { ReviewNoteData } from "@/common/types/review"; +import type { BashOutputGroupInfo } from "@/browser/utils/messages/messageUtils"; interface ToolMessageProps { message: DisplayedMessage & { type: "tool" }; @@ -52,6 +53,8 @@ interface ToolMessageProps { foregroundBashToolCallIds?: Set; /** Callback to send a foreground bash to background */ onSendBashToBackground?: (toolCallId: string) => void; + /** Optional bash_output grouping info */ + bashOutputGroup?: BashOutputGroupInfo; } // Type guards using Zod schemas for single source of truth @@ -133,6 +136,7 @@ export const ToolMessage: React.FC = ({ isLatestProposePlan, foregroundBashToolCallIds, onSendBashToBackground, + bashOutputGroup, }) => { // Route to specialized components based on tool name if (isBashTool(message.toolName, message.args)) { @@ -284,12 +288,20 @@ export const ToolMessage: React.FC = ({ } if (isBashOutputTool(message.toolName, message.args)) { + // Note: "middle" position items are filtered out in AIView.tsx render loop, + // and the collapsed indicator is rendered there. ToolMessage only sees first/last. + const groupPosition = + bashOutputGroup?.position === "first" || bashOutputGroup?.position === "last" + ? bashOutputGroup.position + : undefined; + return (
); diff --git a/src/browser/components/tools/BashOutputCollapsedIndicator.tsx b/src/browser/components/tools/BashOutputCollapsedIndicator.tsx new file mode 100644 index 0000000000..ff58f495d8 --- /dev/null +++ b/src/browser/components/tools/BashOutputCollapsedIndicator.tsx @@ -0,0 +1,52 @@ +import React from "react"; + +interface BashOutputCollapsedIndicatorProps { + processId: string; + collapsedCount: number; + isExpanded: boolean; + onToggle: () => void; +} + +/** + * Visual indicator showing collapsed bash_output calls. + * Renders as a squiggly line with count badge between the first and last calls. + * Clickable to expand/collapse the hidden calls. + */ +export const BashOutputCollapsedIndicator: React.FC = ({ + processId, + collapsedCount, + isExpanded, + onToggle, +}) => { + return ( +
+ +
+ ); +}; diff --git a/src/browser/components/tools/BashOutputToolCall.tsx b/src/browser/components/tools/BashOutputToolCall.tsx index de1ff9dbef..bae1f91b94 100644 --- a/src/browser/components/tools/BashOutputToolCall.tsx +++ b/src/browser/components/tools/BashOutputToolCall.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Layers } from "lucide-react"; +import { Layers, Link } from "lucide-react"; import type { BashOutputToolArgs, BashOutputToolResult } from "@/common/types/tools"; import { ToolContainer, @@ -23,6 +23,8 @@ interface BashOutputToolCallProps { args: BashOutputToolArgs; result?: BashOutputToolResult; status?: ToolStatus; + /** Position in a group of consecutive bash_output calls (undefined if not grouped) */ + groupPosition?: "first" | "last"; } /** @@ -33,6 +35,7 @@ export const BashOutputToolCall: React.FC = ({ args, result, status = "pending", + groupPosition, }) => { const { expanded, toggleExpanded } = useToolExpansion(); @@ -50,6 +53,11 @@ export const BashOutputToolCall: React.FC = ({ output {args.timeout_secs > 0 && ` • wait ${args.timeout_secs}s`} {args.filter && ` • filter: ${args.filter}`} + {groupPosition && ( + + • {groupPosition === "first" ? "start" : "end"} + + )} {result?.success && } {result?.success && processStatus && processStatus !== "running" && ( diff --git a/src/browser/stories/App.bash.stories.tsx b/src/browser/stories/App.bash.stories.tsx index deb05ccf50..530e88b44f 100644 --- a/src/browser/stories/App.bash.stories.tsx +++ b/src/browser/stories/App.bash.stories.tsx @@ -348,3 +348,89 @@ export const Mixed: AppStory = { await expandAllBashTools(canvasElement); }, }; + +/** + * Story: Grouped Bash Output + * Demonstrates the collapsing of consecutive bash_output calls to the same process. + * Grouping is computed at render-time (not as a message transformation). + * Shows: + * - 5 consecutive output calls to same process: first, collapsed indicator, last + * - Group position labels (🔗 start/end) on first and last items + * - Non-grouped bash_output calls for comparison (groups of 1-2) + * - Mixed process IDs are not grouped together + */ +export const GroupedOutput: AppStory = { + render: () => ( + + setupSimpleChatStory({ + workspaceId: "ws-grouped-output", + messages: [ + // Background process started + createUserMessage("msg-1", "Start a dev server and monitor it", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 800000, + }), + createAssistantMessage("msg-2", "Starting dev server:", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 790000, + toolCalls: [ + createBackgroundBashTool("call-1", "npm run dev", "bash_1", "Dev Server"), + ], + }), + // Multiple consecutive output checks (will be grouped) + createUserMessage("msg-3", "Keep checking the server output", { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 700000, + }), + createAssistantMessage("msg-4", "Monitoring server output:", { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 690000, + toolCalls: [ + // These 5 consecutive calls will be collapsed to 3 items + createBashOutputTool("call-2", "bash_1", "Starting compilation...", "running"), + createBashOutputTool("call-3", "bash_1", "Compiling src/index.ts...", "running"), + createBashOutputTool("call-4", "bash_1", "Compiling src/utils.ts...", "running"), + createBashOutputTool("call-5", "bash_1", "Compiling src/components/...", "running"), + createBashOutputTool( + "call-6", + "bash_1", + " VITE v5.0.0 ready in 320 ms\n\n ➜ Local: http://localhost:5173/", + "running" + ), + ], + }), + // Non-grouped output (only 2 consecutive calls - no grouping) + createUserMessage("msg-5", "Check both servers briefly", { + historySequence: 5, + timestamp: STABLE_TIMESTAMP - 500000, + }), + createAssistantMessage("msg-6", "Checking servers:", { + historySequence: 6, + timestamp: STABLE_TIMESTAMP - 490000, + toolCalls: [ + // Only 2 calls - no grouping + createBashOutputTool("call-7", "bash_1", "Server healthy", "running"), + createBashOutputTool("call-8", "bash_1", "", "running"), + ], + }), + // Mixed: different process IDs (no grouping across processes) + createUserMessage("msg-7", "Check dev server and build process", { + historySequence: 7, + timestamp: STABLE_TIMESTAMP - 300000, + }), + createAssistantMessage("msg-8", "Status of both processes:", { + historySequence: 8, + timestamp: STABLE_TIMESTAMP - 290000, + toolCalls: [ + createBashOutputTool("call-9", "bash_1", "Server running", "running"), + createBashOutputTool("call-10", "bash_2", "Build in progress", "running"), + createBashOutputTool("call-11", "bash_1", "New request received", "running"), + ], + }), + ], + }) + } + /> + ), +}; diff --git a/src/browser/stories/mockFactory.ts b/src/browser/stories/mockFactory.ts index d3e1109370..34ab12c217 100644 --- a/src/browser/stories/mockFactory.ts +++ b/src/browser/stories/mockFactory.ts @@ -397,14 +397,15 @@ export function createBashOutputTool( output: string, status: "running" | "exited" | "killed" | "failed" = "running", exitCode?: number, - filter?: string + filter?: string, + timeoutSecs = 5 ): MuxPart { return { type: "dynamic-tool", toolCallId, toolName: "bash_output", state: "output-available", - input: { process_id: processId, filter }, + input: { process_id: processId, timeout_secs: timeoutSecs, filter }, output: { success: true, status, output, exitCode }, }; } @@ -413,14 +414,15 @@ export function createBashOutputTool( export function createBashOutputErrorTool( toolCallId: string, processId: string, - error: string + error: string, + timeoutSecs = 5 ): MuxPart { return { type: "dynamic-tool", toolCallId, toolName: "bash_output", state: "output-available", - input: { process_id: processId }, + input: { process_id: processId, timeout_secs: timeoutSecs }, output: { success: false, error }, }; } diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index 8c86ba58a2..69fa041edb 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "@jest/globals"; -import { mergeConsecutiveStreamErrors } from "./messageUtils"; +import { mergeConsecutiveStreamErrors, computeBashOutputGroupInfo } from "./messageUtils"; import type { DisplayedMessage } from "@/common/types/message"; describe("mergeConsecutiveStreamErrors", () => { @@ -288,3 +288,195 @@ describe("mergeConsecutiveStreamErrors", () => { }); }); }); + +describe("computeBashOutputGroupInfo", () => { + // Helper to create a bash_output tool message + function createBashOutputMessage( + id: string, + processId: string, + historySequence: number + ): DisplayedMessage { + return { + type: "tool", + id, + historyId: `h-${id}`, + toolCallId: `tc-${id}`, + toolName: "bash_output", + args: { process_id: processId, timeout_secs: 0 }, + result: { success: true, status: "running", output: `output-${id}` }, + status: "completed", + isPartial: false, + historySequence, + }; + } + + it("returns undefined for non-bash_output messages", () => { + const messages: DisplayedMessage[] = [ + { + type: "user", + id: "1", + historyId: "h1", + content: "test", + historySequence: 1, + }, + { + type: "tool", + id: "2", + historyId: "h2", + toolCallId: "tc2", + toolName: "file_read", + args: { filePath: "/test" }, + status: "completed", + isPartial: false, + historySequence: 2, + }, + ]; + + expect(computeBashOutputGroupInfo(messages, 0)).toBeUndefined(); + expect(computeBashOutputGroupInfo(messages, 1)).toBeUndefined(); + }); + + it("returns undefined for 1-2 consecutive bash_output calls", () => { + const messages: DisplayedMessage[] = [ + createBashOutputMessage("1", "bash_1", 1), + createBashOutputMessage("2", "bash_1", 2), + ]; + + // Groups of 1-2 don't need grouping + expect(computeBashOutputGroupInfo(messages, 0)).toBeUndefined(); + expect(computeBashOutputGroupInfo(messages, 1)).toBeUndefined(); + }); + + it("returns correct group info for 3+ consecutive bash_output calls", () => { + const messages: DisplayedMessage[] = [ + createBashOutputMessage("1", "bash_1", 1), + createBashOutputMessage("2", "bash_1", 2), + createBashOutputMessage("3", "bash_1", 3), + createBashOutputMessage("4", "bash_1", 4), + ]; + + // First position + expect(computeBashOutputGroupInfo(messages, 0)).toMatchObject({ + position: "first", + totalCount: 4, + collapsedCount: 2, + processId: "bash_1", + }); + + // Middle positions + expect(computeBashOutputGroupInfo(messages, 1)).toMatchObject({ + position: "middle", + totalCount: 4, + collapsedCount: 2, + processId: "bash_1", + }); + expect(computeBashOutputGroupInfo(messages, 2)).toMatchObject({ + position: "middle", + totalCount: 4, + collapsedCount: 2, + processId: "bash_1", + }); + + // Last position + expect(computeBashOutputGroupInfo(messages, 3)).toMatchObject({ + position: "last", + totalCount: 4, + collapsedCount: 2, + processId: "bash_1", + }); + }); + + it("does not group bash_output calls to different processes", () => { + const messages: DisplayedMessage[] = [ + createBashOutputMessage("1", "bash_1", 1), + createBashOutputMessage("2", "bash_1", 2), + createBashOutputMessage("3", "bash_2", 3), // Different process + createBashOutputMessage("4", "bash_1", 4), + ]; + + // No grouping should occur (max consecutive same-process is 2) + expect(computeBashOutputGroupInfo(messages, 0)).toBeUndefined(); + expect(computeBashOutputGroupInfo(messages, 1)).toBeUndefined(); + expect(computeBashOutputGroupInfo(messages, 2)).toBeUndefined(); + expect(computeBashOutputGroupInfo(messages, 3)).toBeUndefined(); + }); + + it("handles multiple separate groups", () => { + const messages: DisplayedMessage[] = [ + createBashOutputMessage("1", "bash_1", 1), + createBashOutputMessage("2", "bash_1", 2), + createBashOutputMessage("3", "bash_1", 3), + { + type: "user", + id: "u1", + historyId: "hu1", + content: "check other", + historySequence: 4, + }, + createBashOutputMessage("4", "bash_2", 5), + createBashOutputMessage("5", "bash_2", 6), + createBashOutputMessage("6", "bash_2", 7), + ]; + + // First group + expect(computeBashOutputGroupInfo(messages, 0)?.position).toBe("first"); + expect(computeBashOutputGroupInfo(messages, 1)?.position).toBe("middle"); + expect(computeBashOutputGroupInfo(messages, 2)?.position).toBe("last"); + + // User message (not grouped) + expect(computeBashOutputGroupInfo(messages, 3)).toBeUndefined(); + + // Second group + expect(computeBashOutputGroupInfo(messages, 4)?.position).toBe("first"); + expect(computeBashOutputGroupInfo(messages, 4)?.processId).toBe("bash_2"); + expect(computeBashOutputGroupInfo(messages, 5)?.position).toBe("middle"); + expect(computeBashOutputGroupInfo(messages, 6)?.position).toBe("last"); + }); + + it("handles exactly 3 consecutive calls (minimum for grouping)", () => { + const messages: DisplayedMessage[] = [ + createBashOutputMessage("1", "bash_1", 1), + createBashOutputMessage("2", "bash_1", 2), + createBashOutputMessage("3", "bash_1", 3), + ]; + + expect(computeBashOutputGroupInfo(messages, 0)).toMatchObject({ + position: "first", + collapsedCount: 1, + }); + expect(computeBashOutputGroupInfo(messages, 1)).toMatchObject({ + position: "middle", + collapsedCount: 1, + }); + expect(computeBashOutputGroupInfo(messages, 2)).toMatchObject({ + position: "last", + collapsedCount: 1, + }); + }); + + it("correctly identifies process_id in group info", () => { + const messages: DisplayedMessage[] = [ + createBashOutputMessage("1", "my-special-process", 1), + createBashOutputMessage("2", "my-special-process", 2), + createBashOutputMessage("3", "my-special-process", 3), + ]; + + const groupInfo = computeBashOutputGroupInfo(messages, 0); + expect(groupInfo?.processId).toBe("my-special-process"); + }); + + it("includes firstIndex for all positions in group", () => { + const messages: DisplayedMessage[] = [ + createBashOutputMessage("1", "proc", 1), + createBashOutputMessage("2", "proc", 2), + createBashOutputMessage("3", "proc", 3), + createBashOutputMessage("4", "proc", 4), + ]; + + // All positions should report firstIndex as 0 + expect(computeBashOutputGroupInfo(messages, 0)?.firstIndex).toBe(0); // first + expect(computeBashOutputGroupInfo(messages, 1)?.firstIndex).toBe(0); // middle + expect(computeBashOutputGroupInfo(messages, 2)?.firstIndex).toBe(0); // middle + expect(computeBashOutputGroupInfo(messages, 3)?.firstIndex).toBe(0); // last + }); +}); diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 3bd9cb2caf..064f98f189 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -1,4 +1,41 @@ import type { DisplayedMessage } from "@/common/types/message"; +import type { BashOutputToolArgs } from "@/common/types/tools"; + +/** + * Type guard to check if a message is a bash_output tool call with valid args + */ +export function isBashOutputTool( + msg: DisplayedMessage +): msg is DisplayedMessage & { type: "tool"; toolName: "bash_output"; args: BashOutputToolArgs } { + if (msg.type !== "tool" || msg.toolName !== "bash_output") { + return false; + } + // Validate args has required process_id field + const args = msg.args; + return ( + typeof args === "object" && + args !== null && + "process_id" in args && + typeof (args as { process_id: unknown }).process_id === "string" + ); +} + +/** + * Information about a bash_output message's position in a consecutive group. + * Used at render-time to determine how to display the message. + */ +export interface BashOutputGroupInfo { + /** Position in the group: 'first', 'last', or 'middle' (collapsed) */ + position: "first" | "last" | "middle"; + /** Total number of calls in this group */ + totalCount: number; + /** Number of collapsed (hidden) calls between first and last */ + collapsedCount: number; + /** Process ID for the collapsed indicator */ + processId: string; + /** Index of the first message in this group (used as expand/collapse key) */ + firstIndex: number; +} /** * Determines if the interrupted barrier should be shown for a DisplayedMessage. @@ -92,3 +129,89 @@ export function mergeConsecutiveStreamErrors(messages: DisplayedMessage[]): Disp return result; } + +/** + * Computes the bash_output group info for a message at a given index. + * Used at render-time to determine how to display bash_output messages. + * + * Returns: + * - undefined if not a bash_output tool or group size < 3 + * - { position: 'first', ... } for the first item in a 3+ group + * - { position: 'middle', ... } for middle items that should be collapsed + * - { position: 'last', ... } for the last item in a 3+ group + * + * @param messages - The full array of DisplayedMessages + * @param index - The index of the message to check + * @returns Group info if in a 3+ group, undefined otherwise + */ +export function computeBashOutputGroupInfo( + messages: DisplayedMessage[], + index: number +): BashOutputGroupInfo | undefined { + const msg = messages[index]; + + // Not a bash_output tool + if (!isBashOutputTool(msg)) { + return undefined; + } + + const processId = msg.args.process_id; + + // Find the start of the consecutive group (walk backwards) + let groupStart = index; + while (groupStart > 0) { + const prevMsg = messages[groupStart - 1]; + if (isBashOutputTool(prevMsg) && prevMsg.args.process_id === processId) { + groupStart--; + } else { + break; + } + } + + // Find the end of the consecutive group (walk forwards) + let groupEnd = index; + while (groupEnd < messages.length - 1) { + const nextMsg = messages[groupEnd + 1]; + if (isBashOutputTool(nextMsg) && nextMsg.args.process_id === processId) { + groupEnd++; + } else { + break; + } + } + + const groupSize = groupEnd - groupStart + 1; + + // Groups of 1-2 don't need special handling + if (groupSize < 3) { + return undefined; + } + + const collapsedCount = groupSize - 2; + + // Determine position + if (index === groupStart) { + return { + position: "first", + totalCount: groupSize, + collapsedCount, + processId, + firstIndex: groupStart, + }; + } else if (index === groupEnd) { + return { + position: "last", + totalCount: groupSize, + collapsedCount, + processId, + firstIndex: groupStart, + }; + } else { + return { + position: "middle", + totalCount: groupSize, + collapsedCount, + processId, + firstIndex: groupStart, + }; + } +}