diff --git a/ts/packages/chat-ui/README.AUTOGEN.md b/ts/packages/chat-ui/README.AUTOGEN.md index bc1aee169..22fc56f29 100644 --- a/ts/packages/chat-ui/README.AUTOGEN.md +++ b/ts/packages/chat-ui/README.AUTOGEN.md @@ -3,7 +3,7 @@ - + # chat-ui — AI-generated documentation @@ -12,17 +12,17 @@ ## Overview -The `chat-ui` package provides a shared, framework-free chat user interface for TypeAgent applications. It is designed to ensure a consistent and interactive chat experience across various platforms, including the VS Code shell extension, the browser extension chat panel, and the Visual Studio extension webview. The package includes components for rendering user and agent messages, handling streaming updates, replaying chat history, managing connection status, and collecting user feedback. +The `chat-ui` package provides a shared, framework-free chat user interface for TypeAgent applications. It is used across multiple platforms, including the VS Code shell extension, the browser extension, and the Visual Studio extension webview, to deliver a consistent and interactive chat experience. The package includes components for rendering chat messages, handling streaming updates, replaying chat history, managing connection status, and collecting user feedback. ## What it does -The `chat-ui` package offers a set of tools and components to build and manage chat interfaces. Its primary features include: +The `chat-ui` package offers a comprehensive set of tools and components for building and managing chat interfaces. Key features include: -- **ChatPanel**: The core component for rendering the chat interface. It supports: +- **ChatPanel**: The central component for rendering the chat interface. It supports: - - Adding user and agent messages via `addAgentMessage`. - - Updating display metadata with `setDisplayInfo`. - - Replaying historical chat entries using `replayHistory`. + - Adding user and agent messages with `addAgentMessage`. + - Updating display metadata using `setDisplayInfo`. + - Replaying historical chat entries via `replayHistory`. - Streaming updates for dynamic content display. - **FeedbackWidget**: A component for collecting user feedback, including thumbs-up/thumbs-down ratings, comments, and contextual information. @@ -120,6 +120,6 @@ External: `ansi_up`, `dompurify`, `markdown-it` --- -_Auto-generated against commit `8f591da77983db53fd4a3e0ca12b58d80aaa3628` on `2026-07-22T20:55:48.144Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter chat-ui docs:verify-links` to spot-check._ +_Auto-generated against commit `274f0c51c3f1dca4e627c5311084db01d02fe1e9` on `2026-07-23T15:09:16.468Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter chat-ui docs:verify-links` to spot-check._ diff --git a/ts/packages/chat-ui/src/chatPanel.ts b/ts/packages/chat-ui/src/chatPanel.ts index 57f6d18e4..bb22b6e54 100644 --- a/ts/packages/chat-ui/src/chatPanel.ts +++ b/ts/packages/chat-ui/src/chatPanel.ts @@ -956,6 +956,7 @@ export class ChatPanel { this.setupContextMenu(); this.setupProviderAffordances(); this.setupImageLightbox(); + this.setupReasoningToolCall(); } /** @@ -977,6 +978,42 @@ export class ChatPanel { }); } + /** + * Lazily syntax-highlight a logged tool-call block the first time it opens. + * The reasoning engine renders every tool call (single or folded) as a native + *
holding only that call's own JSON (an + * object for one call, an array for a folded run). The
/ + * handles show/hide plus keyboard toggling on its own; we only highlight the + *
 once, when it first becomes visible, so it reads like the clickable
+     * action JSON view (same highlightJson tokens). The `toggle` event does not
+     * bubble, so we listen in the capture phase to keep a single delegated
+     * listener. Each block owns its JSON inline, independent of the enclosing
+     * action's JSON view. Shared by the Electron and VS Code shells.
+     */
+    private setupReasoningToolCall() {
+        this.messageDiv.addEventListener(
+            "toggle",
+            (e) => {
+                const details = e.target as HTMLElement | null;
+                if (
+                    !details?.classList?.contains("reasoning-tool-call") ||
+                    !(details as HTMLDetailsElement).open
+                ) {
+                    return;
+                }
+                const pre = details.querySelector(
+                    "pre.reasoning-tool-call-json",
+                );
+                if (!pre || pre.dataset.highlighted === "true") return;
+                // The 
 starts as raw (escaped) JSON text; its textContent is
+                // the un-escaped JSON, which highlightJson re-escapes.
+                pre.innerHTML = sanitize(highlightJson(pre.textContent ?? ""));
+                pre.dataset.highlighted = "true";
+            },
+            true,
+        );
+    }
+
     /**
      * Open a full-window image viewer overlaying the chat. Supports
      * wheel-zoom centered on the cursor, drag-to-pan, double-click toggle,
@@ -6394,6 +6431,13 @@ class AgentMessageContainer {
         }
         if (!text) return undefined;
 
+        // Content that is already a self-contained HTML block (the reasoning
+        // engine's 
thinking and batched-tool blocks) is + // pre-structured: any JSON inside is intentional markup, not a trailing + // action payload to hoist into the details panel. Splitting it at a + // "{"/"[" line would tear the
/
 apart mid-element.
+        if (text.trimStart().startsWith("<")) return undefined;
+
         // Strip ANSI to find the JSON boundary
         const stripped = text.replace(/\x1b\[[0-9;]*m/g, "");
         const lines = stripped.split("\n");
diff --git a/ts/packages/chat-ui/styles/chat.css b/ts/packages/chat-ui/styles/chat.css
index 0eca4c3f3..2f8d53264 100644
--- a/ts/packages/chat-ui/styles/chat.css
+++ b/ts/packages/chat-ui/styles/chat.css
@@ -1805,21 +1805,26 @@ table.sc-kv-table.sc-card-fields {
    `!important` because the host theme (e.g. vscode-shell's overlay)
    applies `color: ... !important` to the parent bubble; without it
    inheritance makes the spans render as the bubble's foreground color. */
