From 2cde08b3a8d977009bb370a9989e5508c404e67f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 3 Aug 2026 19:54:32 -0700 Subject: [PATCH] [eric] chat: builtin coding tools auto-elevate to the vendored terminal/code/diff components, no ShowUI needed --- .../AgentChat/parsing/richResultDispatch.ts | 116 ++++++++++++++++++ .../tool-bubbles/DefaultToolBubble.tsx | 20 ++- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/pages/AgentChat/parsing/richResultDispatch.ts diff --git a/frontend/src/app/pages/AgentChat/parsing/richResultDispatch.ts b/frontend/src/app/pages/AgentChat/parsing/richResultDispatch.ts new file mode 100644 index 00000000..bae7ab54 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/parsing/richResultDispatch.ts @@ -0,0 +1,116 @@ +import type { ParsedResult } from './toolResultParsing'; + +export interface RichRender { + name: 'terminal' | 'code-block' | 'code-diff'; + props: Record; +} + +const EXT_LANGUAGE: Record = { + ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', py: 'python', rb: 'ruby', + go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', swift: 'swift', c: 'c', h: 'c', + cpp: 'cpp', hpp: 'cpp', cs: 'csharp', sh: 'bash', bash: 'bash', zsh: 'bash', + json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown', html: 'html', + css: 'css', scss: 'scss', sql: 'sql', xml: 'xml', txt: 'text', +}; + +export function languageForPath(path: string): string { + const ext = (path.split('.').pop() || '').toLowerCase(); + return EXT_LANGUAGE[ext] ?? 'text'; +} + +// Keep highlighting off giant bodies: shiki on a 1MB Read janks the canvas; plain
 handles those.
+const MAX_RICH_CHARS = 60_000;
+const COLLAPSED_LINES = 12;
+
+// The `cat -n` line prefix Read results carry ("   12\tcode"); stripped so the code block shows code.
+const READ_LINE_PREFIX_RE = /^\s{0,8}\d+\t/;
+
+function stripReadLinePrefixes(text: string): string {
+  const lines = text.split('\n');
+  const prefixed = lines.filter((l) => l.length === 0 || READ_LINE_PREFIX_RE.test(l)).length;
+  if (prefixed < lines.length * 0.9) return text;
+  return lines.map((l) => l.replace(READ_LINE_PREFIX_RE, '')).join('\n');
+}
+
+/** Map a finished builtin tool call onto a vendored display component, or null for the classic
+ * bubble. Display-only components ONLY: tool output is untrusted text, so it must never pick an
+ * interactive component (approval-card, question-flow), and the component choice comes from OUR
+ * tool-name rules, never from anything inside the output itself. */
+export function resolveRichRender(
+  toolName: string,
+  input: Record,
+  parsed: ParsedResult | null,
+  resultElapsedMs: number | null,
+  callId: string,
+): RichRender | null {
+  try {
+    const n = toolName.toLowerCase();
+
+    if ((n === 'bash') && parsed?.type === 'bash') {
+      const command = typeof input.command === 'string' ? input.command : '';
+      if (!command || (parsed.stdout.length + parsed.stderr.length) > MAX_RICH_CHARS) return null;
+      return {
+        name: 'terminal',
+        props: {
+          id: `auto-${callId}`,
+          command,
+          stdout: parsed.stdout || undefined,
+          stderr: parsed.stderr || undefined,
+          exitCode: parsed.exitCode ?? 0,
+          durationMs: resultElapsedMs ?? undefined,
+          maxCollapsedLines: COLLAPSED_LINES,
+        },
+      };
+    }
+
+    if ((n === 'edit' || n === 'strreplace') && typeof input.file_path === 'string') {
+      const oldCode = typeof input.old_string === 'string' ? input.old_string : '';
+      const newCode = typeof input.new_string === 'string' ? input.new_string : '';
+      if ((!oldCode && !newCode) || oldCode.length + newCode.length > MAX_RICH_CHARS) return null;
+      return {
+        name: 'code-diff',
+        props: {
+          id: `auto-${callId}`,
+          oldCode,
+          newCode,
+          filename: input.file_path,
+          language: languageForPath(input.file_path),
+          maxCollapsedLines: COLLAPSED_LINES,
+        },
+      };
+    }
+
+    if (n === 'write' && typeof input.file_path === 'string' && typeof input.content === 'string') {
+      if (!input.content || input.content.length > MAX_RICH_CHARS) return null;
+      return {
+        name: 'code-block',
+        props: {
+          id: `auto-${callId}`,
+          code: input.content,
+          filename: input.file_path,
+          language: languageForPath(input.file_path),
+          maxCollapsedLines: COLLAPSED_LINES,
+        },
+      };
+    }
+
+    if (n === 'read' && parsed?.type === 'text' && typeof input.file_path === 'string') {
+      const body = stripReadLinePrefixes(parsed.content);
+      if (!body.trim() || body.length > MAX_RICH_CHARS) return null;
+      return {
+        name: 'code-block',
+        props: {
+          id: `auto-${callId}`,
+          code: body,
+          filename: input.file_path,
+          language: languageForPath(input.file_path),
+          maxCollapsedLines: COLLAPSED_LINES,
+        },
+      };
+    }
+
+    return null;
+  } catch {
+    return null;
+  }
+}
diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx
index 7a9b1ae9..14816add 100644
--- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx
+++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx
@@ -17,11 +17,13 @@ import BrowserAgentInlineFeed from '../shell/BrowserAgentInlineFeed';
 import { GoogleServiceIcon } from '../mcp-cards/GoogleServiceIcon';
 import { ElapsedTimer, formatElapsed } from '../parsing/toolBubbleChrome';
 import { useTermColors, colorizeInput, colorizeOutput } from '../parsing/toolColorize';
-import { ParsedResult } from '../parsing/toolResultParsing';
+import { ParsedResult, getToolData } from '../parsing/toolResultParsing';
+import { resolveRichRender } from '../parsing/richResultDispatch';
 import { McpToolInfo } from '@/shared/mcpToolMeta';
 import { McpResultCard } from '../mcp-cards/McpResultCard';
 import { domainFromUrl } from './SourceFavicons';
 import { DomainIcon } from './DomainIcon';
+import VendoredToolUi from '@toolui/VendoredToolUi';
 
 interface DefaultToolBubbleProps {
   call: AgentMessage;
@@ -57,6 +59,11 @@ export const DefaultToolBubble: React.FC = ({
 }) => {
   const c = useClaudeTokens();
   const tc = useTermColors();
+  // Auto-elevated rendering: builtin coding tools map onto the vendored terminal/code components by schema, no ShowUI involved; null keeps the classic colorized 
. Streaming stays on the classic path (partial args are unparseable).
+  const richRender = React.useMemo(
+    () => (!isStreaming && result ? resolveRichRender(toolName, input ?? {}, parsedResult, resultElapsedMs, getToolData(call).toolId || call.id) : null),
+    [isStreaming, result, toolName, input, parsedResult, resultElapsedMs, call],
+  );
   // JS-driven mount reveal (see useMountReveal). The streaming pill itself glides in so a tool enters smoothly the moment it starts; when it commits, AgentChat sets suppressReveal on that same row so the hand-off doesn't re-animate what's already on screen. mcpCompact rows opt out (the group's row-fade handles them).
   const reveal = useMountReveal();
   const enterStyle = (!mcpCompact && !suppressReveal) ? reveal : {};
@@ -207,6 +214,16 @@ export const DefaultToolBubble: React.FC = ({
         
 
         
+          {richRender ? (
+            
+              
+              {parsedResult?.platformNote && (
+                
+                  {parsedResult.platformNote}
+                
+              )}
+            
+          ) : (
            = ({
               
             )}
           
+          )}