all: support hiding subconvo output

Some of it is systematically noisy.
diff --git a/claudetool/codereview/llm_review.go b/claudetool/codereview/llm_review.go
index 15205e7..32a8e00 100644
--- a/claudetool/codereview/llm_review.go
+++ b/claudetool/codereview/llm_review.go
@@ -32,6 +32,7 @@
 	info := conversation.ToolCallInfoFromContext(ctx)
 	convo := info.Convo.SubConvo()
 	convo.PromptCaching = false
+	convo.Hidden = true
 	convo.SystemPrompt = strings.TrimSpace(llmCodereviewPrompt)
 	initialMessage := llm.UserStringMessage("<diff>\n" + string(out) + "\n</diff>")
 
diff --git a/claudetool/pre-commit.go b/claudetool/pre-commit.go
index 306c7b9..f8fe5a0 100644
--- a/claudetool/pre-commit.go
+++ b/claudetool/pre-commit.go
@@ -55,6 +55,7 @@
 
 	info := conversation.ToolCallInfoFromContext(ctx)
 	sub := info.Convo.SubConvo()
+	sub.Hidden = true
 	sub.PromptCaching = false
 
 	sub.SystemPrompt = `Analyze the provided git commit messages to identify consistent patterns, including but not limited to:
diff --git a/llm/conversation/convo.go b/llm/conversation/convo.go
index 5a12256..7860a07 100644
--- a/llm/conversation/convo.go
+++ b/llm/conversation/convo.go
@@ -79,6 +79,9 @@
 	// The Conversation DOES NOT automatically enforce the budget.
 	// It is up to the caller to call OverBudget() as appropriate.
 	Budget Budget
+	// Hidden indicates that the output of this conversation should be hidden in the UI.
+	// This is useful for subconversations that can generate noisy, uninteresting output.
+	Hidden bool
 
 	// messages tracks the messages so far in the conversation.
 	messages []llm.Message
diff --git a/loop/agent.go b/loop/agent.go
index 0f08d36..95d7391 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -165,10 +165,14 @@
 	// Turn duration - the time taken for a complete agent turn
 	TurnDuration *time.Duration `json:"turnDuration,omitempty"`
 
+	// HideOutput indicates that this message should not be rendered in the UI.
+	// This is useful for subconversations that generate output that shouldn't be shown to the user.
+	HideOutput bool `json:"hide_output,omitempty"`
+
 	Idx int `json:"idx"`
 }
 
-// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
+// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
 func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
 	if convo == nil {
 		m.ConversationID = ""
@@ -176,6 +180,7 @@
 		return
 	}
 	m.ConversationID = convo.ID
+	m.HideOutput = convo.Hidden
 	if convo.Parent != nil {
 		m.ParentConversationID = &convo.Parent.ID
 	}
diff --git a/termui/termui.go b/termui/termui.go
index 1ffe655..7e4def9 100644
--- a/termui/termui.go
+++ b/termui/termui.go
@@ -150,6 +150,9 @@
 		if resp == nil {
 			return
 		}
+		if resp.HideOutput {
+			continue
+		}
 		// Typically a user message will start the thinking and a (top-level
 		// conversation) end of turn will stop it.
 		thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
diff --git a/webui/src/types.ts b/webui/src/types.ts
index 9bc1e6d..78a73b0 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -45,6 +45,7 @@
 	end_time?: string | null;
 	elapsed?: Duration | null;
 	turnDuration?: Duration | null;
+	hide_output?: boolean;
 	idx: number;
 }
 
diff --git a/webui/src/web-components/sketch-timeline-message.ts b/webui/src/web-components/sketch-timeline-message.ts
index b148203..f28b1a3 100644
--- a/webui/src/web-components/sketch-timeline-message.ts
+++ b/webui/src/web-components/sketch-timeline-message.ts
@@ -1090,11 +1090,11 @@
     pointer-events: none;
     transition: opacity 0.3s ease, transform 0.3s ease;
   }
-  
+
   .floating-message.success {
     background-color: rgba(40, 167, 69, 0.9);
   }
-  
+
   .floating-message.error {
     background-color: rgba(220, 53, 69, 0.9);
   }
diff --git a/webui/src/web-components/sketch-timeline.ts b/webui/src/web-components/sketch-timeline.ts
index 7fbe83a..64f3db0 100644
--- a/webui/src/web-components/sketch-timeline.ts
+++ b/webui/src/web-components/sketch-timeline.ts
@@ -321,17 +321,33 @@
     return html`
       <div id="scroll-container">
         <div class="timeline-container">
-          ${repeat(this.messages, this.messageKey, (message, index) => {
-            let previousMessage: AgentMessage;
-            if (index > 0) {
-              previousMessage = this.messages[index - 1];
-            }
-            return html`<sketch-timeline-message
-              .message=${message}
-              .previousMessage=${previousMessage}
-              .open=${false}
-            ></sketch-timeline-message>`;
-          })}
+          ${repeat(
+            this.messages.filter((msg) => !msg.hide_output),
+            this.messageKey,
+            (message, index) => {
+              let previousMessageIndex =
+                this.messages.findIndex((m) => m === message) - 1;
+              let previousMessage =
+                previousMessageIndex >= 0
+                  ? this.messages[previousMessageIndex]
+                  : undefined;
+
+              // Skip hidden messages when determining previous message
+              while (previousMessage && previousMessage.hide_output) {
+                previousMessageIndex--;
+                previousMessage =
+                  previousMessageIndex >= 0
+                    ? this.messages[previousMessageIndex]
+                    : undefined;
+              }
+
+              return html`<sketch-timeline-message
+                .message=${message}
+                .previousMessage=${previousMessage}
+                .open=${false}
+              ></sketch-timeline-message>`;
+            },
+          )}
           ${isThinking
             ? html`
                 <div class="thinking-indicator">