-.chat-message-details pre.chat-json .json-key {
+.chat-message-details pre.chat-json .json-key,
+.reasoning-tool-call pre.chat-json .json-key {
   color: #9c27b0 !important;
   font-weight: 600;
 }
-.chat-message-details pre.chat-json .json-string {
+.chat-message-details pre.chat-json .json-string,
+.reasoning-tool-call pre.chat-json .json-string {
   color: #0a8043 !important;
 }
-.chat-message-details pre.chat-json .json-number {
+.chat-message-details pre.chat-json .json-number,
+.reasoning-tool-call pre.chat-json .json-number {
   color: #1976d2 !important;
 }
-.chat-message-details pre.chat-json .json-bool {
+.chat-message-details pre.chat-json .json-bool,
+.reasoning-tool-call pre.chat-json .json-bool {
   color: #b85c00 !important;
   font-weight: 600;
 }
-.chat-message-details pre.chat-json .json-null {
+.chat-message-details pre.chat-json .json-null,
+.reasoning-tool-call pre.chat-json .json-null {
   color: #888 !important;
   font-style: italic;
 }
@@ -1828,6 +1833,51 @@ table.sc-kv-table.sc-card-fields {
   display: block;
 }
 
+/* Logged tool calls inside a reasoning step. Every tool call (single or a folded
+   run "Tool:  xN") renders as a native 
whose is a + clickable line with the tool name as inline code; expanding it reveals only + that call's own JSON (same look as the clickable action JSON view), independent + of the enclosing action's JSON. The disclosure marker is hidden so it reads as + a plain line. */ +.reasoning-tool-call { + margin: 2px 0; +} +.reasoning-tool-call-summary { + cursor: pointer; + list-style: none; +} +.reasoning-tool-call-summary::-webkit-details-marker { + display: none; +} +.reasoning-tool-call-summary strong { + font-weight: 600; +} +/* Tool name chip — light-gray background, yellow/gold text (matches how inline + code renders in the reasoning text and how a single tool call looks). */ +.reasoning-tool-call-summary code { + background: rgba(127, 127, 127, 0.18); + color: #9a7d0a; + padding: 0 4px; + border-radius: 3px; + font-family: Consolas, "Courier New", monospace; + font-size: 0.95em; +} +.reasoning-tool-call-json { + margin: 4px 0 0 0; + padding: 8px; + background: rgba(127, 127, 127, 0.1); + border-radius: 6px; + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + max-width: 100%; + max-height: 400px; + overflow-y: auto; + font-family: Consolas, "Courier New", monospace; + font-size: 12px; + line-height: 1.4; +} + /* ========================================================================= Choice Panel ========================================================================= */ diff --git a/ts/packages/chat-ui/test/chatPanel.spec.ts b/ts/packages/chat-ui/test/chatPanel.spec.ts index d4cf41064..b3d5deeda 100644 --- a/ts/packages/chat-ui/test/chatPanel.spec.ts +++ b/ts/packages/chat-ui/test/chatPanel.spec.ts @@ -657,6 +657,139 @@ describe("notifications (persistent, dismissable)", () => { }); }); +describe("reasoning tool calls (single + folded)", () => { + // Mirrors what the reasoning engine emits for a logged tool call: a native + //
with a (tool name as inline + // code) and a
 holding only that call's own JSON, collapsed until opened.
+    // Sent as a markdown display message (MarkdownIt passes the block-level HTML
+    // through verbatim). Folded runs carry a JSON array; single calls a lone object.
+    const foldedHtml =
+        '
' + + 'Tool: ' + + "read_conversation x2" + + '
[\n' +
+        '  {\n    "tool": "read_conversation",\n    "arguments": {\n      "offset": 0\n    }\n  },\n' +
+        '  {\n    "tool": "read_conversation",\n    "arguments": {\n      "offset": 6\n    }\n  }\n' +
+        "]
"; + + const singleHtml = + '
' + + 'Tool: ' + + "get_conversation_info" + + '
{\n' +
+        '  "tool": "get_conversation_info",\n  "arguments": {\n    "limit": 1\n  }\n' +
+        "}
"; + + function addRun(panel: ChatPanel, html: string) { + panel.addUserMessage("run a tool", "req-1"); + panel.addAgentMessage( + { type: "markdown", content: html, kind: "info" }, + "dispatcher.reasoningAction.copilot", + undefined, + "step", + "req-1", + ); + } + + it("renders a folded run's summary and collapsed JSON array", () => { + const { root, panel } = makePanel(); + addRun(panel, foldedHtml); + + const details = root.querySelector( + "details.reasoning-tool-call", + ); + expect(details).not.toBeNull(); + // Native
is collapsed until the user opens it. + expect(details!.open).toBe(false); + const summary = root.querySelector( + ".reasoning-tool-call-summary", + ); + expect(summary).not.toBeNull(); + // Tool name is inline code, not split apart by the action-JSON splitter. + expect(summary!.querySelector("code")!.textContent).toBe( + "read_conversation", + ); + expect(summary!.textContent).toContain("x2"); + const pre = root.querySelector( + "pre.reasoning-tool-call-json", + ); + expect(pre).not.toBeNull(); + const parsed = JSON.parse(pre!.textContent ?? ""); + expect(parsed).toHaveLength(2); + expect(parsed[1].arguments.offset).toBe(6); + }); + + it("renders a single tool call as its own collapsed block with object JSON", () => { + const { root, panel } = makePanel(); + addRun(panel, singleHtml); + + const details = root.querySelector( + "details.reasoning-tool-call", + )!; + expect(details.open).toBe(false); + const summary = root.querySelector( + ".reasoning-tool-call-summary", + )!; + expect(summary.querySelector("code")!.textContent).toBe( + "get_conversation_info", + ); + expect(summary.textContent).not.toContain("x"); + const pre = root.querySelector( + "pre.reasoning-tool-call-json", + )!; + // Only the relevant JSON for this one call — a lone object. + expect(JSON.parse(pre.textContent ?? "")).toEqual({ + tool: "get_conversation_info", + arguments: { limit: 1 }, + }); + }); + + it("keeps each call's JSON inline, not in the action-data details panel", () => { + const { root, panel } = makePanel(); + addRun(panel, singleHtml); + + const pre = root.querySelector( + "pre.reasoning-tool-call-json", + )!; + // The JSON lives in the message content, decoupled from the clickable + // action JSON view (.chat-message-details) of the reasoningAction bubble. + expect(pre.closest(".chat-message-content")).not.toBeNull(); + expect(pre.closest(".chat-message-details")).toBeNull(); + }); + + it("syntax-highlights the JSON once, the first time the block opens", () => { + const { root, panel } = makePanel(); + addRun(panel, foldedHtml); + + const details = root.querySelector( + "details.reasoning-tool-call", + )!; + const pre = root.querySelector( + "pre.reasoning-tool-call-json", + )!; + expect(pre.querySelector(".json-key")).toBeNull(); + + // Native
handles show/hide + keyboard on its own; our capture- + // phase `toggle` listener highlights the JSON once when the block first + // opens. Drive the toggle event directly since jsdom doesn't run the + // native summary-click -> open behavior. + details.open = true; + details.dispatchEvent(new Event("toggle")); + expect(pre.dataset.highlighted).toBe("true"); + expect(pre.querySelector(".json-key")).not.toBeNull(); + expect(pre.querySelector(".json-string")).not.toBeNull(); + + // Closing and re-opening does not re-highlight or duplicate the body. + const highlightedHtml = pre.innerHTML; + details.open = false; + details.dispatchEvent(new Event("toggle")); + expect(pre.innerHTML).toBe(highlightedHtml); + details.open = true; + details.dispatchEvent(new Event("toggle")); + expect(pre.innerHTML).toBe(highlightedHtml); + }); +}); + describe("question form wizard (paged)", () => { const form: QuestionForm = { message: "Q", diff --git a/ts/packages/dispatcher/dispatcher/README.AUTOGEN.md b/ts/packages/dispatcher/dispatcher/README.AUTOGEN.md index 5f7e68b7d..feadb2da4 100644 --- a/ts/packages/dispatcher/dispatcher/README.AUTOGEN.md +++ b/ts/packages/dispatcher/dispatcher/README.AUTOGEN.md @@ -3,7 +3,7 @@ - + # agent-dispatcher — AI-generated documentation @@ -12,7 +12,7 @@ ## Overview -The TypeAgent Dispatcher is a TypeScript library that acts as the central component of the TypeAgent ecosystem. It processes user inputs, translates natural language into structured actions using large language models (LLMs), and coordinates interactions across various application agents. The Dispatcher is designed to integrate with multiple front ends, such as the TypeAgent Shell and CLI, and supports an extensible architecture for building personal agents with natural language interfaces. +The TypeAgent Dispatcher is a TypeScript library that serves as the core component of the TypeAgent ecosystem. It processes user inputs, translates natural language into structured actions using large language models (LLMs), and coordinates interactions across various application agents. The Dispatcher is designed to integrate with multiple front ends, such as the TypeAgent Shell and CLI, and supports an extensible architecture for building personal agents with natural language interfaces. ## What it does @@ -236,6 +236,6 @@ _9 environment variables referenced from `./src/` (set in `ts/.env` or your shel --- -_Auto-generated against commit `ff9101e00228c5d0ff34c8dd3f108f46af46b0a6` on `2026-07-23T00:41:03.476Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter agent-dispatcher docs:verify-links` to spot-check._ +_Auto-generated against commit `274f0c51c3f1dca4e627c5311084db01d02fe1e9` on `2026-07-23T15:09:16.468Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter agent-dispatcher docs:verify-links` to spot-check._ diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts index 47863e87f..179583a8a 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts @@ -40,6 +40,7 @@ import { createLimiter } from "@typeagent/common-utils"; import { ReasoningTraceCollector } from "./tracing/traceCollector.js"; import { ReasoningRecipeGenerator } from "./recipeGenerator.js"; import { formatUserContextForPrompt } from "./userContextPrompt.js"; +import { ToolRunFolder } from "./reasoningLoopBase.js"; import { buildReasoningActionResult, estimateReasoningTokens, @@ -508,7 +509,7 @@ function formatExecuteActionDisplay(input: unknown): string { // actionName out directly so the label shows the real action, not "?". const action = parseActionInput(toolInput?.action); const actionName = getStringProp(action, "actionName") ?? "?"; - return `**Tool:** execute_action — \`${schema}.${actionName}\``; + return `**Tool:** \`execute_action\` — \`${schema}.${actionName}\``; } function getPrimaryToolArg(input: unknown): string | undefined { @@ -530,27 +531,29 @@ function formatToolCallDisplay(toolName: string, input: unknown): string { case "discover_actions": { const schema = getStringProp(toolInput, "schemaName") ?? JSON.stringify(input); - return `**Tool:** discover_actions — schema: \`${schema}\``; + return `**Tool:** \`discover_actions\` — schema: \`${schema}\``; } case "execute_action": return formatExecuteActionDisplay(input); case "search_memory": { const question = getStringProp(toolInput, "question") ?? JSON.stringify(input); - return `**Tool:** search_memory — \`${question}\``; + return `**Tool:** \`search_memory\` — \`${question}\``; } case "remember": - return `**Tool:** remember`; + return `**Tool:** \`remember\``; } // Built-in tools (shell, github/fs/*, github/search/*, ...): show the // primary argument so parallel or similar calls are distinguishable - // instead of rendering as identical "Tool: " bubbles. + // instead of rendering as identical "Tool: " bubbles. The tool name + // is rendered as inline code so it reads as a highlighted chip (matching a + // single tool call and the folded-batch summary). const primaryArg = getPrimaryToolArg(input); if (primaryArg) { - return `**Tool:** ${toolName} — \`${primaryArg}\``; + return `**Tool:** \`${toolName}\` — \`${primaryArg}\``; } - return `**Tool:** ${toolName}`; + return `**Tool:** \`${toolName}\``; } /** @@ -1228,6 +1231,15 @@ async function executeReasoningWithoutPlanning( debug(`Executing reasoning request: ${originalRequest}`); context.actionIO.appendDisplay("Thinking...", "temporary"); const displayMode = resolveReasoningDisplayMode(context); + // Fold runs of identical, back-to-back tool-call lines into one "xN" line. + const toolFolder = new ToolRunFolder( + (content) => + context.actionIO.appendDisplay( + { type: "markdown", content, kind: "info" }, + displayMode, + ), + formatToolCallDisplay, + ); const client = await getCopilotClient(context.sessionContext.agentContext); const config = getCopilotSessionConfig(context); @@ -1296,6 +1308,7 @@ async function executeReasoningWithoutPlanning( "assistant.reasoning_delta", (event: any) => { if (event.data?.deltaContent) { + toolFolder.flush(); currentReasoning += event.data.deltaContent; context.actionIO.appendDisplay( { @@ -1319,6 +1332,7 @@ async function executeReasoningWithoutPlanning( event.data.content !== lastReasoningContent ) { // Final reasoning content - display as permanent thinking block + toolFolder.flush(); lastReasoningContent = event.data.content; reasoningBlockTexts.push(event.data.content); context.actionIO.appendDisplay( @@ -1341,6 +1355,7 @@ async function executeReasoningWithoutPlanning( "assistant.message_delta", (event: any) => { if (event.data?.deltaContent) { + toolFolder.flush(); currentContent += event.data.deltaContent; context.actionIO.appendDisplay( { @@ -1371,14 +1386,7 @@ async function executeReasoningWithoutPlanning( event.data?.parameters || {}; debug(`Tool execution started: ${toolName}`); - context.actionIO.appendDisplay( - { - type: "markdown", - content: formatToolCallDisplay(toolName, parameters), - kind: "info", - }, - displayMode, - ); + toolFolder.tool(toolName, parameters); }, ); @@ -1454,6 +1462,7 @@ async function executeReasoningWithoutPlanning( // streaming display). Prefer the authoritative final message over the // streamed accumulation, which can be stale/truncated when the model // interleaves tool calls mid-turn (which produced an "unfinished" answer). + toolFolder.flush(); const displayContent = finalResult || currentContent; if (displayContent) { context.actionIO.appendDisplay( @@ -1492,6 +1501,7 @@ async function executeReasoningWithoutPlanning( return result; } catch (error) { debug("Error during reasoning:", error); + toolFolder.flush(); context.actionIO.appendDisplay( { type: "text", @@ -1545,6 +1555,15 @@ async function executeReasoningWithTracing( debug(`Executing reasoning with tracing: ${originalRequest}`); context.actionIO.appendDisplay("Thinking...", "temporary"); const displayMode = resolveReasoningDisplayMode(context); + // Fold runs of identical, back-to-back tool-call lines into one "xN" line. + const toolFolder = new ToolRunFolder( + (content) => + context.actionIO.appendDisplay( + { type: "markdown", content, kind: "info" }, + displayMode, + ), + formatToolCallDisplay, + ); const client = await getCopilotClient( context.sessionContext.agentContext, @@ -1617,6 +1636,7 @@ async function executeReasoningWithTracing( "assistant.reasoning_delta", (event: any) => { if (event.data?.deltaContent) { + toolFolder.flush(); currentReasoning += event.data.deltaContent; context.actionIO.appendDisplay( { @@ -1648,6 +1668,7 @@ async function executeReasoningWithTracing( ], }); + toolFolder.flush(); context.actionIO.appendDisplay( { type: "markdown", @@ -1667,6 +1688,7 @@ async function executeReasoningWithTracing( "assistant.message_delta", (event: any) => { if (event.data?.deltaContent) { + toolFolder.flush(); currentContent += event.data.deltaContent; context.actionIO.appendDisplay( { @@ -1702,14 +1724,7 @@ async function executeReasoningWithTracing( // Record tool call for trace tracer.recordToolCall(toolName, parameters); - context.actionIO.appendDisplay( - { - type: "markdown", - content: formatToolCallDisplay(toolName, parameters), - kind: "info", - }, - displayMode, - ); + toolFolder.tool(toolName, parameters); }, ); @@ -1778,6 +1793,7 @@ async function executeReasoningWithTracing( // streaming display). Prefer the authoritative final message over // the streamed accumulation, which can be stale/truncated when the // model interleaves tool calls mid-turn. + toolFolder.flush(); const displayContent = finalResult || currentContent; if (displayContent) { context.actionIO.appendDisplay( @@ -1856,6 +1872,12 @@ async function executeReasoningWithTracing( ); return result; } finally { + // Emit any tool run still buffered when the inner try exits. On + // success the final-content flush above already drained it (no-op + // here); on an error thrown before that point (e.g. sendAndWait), + // this is the last chance to render it, since the outer catch is a + // separate scope and cannot see toolFolder. + toolFolder.flush(); unsubscribeReasoningDelta(); unsubscribeReasoning(); unsubscribeMessageDelta(); diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts index 95b4e5082..e97802880 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningLoopBase.ts @@ -164,14 +164,21 @@ export function formatToolResultDisplay( return `${label} \`${preview || "(empty)"}\``; } +// Escape the three HTML-significant characters so text renders literally inside +// our generated markup. Shared by the thinking, tool-call summary, and tool-call +// JSON builders below. +function escapeHtmlText(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + export function formatThinkingDisplay( thinkingText: string, thinkingTokens?: number, ): string { - const escaped = thinkingText - .replace(/&/g, "&") - .replace(//g, ">"); + const escaped = escapeHtmlText(thinkingText); // Carry the per-block token estimate as a data attribute (not in the // summary text). The client moves it into the reasoning step bubble's // metrics row - alongside the other token metrics - rather than the block @@ -189,6 +196,132 @@ export function formatThinkingDisplay( ].join(""); } +// One logged tool call: the tool name and the arguments it was invoked with. +// Each call renders as a click-to-expand block; a folded run reveals a JSON +// array of its calls, a single call reveals just its own object. +export interface ToolCallDetail { + tool: string; + args: unknown; +} + +// Convert the limited markdown our tool-call display lines use (**bold** and +// `code`) to HTML so the folded line can be shown inside the summary. The input +// comes from formatToolCallDisplay (our own strings, not raw user text), so +// escaping first and then applying these two rules is safe. +function inlineMarkdownToHtml(text: string): string { + return escapeHtmlText(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/`(.+?)`/g, "$1"); +} + +/** + * Render a logged tool call (single or folded) as a native
block that + * starts collapsed. The shows the display line (tool name as inline + * code, plus "xN" when folded); expanding it reveals only that call's own JSON - + * a single object for one call, an array for a folded run. Using
gives + * click and keyboard toggling for free and matches the sibling "Thinking" block; + * chat-ui only syntax-highlights the JSON the first time the block is opened, and + * the disclosure marker is hidden in CSS. Because the block is self-contained + * HTML starting with "<", splitActionContent leaves it intact, so the JSON stays + * inline and is never hoisted into the enclosing action's JSON view. + */ +export function formatToolRun( + displayLine: string, + details: ToolCallDetail[], +): string { + const payload = + details.length === 1 + ? { tool: details[0].tool, arguments: details[0].args } + : details.map((d) => ({ tool: d.tool, arguments: d.args })); + let json: string; + try { + json = JSON.stringify(payload, undefined, 2); + } catch { + // Arguments should always be JSON-serializable (they arrive as JSON + // from the reasoning SDK), but never let a bad payload break the run. + json = JSON.stringify( + details.length === 1 + ? { tool: details[0].tool } + : details.map((d) => ({ tool: d.tool })), + ); + } + return [ + `
`, + `${inlineMarkdownToHtml( + displayLine, + )}`, + `
${escapeHtmlText(
+            json,
+        )}
`, + `
`, + ].join(""); +} + +/** + * Collapses runs of identical, back-to-back tool-call lines into a single line + * with an "xN" suffix (e.g. three identical calls render as "... x3"). Only + * direct neighbors merge: any other display between two identical calls (a + * thinking block, streamed text, a tool result, or a different tool call) ends + * the run and keeps the calls separate. + * + * Tool lines are buffered instead of emitted right away, because the count is + * not known until the run ends. Callers must: + * - route every tool-call line through tool() + * - call flush() before emitting any non-tool display + * - call flush() once more after the reasoning stream completes + * + * Every run - single or folded - is emitted as its own click-to-expand block + * (see formatToolRun): the summary is the display line ("xN" only when folded) + * and the hidden body is that run's own JSON. + */ +export class ToolRunFolder { + private pending: string | undefined; + private count = 0; + private details: ToolCallDetail[] = []; + + // `format` turns a raw tool call into its display line; it also serves as + // the folding key (identical display = same run). Injected rather than + // imported because each reasoning engine formats tool calls differently and + // its formatter lives in the engine module (copilot.ts), which depends on + // this file, not the other way around. + constructor( + private readonly emit: (content: string) => void, + private readonly format: (toolName: string, args: unknown) => string, + ) {} + + // Record a tool call, folding it into the current run when its display line + // matches the immediately preceding one. The raw name and arguments are + // buffered so the run can reveal the full JSON on demand. + tool(toolName: string, args: unknown): void { + const display = this.format(toolName, args); + if (this.pending === display) { + this.count++; + this.details.push({ tool: toolName, args }); + return; + } + this.flush(); + this.pending = display; + this.count = 1; + this.details = [{ tool: toolName, args }]; + } + + // Emit the buffered run (if any) as a click-to-expand block and reset. A run + // of two or more identical calls gets an "xN" summary; a single call shows + // its line unchanged. Both reveal their own JSON on click. + flush(): void { + if (this.pending === undefined) { + return; + } + const line = + this.count > 1 ? `${this.pending} x${this.count}` : this.pending; + const details = this.details; + this.pending = undefined; + this.count = 0; + this.details = []; + this.emit(formatToolRun(line, details)); + } +} + /** * Build the reasoning token-usage record reported to the dispatcher (surfaced * as "Action Tokens" in the UI). Returns undefined when no tokens were counted diff --git a/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts b/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts new file mode 100644 index 000000000..3acc845ce --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/toolRunFolder.spec.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ToolRunFolder, + formatToolRun, +} from "../src/reasoning/reasoningLoopBase.js"; + +// Collect the strings the folder emits so each test can assert on the exact +// sequence of rendered tool-call blocks. The injected formatter defaults to the +// identity, so a call's tool name doubles as its display / folding key while its +// arguments vary independently (as real folded calls do). +function makeFolder( + format: (tool: string, args: unknown) => string = (t) => t, +): { folder: ToolRunFolder; emitted: string[] } { + const emitted: string[] = []; + const folder = new ToolRunFolder( + (content) => emitted.push(content), + format, + ); + return { folder, emitted }; +} + +// Every tool call (single or folded) renders as a native +//
with a and a
 holding that
+// call's own JSON (an object for one call, an array for a folded run). Parse both
+// out for assertions.
+function parseToolRun(content: string): {
+    summary: string;
+    json: unknown;
+    tools: string[];
+} {
+    expect(content).toContain('
'); + expect(content).toContain(''); + expect(content).toContain( + '
',
+    );
+    const summary =
+        content.match(
+            /reasoning-tool-call-summary">([\s\S]*?)<\/summary>/,
+        )?.[1] ?? "";
+    const raw =
+        content.match(/reasoning-tool-call-json">([\s\S]*?)<\/pre>/)?.[1] ?? "";
+    const json = JSON.parse(
+        raw.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"),
+    );
+    const tools = Array.isArray(json)
+        ? json.map((t: { tool: string }) => t.tool)
+        : [(json as { tool: string }).tool];
+    return { summary, json, tools };
+}
+
+function expectRun(content: string, summary: string, tools: string[]): void {
+    const parsed = parseToolRun(content);
+    expect(parsed.summary).toContain(summary);
+    expect(parsed.tools).toEqual(tools);
+}
+
+describe("ToolRunFolder", () => {
+    it("emits a single tool call as its own click-to-expand block (no xN)", () => {
+        const { folder, emitted } = makeFolder();
+        folder.tool("A", { offset: 0 });
+        expect(emitted).toEqual([]); // buffered until flushed
+        folder.flush();
+        expect(emitted).toHaveLength(1);
+        expectRun(emitted[0], "A", ["A"]);
+        // A single call's JSON is a lone object (not an array).
+        expect(parseToolRun(emitted[0]).json).toEqual({
+            tool: "A",
+            arguments: { offset: 0 },
+        });
+    });
+
+    it("folds identical adjacent calls (differing args) into one xN block", () => {
+        const { folder, emitted } = makeFolder();
+        folder.tool("A", { offset: 0 });
+        folder.tool("A", { offset: 6 });
+        folder.tool("A", { offset: 12 });
+        folder.flush();
+        expect(emitted).toHaveLength(1);
+        expectRun(emitted[0], "A x3", ["A", "A", "A"]);
+        // A folded run's JSON is an array preserving each call's own arguments.
+        const json = parseToolRun(emitted[0]).json as {
+            arguments: { offset: number };
+        }[];
+        expect(json.map((e) => e.arguments.offset)).toEqual([0, 6, 12]);
+    });
+
+    it("keeps duplicate calls separate when a different call splits them", () => {
+        const { folder, emitted } = makeFolder();
+        folder.tool("A", {});
+        folder.tool("B", {});
+        folder.tool("A", {});
+        folder.flush();
+        // No adjacent identical pair → three separate single blocks.
+        expect(emitted).toHaveLength(3);
+        expectRun(emitted[0], "A", ["A"]);
+        expectRun(emitted[1], "B", ["B"]);
+        expectRun(emitted[2], "A", ["A"]);
+    });
+
+    it("does not merge across a flush (e.g. a thinking block between runs)", () => {
+        const { folder, emitted } = makeFolder();
+        folder.tool("A", {});
+        folder.flush(); // interrupted by a non-tool display
+        folder.tool("A", {});
+        folder.flush();
+        expect(emitted).toHaveLength(2);
+        expectRun(emitted[0], "A", ["A"]);
+        expectRun(emitted[1], "A", ["A"]);
+    });
+
+    it("folds multiple distinct runs independently", () => {
+        const { folder, emitted } = makeFolder();
+        folder.tool("A", {});
+        folder.tool("A", {});
+        folder.tool("B", {});
+        folder.tool("B", {});
+        folder.tool("B", {});
+        folder.flush();
+        expect(emitted).toHaveLength(2);
+        expectRun(emitted[0], "A x2", ["A", "A"]);
+        expectRun(emitted[1], "B x3", ["B", "B", "B"]);
+    });
+
+    it("emits the prior run immediately when a different call starts", () => {
+        const { folder, emitted } = makeFolder();
+        folder.tool("A", {});
+        folder.tool("A", {});
+        // Switching tools flushes the buffered run without an explicit flush().
+        folder.tool("B", {});
+        expect(emitted).toHaveLength(1);
+        expectRun(emitted[0], "A x2", ["A", "A"]);
+        folder.flush();
+        expect(emitted).toHaveLength(2);
+        expectRun(emitted[1], "B", ["B"]); // single call → no xN
+    });
+
+    it("folds by display line, so different args do not split a run", () => {
+        // A formatter coarser than the raw args (only the tool name) is what
+        // makes read_conversation-style paging fold despite varying offsets.
+        const { folder, emitted } = makeFolder((tool) => `**Tool:** ${tool}`);
+        folder.tool("read", { offset: 0 });
+        folder.tool("read", { offset: 6 });
+        folder.flush();
+        expect(emitted).toHaveLength(1);
+        // The summary carries the display line with its markdown converted to
+        // HTML (**Tool:** -> Tool:).
+        expectRun(emitted[0], "Tool: read x2", [
+            "read",
+            "read",
+        ]);
+    });
+
+    it("treats flush with nothing pending as a no-op", () => {
+        const { folder, emitted } = makeFolder();
+        folder.flush();
+        folder.flush();
+        expect(emitted).toEqual([]);
+    });
+
+    it("resets the count and buffered details after each flushed run", () => {
+        const { folder, emitted } = makeFolder();
+        folder.tool("A", {});
+        folder.tool("A", {});
+        folder.flush();
+        folder.tool("A", {});
+        folder.flush();
+        expect(emitted).toHaveLength(2);
+        expectRun(emitted[0], "A x2", ["A", "A"]);
+        expectRun(emitted[1], "A", ["A"]); // second run is a single call
+    });
+});
+
+describe("formatToolRun", () => {
+    it("renders a single call as a click-to-expand block with its own object JSON", () => {
+        const html = formatToolRun("**Tool:** `get_conversation_info`", [
+            { tool: "get_conversation_info", args: { limit: 1 } },
+        ]);
+        expect(html).toContain('
'); + // Tool name becomes inline (highlighted chip); no "xN" for one call. + expect(html).toContain( + 'Tool:', + ); + expect(html).toContain("get_conversation_info"); + expect(html).not.toContain(" x1"); + expect(html).toContain( + '
',
+        );
+        // Only the relevant JSON for this one call — a lone object.
+        expect(parseToolRun(html).json).toEqual({
+            tool: "get_conversation_info",
+            arguments: { limit: 1 },
+        });
+    });
+
+    it("renders a folded run's JSON as an array of the calls", () => {
+        const html = formatToolRun("**Tool:** `read_conversation` x2", [
+            { tool: "read_conversation", args: { offset: 0 } },
+            { tool: "read_conversation", args: { offset: 6 } },
+        ]);
+        expect(html).toContain("read_conversation x2");
+        expect(parseToolRun(html).json).toEqual([
+            { tool: "read_conversation", arguments: { offset: 0 } },
+            { tool: "read_conversation", arguments: { offset: 6 } },
+        ]);
+    });
+
+    it("HTML-escapes argument values so markup in args cannot break out", () => {
+        const html = formatToolRun("**Tool:** `shell`", [
+            { tool: "shell", args: { command: "" } },
+        ]);
+        expect(html).not.toContain("");
+        expect(html).toContain("<script>");
+    });
+});
diff --git a/ts/packages/shell/README.AUTOGEN.md b/ts/packages/shell/README.AUTOGEN.md
index 601d12672..29aad4a5e 100644
--- a/ts/packages/shell/README.AUTOGEN.md
+++ b/ts/packages/shell/README.AUTOGEN.md
@@ -3,7 +3,7 @@
 
 
 
-
+
 
 
 # agent-shell — AI-generated documentation
@@ -12,11 +12,11 @@
 
 ## Overview
 
-The `agent-shell` package is a TypeScript library that serves as the graphical user interface (GUI) for the TypeAgent ecosystem. Built on Electron, it provides a personal agent interface for interacting with an extensible set of agents. Users can perform actions, manage conversations, and interact via text or voice input. The shell integrates with other TypeAgent components, such as the dispatcher and agent server, and supports both local and remote operation modes.
+The `agent-shell` package is a TypeScript library that provides the graphical user interface (GUI) for the TypeAgent ecosystem. Built on Electron, it serves as a personal agent interface, enabling users to interact with an extensible set of agents through natural language, perform actions, and manage conversations. The shell supports both local and remote operation modes and integrates with other TypeAgent components, such as the dispatcher and agent server.
 
 ## What it does
 
-The `agent-shell` package offers the following capabilities:
+The `agent-shell` package offers the following key features:
 
 ### Conversation Management
 
@@ -176,6 +176,6 @@ _6 environment variables referenced from `./src/` (set in `ts/.env` or your shel
 
 ---
 
-_Auto-generated against commit `8f591da77983db53fd4a3e0ca12b58d80aaa3628` on `2026-07-22T20:55:48.144Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter agent-shell docs:verify-links` to spot-check._
+_Auto-generated against commit `274f0c51c3f1dca4e627c5311084db01d02fe1e9` on `2026-07-23T15:09:16.468Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter agent-shell docs:verify-links` to spot-check._
 
 
diff --git a/ts/packages/shell/src/renderer/assets/styles.less b/ts/packages/shell/src/renderer/assets/styles.less
index 6c49edc28..2be934fbb 100644
--- a/ts/packages/shell/src/renderer/assets/styles.less
+++ b/ts/packages/shell/src/renderer/assets/styles.less
@@ -973,6 +973,48 @@ details.reasoning-thinking pre {
   overflow-y: auto;
 }
 
+/* Logged tool calls in a reasoning step. Every tool call (single or a folded
+   run) renders as a native 
whose is a clickable + "Tool: xN" line; the tool name renders as an inline-code chip and + expanding the line reveals only that call's own JSON (like the action JSON + view). The disclosure marker is hidden so it reads as a plain line. */ +.reasoning-tool-call { + margin: 4px 0; +} + +.reasoning-tool-call-summary { + cursor: pointer; + list-style: none; + user-select: none; +} + +.reasoning-tool-call-summary::-webkit-details-marker { + display: none; +} + +.reasoning-tool-call-summary code { + padding: 0 4px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.08); + color: #d7ba7d; + font-family: monospace; + font-size: 0.95em; +} + +.reasoning-tool-call-json { + margin: 4px 0 0 0; + padding: 8px 10px; + white-space: pre-wrap; + word-wrap: break-word; + border-radius: 6px; + background: rgba(0, 0, 0, 0.03); + max-width: 100%; + max-height: 400px; + overflow-y: auto; + font-family: monospace; + font-size: 0.9em; +} + // Panel below an agent message that shows the action JSON when the // user clicks the agent name. Mirrors the chat-ui details-row pattern // so the JSON expands beneath the response instead of overlaying it. diff --git a/ts/packages/vscode-shell/README.AUTOGEN.md b/ts/packages/vscode-shell/README.AUTOGEN.md index d817e329b..1b118f460 100644 --- a/ts/packages/vscode-shell/README.AUTOGEN.md +++ b/ts/packages/vscode-shell/README.AUTOGEN.md @@ -3,7 +3,7 @@ - + # vscode-shell — AI-generated documentation @@ -16,7 +16,7 @@ The `vscode-shell` package integrates the TypeAgent shell chat into Visual Studi ## What it does -The `vscode-shell` package provides a rich set of features for interacting with TypeAgent conversations in Visual Studio Code. Key capabilities include: +The `vscode-shell` package provides a range of features for interacting with TypeAgent conversations in Visual Studio Code. Key capabilities include: - **Chat Interface**: @@ -174,6 +174,6 @@ External: `ansi_up`, `debug`, `dompurify`, `isomorphic-ws`, `markdown-it`, `micr --- -_Auto-generated against commit `8f591da77983db53fd4a3e0ca12b58d80aaa3628` on `2026-07-22T20:55:48.144Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter vscode-shell docs:verify-links` to spot-check._ +_Auto-generated against commit `274f0c51c3f1dca4e627c5311084db01d02fe1e9` on `2026-07-23T15:09:16.468Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter vscode-shell docs:verify-links` to spot-check._ diff --git a/ts/packages/vscode-shell/src/webview/vscode-theme.css b/ts/packages/vscode-shell/src/webview/vscode-theme.css index 45b808d2b..6bd7da5af 100644 --- a/ts/packages/vscode-shell/src/webview/vscode-theme.css +++ b/ts/packages/vscode-shell/src/webview/vscode-theme.css @@ -368,6 +368,33 @@ ); } +/* 5b. Logged tool calls in a reasoning step. Every tool call (single or a folded + run) renders as
with a clickable + "Tool: xN" summary and a hidden JSON
 body. The tool name is
+       inline code (VS Code themes it gold-on-gray, matching a single tool call);
+       the body is styled like the collapsible action JSON. */
+.chat-message-content .reasoning-tool-call-summary code {
+  color: var(
+    --vscode-textPreformat-foreground,
+    var(--vscode-terminal-ansiYellow, #d7ba7d)
+  ) !important;
+  background-color: var(
+    --vscode-textPreformat-background,
+    var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.15))
+  );
+}
+.chat-message-content .reasoning-tool-call-json {
+  background-color: rgba(127, 127, 127, 0.1);
+  color: var(--vscode-foreground);
+  white-space: pre-wrap;
+  word-break: break-word;
+  font-family: var(
+    --vscode-editor-font-family,
+    Consolas,
+    "Courier New",
+    monospace
+  );
+}
 /* 6. Slim, theme-colored scrollbars for the remaining scroll areas: code
       blocks and the collapsible JSON action details. */
 .chat-message-content pre::-webkit-scrollbar,