From 865dff4cf0918f822f674f18823a73bf463ab0fe Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 21:43:15 -0700 Subject: [PATCH 1/2] Agent chat: a tool output whose few lines wrap past eight rows collapses with Show more A tool block already showed its first eight lines under a fade with Show all N lines (#23413), but it counted newlines only: a one-line JSON result or a minified log line wrapped to dozens of rows with no cap and no toggle. Those are now cut at eight rows too, with Show more, and Show less closes them. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray --- .../acpmux/conversation/BlockParts.tsx | 71 ++++- .../acpmux/conversation/ShellBlock.tsx | 10 +- .../acpmux/conversation/ToolRow.tsx | 14 +- ...transcript-wrapped-output-preview.test.tsx | 246 ++++++++++++++++++ 4 files changed, 321 insertions(+), 20 deletions(-) create mode 100644 webviews/test/transcript-wrapped-output-preview.test.tsx diff --git a/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx b/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx index 45aa219bcc8f..7e5ec056c7d2 100644 --- a/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx +++ b/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx @@ -1,7 +1,7 @@ // The parts every tool-call block in the transcript shares (an edit's diff, a command's Shell block, // a call's output): Wrap lines in its header beside Copy, the first `PREVIEW_LINES` lines under a -// fade with "Show all N lines" below, and lines drawn one block each, so a wrapped line hangs under -// its first row. +// fade with "Show all N lines" below (or "Show more" when a few lines wrap to more rows), and lines +// drawn one block each, so a wrapped line hangs under its first row. import { useLayoutEffect, useRef, useState, type RefObject } from "react"; import { useT } from "../i18n"; import { useBlockWrap, type BlockKind } from "./blockWrap"; @@ -28,19 +28,22 @@ export function WrapButton({ kind, className = "cv-codeblock__action" }: { kind: ); } -/// A block's preview: whether it is long enough to cut (more than `PREVIEW_LINES` lines), and -/// whether the reader opened it. `data-preview` on its body is "cut" or "all", absent when it fits. -/// Each new `reveal` (Find in Chat revealed the block, and a match may be past the cut) opens it. -export function usePreview(lines: number, reveal = 0) { +/// A block's preview: whether it is long enough to cut (more than `PREVIEW_LINES` lines, or fewer +/// that `wraps` past as many rows: `useWrapsPast`), and whether the reader opened it. `data-preview` +/// on its body is "cut" or "all", absent when it fits. Each new `reveal` (Find in Chat revealed the +/// block, and a match may be past the cut) opens it. +export function usePreview(lines: number, reveal = 0, wraps = false) { const [all, setAll] = useState(reveal > 0); const [revealed, setRevealed] = useState(reveal); if (reveal !== revealed) { setRevealed(reveal); if (reveal > 0) setAll(true); } - const long = lines > PREVIEW_LINES; + const long = lines > PREVIEW_LINES || wraps; return { long, + /** Cut for its wrapped rows alone: too few lines to count in "Show all N lines". */ + wrapped: long && lines <= PREVIEW_LINES, cut: long && !all, all, lines, @@ -49,9 +52,10 @@ export function usePreview(lines: number, reveal = 0) { }; } -/// "Show all N lines" under a cut block, "Show less" once it is open. A block that draws only the -/// end of a long output counts all of its lines, and opened it draws them all (toolOutput.tsx -/// `previewedOutput`), so N is never the tail's count. Find skips the button's words. +/// "Show all N lines" under a cut block ("Show more" under one cut for its wrapped rows), "Show less" +/// once it is open. A block that draws only the end of a long output counts all of its lines, and +/// opened it draws them all (toolOutput.tsx `previewedOutput`), so N is never the tail's count. Find +/// skips the button's words. export function PreviewMore({ preview }: { preview: ReturnType }) { const t = useT(); if (!preview.long) return null; @@ -63,11 +67,56 @@ export function PreviewMore({ preview }: { preview: ReturnType - {preview.all ? t("shell.showLess") : t("transcript.showAllLines", { n: preview.lines })} + {preview.all + ? t("shell.showLess") + : preview.wrapped + ? t("message.more") + : t("transcript.showAllLines", { n: preview.lines })} ); } +/// A line longer than the narrowest block's row: only a text with one can wrap. +const WIDE_LINE = /[^\n]{24}/; + +/// Whether `ref`'s body, wrapping a text of `lines` lines (no more than the preview shows), still +/// runs past `PREVIEW_LINES` rows: one long line (a minified log, a one-line JSON result) wraps to +/// many. Its rows are read from the body's own height and line height, never per frame: once each +/// time its text, wrap or line count changes, and as its width changes (a ResizeObserver on this +/// body alone, disconnected when the text changes, it stops wrapping or fits by line count, or it +/// unmounts). A block that is not drawn (a closed call) reads nothing. +export function useWrapsPast( + ref: RefObject, + text: string | undefined, + wrap: boolean, + lines: number, +) { + const watch = wrap && lines <= PREVIEW_LINES && Boolean(text && WIDE_LINE.test(text)); + const [past, setPast] = useState(false); + useLayoutEffect(() => { + const node = ref.current; + if (!watch || !node) return; + const measure = () => setPast(wrappedRows(node) > PREVIEW_LINES + 0.5); + measure(); + const Observer = node.ownerDocument.defaultView?.ResizeObserver; + if (!Observer) return; + const observer = new Observer(measure); + observer.observe(node); + return () => observer.disconnect(); + }, [ref, watch, text, lines]); + return watch && past; +} + +/// The rows of text in `node`: its content height (the whole of it, even while cut) over its line +/// height, less its padding and the gap between a Shell block's command and output. +function wrappedRows(node: HTMLElement) { + const style = node.ownerDocument.defaultView!.getComputedStyle(node); + const line = parseFloat(style.lineHeight); + if (!(line > 0)) return 0; + const inset = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0); + return (node.scrollHeight - inset - (parseFloat(style.rowGap) || 0)) / line; +} + /// Keeps the right-edge fade of `ref`'s sideways scrollers current while it is drawn. export function useSidewaysEdge(ref: RefObject) { useLayoutEffect(() => { diff --git a/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx b/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx index 88bbe98139c3..6a9d043e0e55 100644 --- a/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx @@ -7,7 +7,7 @@ import type { ReactNode } from "react"; import { useT } from "../i18n"; import { useBlockWrap } from "./blockWrap"; -import { Lines, lineCount, PreviewMore, usePreview, useSidewaysRef, WrapButton } from "./BlockParts"; +import { Lines, lineCount, PreviewMore, usePreview, useSidewaysRef, useWrapsPast, WrapButton } from "./BlockParts"; import { commandFolder } from "./commandFolder"; import { CopyButton } from "./CopyButton"; import { menuPartRef } from "./menuParts"; @@ -40,11 +40,13 @@ export function ShellBlock({ const raw = useOutput(output, reveal); const { folder, rest } = command ? commandFolder(command) : { folder: undefined, rest: undefined }; const [wrap] = useBlockWrap("output"); + const body = useSidewaysRef(); // The command's lines and all the output's, though a long output draws only its end until opened - // (none to cut when whole). - const preview = usePreview(whole ? 0 : (rest ? lineCount(rest) : 0) + lineCount(raw.text), reveal); + // (none to cut when whole); a few lines that wrap past the preview's rows are cut too. + const lines = whole ? 0 : (rest ? lineCount(rest) : 0) + lineCount(raw.text); + const wraps = useWrapsPast(body, whole ? undefined : rest ? `${rest}\n${raw.text ?? ""}` : raw.text, wrap, lines); + const preview = usePreview(lines, reveal, wraps); const shown = previewedOutput(raw, preview); - const body = useSidewaysRef(); return (
diff --git a/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx b/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx index 6d0d79511169..a92cc1cb09f3 100644 --- a/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx @@ -22,7 +22,7 @@ import { BackgroundMark } from "./BackgroundMark"; import { toolDuration } from "./toolGroups"; import { OutputMore, previewedOutput, useOutput } from "./toolOutput"; import { useBlockWrap } from "./blockWrap"; -import { Lines, lineCount, PreviewMore, usePreview, useSidewaysRef, WrapButton } from "./BlockParts"; +import { Lines, lineCount, PreviewMore, usePreview, useSidewaysRef, useWrapsPast, WrapButton } from "./BlockParts"; import { cleanText } from "./mcpCalls"; import { useFold } from "./fold"; import { WriteLabel, writeCall } from "./WriteLabel"; @@ -182,15 +182,19 @@ export function ToolRow({ item }: { item: AcpmuxActivity }) { /// A call's output when it is not a shell's: copyable whole from its right-click menu (Copy Output; /// the `hover-actions` experiment's "all" arm adds a Copy in its corner on hover), the end of a long /// one shown. Wrap lines sits in its corner on hover; past eight lines it shows its first ones under -/// a fade, with Show all N lines (BlockParts.tsx). +/// a fade, with Show all N lines, and past eight rows of a few wrapped lines, with Show more +/// (BlockParts.tsx). function ToolOutput({ id, output, reveal }: { id: string; output: string; reveal: number }) { const t = useT(); const raw = useOutput(output, reveal); const [wrap] = useBlockWrap("output"); - // All the output's lines, though a long one draws only its end until opened. - const preview = usePreview(lineCount(raw.text), reveal); - const shown = previewedOutput(raw, preview); const body = useSidewaysRef(); + // All the output's lines, though a long one draws only its end until opened; a few lines that + // wrap past the preview's rows (a one-line result) are cut too. + const lines = lineCount(raw.text); + const wraps = useWrapsPast(body, raw.text, wrap, lines); + const preview = usePreview(lines, reveal, wraps); + const shown = previewedOutput(raw, preview); return (
diff --git a/webviews/test/transcript-wrapped-output-preview.test.tsx b/webviews/test/transcript-wrapped-output-preview.test.tsx new file mode 100644 index 000000000000..dc47dc5db352 --- /dev/null +++ b/webviews/test/transcript-wrapped-output-preview.test.tsx @@ -0,0 +1,246 @@ +// l10n-allow-file: test fixture output and commands, not shipped UI. +// Long tool outputs collapse with Show more (Leo 2026-10-22: "also the tool calling i think there +// should be more of like a "show more" type thing for long outputs or whatnot..."). A block of more +// than eight lines already showed its first eight under a fade with "Show all N lines" +// (transcript-block-overflow.test.tsx); a few lines that wrap to many rows (a one-line JSON result, +// a minified log line) ran on unbounded. Now they are cut at eight rows too, with "Show more", and +// "Show less" closes them again. The rows are read from the block's own height only: jsdom has no +// layout, so its height here is its text's rows at `columns` characters a row, and its +// ResizeObserver fires when the test narrows or widens it. +import { afterAll, afterEach, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { JSDOM } from "jsdom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +const scope = globalThis as Record; +const keys = [ + "window", + "document", + "navigator", + "HTMLElement", + "Element", + "Node", + "customElements", + "MutationObserver", + "ResizeObserver", + "localStorage", + "requestAnimationFrame", + "cancelAnimationFrame", + "IS_REACT_ACT_ENVIRONMENT", +] as const; +const saved = keys.map((key) => [key, key in scope, scope[key]] as const); +const added: string[] = []; +let root: Root | undefined; +let host: HTMLElement; +let window: JSDOM["window"]; +// The first diff card imports Pierre's chunk, which takes seconds cold. +setDefaultTimeout(30_000); + +beforeAll(() => { + ({ window } = new JSDOM("
", { url: "http://localhost/", pretendToBeVisual: true })); + Object.assign(scope, { + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + Element: window.Element, + Node: window.Node, + customElements: window.customElements, + MutationObserver: window.MutationObserver, + // jsdom has no layout; Pierre's lines and the blocks' edges watch their size. + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + localStorage: window.localStorage, + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + IS_REACT_ACT_ENVIRONMENT: true, + }); + for (const key of Object.getOwnPropertyNames(window)) + if (/^(HTML|SVG|CSS|Shadow|Document|Event|MouseEvent)/.test(key) && !(key in scope)) { + added.push(key); + scope[key] = (window as unknown as Record)[key]; + } + host = window.document.getElementById("root")!; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = undefined; + window.localStorage.clear(); +}); + +afterAll(() => { + for (const key of added) delete scope[key]; + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +async function render(node: React.ReactNode) { + const { createRoot } = await import("react-dom/client"); + root = createRoot(host); + await act(async () => root!.render(node)); +} + +const click = (element: Element | null | undefined) => + act(async () => { + // A boolean, so a miss prints one line rather than the element's whole React tree. + expect(Boolean(element)).toBe(true); + element!.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); + +const buttons = (within: ParentNode = host) => [...within.querySelectorAll("button")]; +const named = (label: string, within: ParentNode = host) => + buttons(within).filter((node) => (node.getAttribute("aria-label") ?? node.textContent) === label); +const byText = (text: string, within: ParentNode = host) => buttons(within).find((node) => node.textContent === text); + +/// The characters one row of a block holds; a test changes it and fires `resize` to narrow or widen. +let columns = 80; +const observers = new Set<() => void>(); +const resize = (to: number) => + act(async () => { + columns = to; + for (const fire of observers) fire(); + }); + +beforeAll(() => { + scope.ResizeObserver = window.ResizeObserver = class { + #fire: () => void; + constructor(fire: () => void) { + this.#fire = () => fire(); + } + observe() { + observers.add(this.#fire); + } + unobserve() {} + disconnect() { + observers.delete(this.#fire); + } + } as never; + const style = window.getComputedStyle.bind(window); + window.getComputedStyle = ((node: Element) => + node.matches("pre") + ? { lineHeight: "18px", paddingTop: "8px", paddingBottom: "8px", rowGap: "normal" } + : style(node)) as never; + // A block's whole height: each of its lines' rows at `columns` characters, and its padding. + Object.defineProperty(window.HTMLElement.prototype, "scrollHeight", { + configurable: true, + get(this: HTMLElement) { + if (!this.matches("pre")) return 0; + const rows = [...this.querySelectorAll(".cv-line")] + .map((line) => line.textContent!.replace(/\n$/, "")) + .reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / columns)), 0); + return rows * 18 + 16; + }, + }); +}); + +afterEach(() => { + columns = 80; +}); + +const call = (kind: string, output: string, command?: string) => + ({ + kind: "activity", + text: "Ran", + tool: { id: `${kind}-call`, kind, title: "Tool", status: "completed", command, output }, + }) as never; + +async function openRow(kind: string, output: string, command?: string) { + const { ToolRow } = await import("../src/agent-session/acpmux/conversation/ToolRow"); + await render(); + await click(host.querySelector("button.cv-tool")); +} + +/// A one-line JSON result of 1,200 characters: 15 rows at 80 a row. +const ONE_LINE = JSON.stringify({ items: Array.from({ length: 60 }, (_, index) => `item-${index + 1}`), ok: true }); + +test("a call's one-line output that wraps past eight rows is cut under a fade with Show more", async () => { + expect(ONE_LINE.includes("\n")).toBe(false); + await openRow("fetch", ONE_LINE); + const card = host.querySelector(".cv-tool-output-card")!; + const body = card.querySelector(".cv-tool-output")!; + expect(body.getAttribute("data-preview")).toBe("cut"); + const more = byText("Show more", card); + expect(more?.getAttribute("aria-expanded")).toBe("false"); + // A real button, so Tab reaches it and Return or Space opens it; Find skips its words. + expect(more?.tagName).toBe("BUTTON"); + expect(more?.hasAttribute("data-find-skip")).toBe(true); + // Never "Show all 1 lines": the count is only for blocks cut by their lines. + expect(buttons(card).some((node) => node.textContent?.startsWith("Show all"))).toBe(false); + // The whole text is drawn, only clipped: Copy and Find read all of it. + expect(body.textContent).toBe(ONE_LINE); + await click(more); + expect(body.getAttribute("data-preview")).toBe("all"); + const less = byText("Show less", card); + expect(less?.getAttribute("aria-expanded")).toBe("true"); + await click(less); + expect(body.getAttribute("data-preview")).toBe("cut"); + expect(Boolean(byText("Show more", card))).toBe(true); +}); + +test("an output that wraps to eight rows or fewer shows whole, with no toggle", async () => { + // 600 characters: 8 rows at 80 a row. + await openRow("fetch", "x".repeat(600)); + const card = host.querySelector(".cv-tool-output-card")!; + expect(card.querySelector(".cv-tool-output")?.hasAttribute("data-preview")).toBe(false); + expect(buttons(card).some((node) => /^Show (more|less|all)/.test(node.textContent ?? ""))).toBe(false); + // Short lines never wrap, so a short output is not even measured. + act(() => root?.unmount()); + await openRow("fetch", "3 pass\n0 fail"); + expect(host.querySelector(".cv-tool-output")?.hasAttribute("data-preview")).toBe(false); +}); + +test("narrowing the chat cuts an output that now wraps past eight rows, and widening it again shows it whole", async () => { + await openRow("fetch", "x".repeat(600)); + const body = () => host.querySelector(".cv-tool-output")!; + expect(body().hasAttribute("data-preview")).toBe(false); + await resize(40); + expect(body().getAttribute("data-preview")).toBe("cut"); + expect(Boolean(byText("Show more"))).toBe(true); + await resize(120); + expect(body().hasAttribute("data-preview")).toBe(false); + expect(Boolean(byText("Show more"))).toBe(false); +}); + +test("with Wrap lines off a long line stays on one row and is not cut", async () => { + await openRow("fetch", ONE_LINE); + const card = host.querySelector(".cv-tool-output-card")!; + await click(named("Wrap lines", card)[0]); + expect(card.querySelector(".cv-tool-output")?.getAttribute("data-wrap")).toBe("false"); + expect(card.querySelector(".cv-tool-output")?.hasAttribute("data-preview")).toBe(false); + expect(Boolean(byText("Show more", card))).toBe(false); +}); + +test("a Shell block whose few lines wrap past eight rows is cut with Show more too", async () => { + await openRow("execute", ONE_LINE, "curl -s https://example.test/items"); + const shell = host.querySelector(".cv-shell")!; + expect(shell.querySelector(".cv-shell__body")?.getAttribute("data-preview")).toBe("cut"); + await click(byText("Show more", shell)); + expect(shell.querySelector(".cv-shell__body")?.getAttribute("data-preview")).toBe("all"); + expect(Boolean(byText("Show less", shell))).toBe(true); +}); + +test("Find in Chat revealing the call opens an output cut for its wrapped rows", async () => { + const { ShellBlock } = await import("../src/agent-session/acpmux/conversation/ShellBlock"); + await render(); + expect(host.querySelector(".cv-shell__body")?.getAttribute("data-preview")).toBe("cut"); + // As ToolRow passes it: bumped each time Find reveals the row. + await act(async () => + root!.render(), + ); + expect(host.querySelector(".cv-shell__body")?.getAttribute("data-preview")).toBe("all"); + expect(Boolean(byText("Show less"))).toBe(true); +}); + +test("a block opened whole for a permission ask is never cut", async () => { + const { ShellBlock } = await import("../src/agent-session/acpmux/conversation/ShellBlock"); + await render(); + expect(host.querySelector(".cv-shell__body")?.hasAttribute("data-preview")).toBe(false); +}); From f3474b1a74657b2ea1418e2439323579906cf0ce Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 22:01:55 -0700 Subject: [PATCH 2/2] Agent chat: tool-output-collapse experiment and gallery entry Four looks for a long tool output's collapse (8 rows + fade + link, the shipped one; 6 + fade + bar; 10 + hard cut + chip; 15 + fade + link), compared in the new agent-pane.tool-output-collapse gallery entry. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray --- .../acpmux/conversation/BlockParts.tsx | 19 +-- .../acpmux/conversation/ShellBlock.tsx | 14 +- .../acpmux/conversation/ToolRow.tsx | 8 +- .../acpmux/conversation/conversation.css | 35 ++++- .../toolOutputCollapse.experiment.ts | 54 +++++++ .../toolOutputCollapse.gallery.ts | 144 ++++++++++++++++++ webviews/src/experiments/registry.ts | 2 + ...transcript-wrapped-output-preview.test.tsx | 43 +++++- 8 files changed, 296 insertions(+), 23 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/conversation/toolOutputCollapse.experiment.ts create mode 100644 webviews/src/agent-session/acpmux/conversation/toolOutputCollapse.gallery.ts diff --git a/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx b/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx index 7e5ec056c7d2..92a839d5c955 100644 --- a/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx +++ b/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx @@ -28,22 +28,22 @@ export function WrapButton({ kind, className = "cv-codeblock__action" }: { kind: ); } -/// A block's preview: whether it is long enough to cut (more than `PREVIEW_LINES` lines, or fewer -/// that `wraps` past as many rows: `useWrapsPast`), and whether the reader opened it. `data-preview` +/// A block's preview: whether it is long enough to cut (more than `rows` lines, or fewer that +/// `wraps` past as many rows: `useWrapsPast`), and whether the reader opened it. `data-preview` /// on its body is "cut" or "all", absent when it fits. Each new `reveal` (Find in Chat revealed the /// block, and a match may be past the cut) opens it. -export function usePreview(lines: number, reveal = 0, wraps = false) { +export function usePreview(lines: number, reveal = 0, wraps = false, rows = PREVIEW_LINES) { const [all, setAll] = useState(reveal > 0); const [revealed, setRevealed] = useState(reveal); if (reveal !== revealed) { setRevealed(reveal); if (reveal > 0) setAll(true); } - const long = lines > PREVIEW_LINES || wraps; + const long = lines > rows || wraps; return { long, /** Cut for its wrapped rows alone: too few lines to count in "Show all N lines". */ - wrapped: long && lines <= PREVIEW_LINES, + wrapped: long && lines <= rows, cut: long && !all, all, lines, @@ -80,7 +80,7 @@ export function PreviewMore({ preview }: { preview: ReturnType { const node = ref.current; if (!watch || !node) return; - const measure = () => setPast(wrappedRows(node) > PREVIEW_LINES + 0.5); + const measure = () => setPast(wrappedRows(node) > rows + 0.5); measure(); const Observer = node.ownerDocument.defaultView?.ResizeObserver; if (!Observer) return; const observer = new Observer(measure); observer.observe(node); return () => observer.disconnect(); - }, [ref, watch, text, lines]); + }, [ref, watch, text, lines, rows]); return watch && past; } diff --git a/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx b/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx index 6a9d043e0e55..9f756708e274 100644 --- a/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from "react"; import { useT } from "../i18n"; import { useBlockWrap } from "./blockWrap"; import { Lines, lineCount, PreviewMore, usePreview, useSidewaysRef, useWrapsPast, WrapButton } from "./BlockParts"; +import { collapseLook } from "./toolOutputCollapse.experiment"; import { commandFolder } from "./commandFolder"; import { CopyButton } from "./CopyButton"; import { menuPartRef } from "./menuParts"; @@ -44,11 +45,18 @@ export function ShellBlock({ // The command's lines and all the output's, though a long output draws only its end until opened // (none to cut when whole); a few lines that wrap past the preview's rows are cut too. const lines = whole ? 0 : (rest ? lineCount(rest) : 0) + lineCount(raw.text); - const wraps = useWrapsPast(body, whole ? undefined : rest ? `${rest}\n${raw.text ?? ""}` : raw.text, wrap, lines); - const preview = usePreview(lines, reveal, wraps); + const look = collapseLook(); + const wraps = useWrapsPast( + body, + whole ? undefined : rest ? `${rest}\n${raw.text ?? ""}` : raw.text, + wrap, + lines, + look.rows, + ); + const preview = usePreview(lines, reveal, wraps, look.rows); const shown = previewedOutput(raw, preview); return ( -
+
{t("shell.label")} diff --git a/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx b/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx index a92cc1cb09f3..f30e73049195 100644 --- a/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx @@ -23,6 +23,7 @@ import { toolDuration } from "./toolGroups"; import { OutputMore, previewedOutput, useOutput } from "./toolOutput"; import { useBlockWrap } from "./blockWrap"; import { Lines, lineCount, PreviewMore, usePreview, useSidewaysRef, useWrapsPast, WrapButton } from "./BlockParts"; +import { collapseLook } from "./toolOutputCollapse.experiment"; import { cleanText } from "./mcpCalls"; import { useFold } from "./fold"; import { WriteLabel, writeCall } from "./WriteLabel"; @@ -192,11 +193,12 @@ function ToolOutput({ id, output, reveal }: { id: string; output: string; reveal // All the output's lines, though a long one draws only its end until opened; a few lines that // wrap past the preview's rows (a one-line result) are cut too. const lines = lineCount(raw.text); - const wraps = useWrapsPast(body, raw.text, wrap, lines); - const preview = usePreview(lines, reveal, wraps); + const look = collapseLook(); + const wraps = useWrapsPast(body, raw.text, wrap, lines, look.rows); + const preview = usePreview(lines, reveal, wraps, look.rows); const shown = previewedOutput(raw, preview); return ( -
+
diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index da6a8af1a303..c8352cdc9a31 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -1885,7 +1885,7 @@ pre.cv-math-source { overflow-wrap: anywhere; } .cv-shell__body[data-preview="cut"] { - max-height: calc(8 * 18px + 8px); + max-height: calc(var(--cv-preview-rows, 8) * 18px + 8px); } .cv-shell__exit { margin-top: 6px; @@ -2402,7 +2402,7 @@ a.cv-sha:focus-visible { box-shadow: none; } .cv-tool-output__box > .cv-tool-output[data-preview="cut"] { - max-height: calc(8 * 18px); + max-height: calc(var(--cv-preview-rows, 8) * 18px); } /* Wrap lines (and the hover-actions experiment's Copy) in its corner, on hover or focus. */ .cv-tool-output__actions { @@ -2480,6 +2480,37 @@ a.cv-sha:focus-visible { outline-offset: 1px; border-radius: 4px; } +/* The tool-output-collapse experiment's other looks (toolOutputCollapse.experiment.ts) on a call's + output and a Shell block; the shipped arm, fade-link, is the rules above. Its rows come in as + --cv-preview-rows. six-bar: a full-width quiet bar along the foot. ten-chip: a hard cut and a + small chip in the bottom-right corner. */ +[data-collapse="six-bar"] > .cv-output-more { + box-sizing: border-box; + width: 100%; + margin: 4px 0 0; + padding: 3px 0; + border-radius: 8px; + text-align: center; + background: var(--cv-card-fill); + box-shadow: inset 0 0 0 1px var(--cv-card-ring); +} +[data-collapse="ten-chip"] { + position: relative; +} +[data-collapse="ten-chip"] :is(.cv-shell__body, .cv-tool-output)[data-preview="cut"] { + --cv-fade-bottom: 0px; +} +[data-collapse="ten-chip"] > .cv-output-more { + position: absolute; + z-index: var(--layer-raised); + right: 8px; + bottom: 8px; + margin: 0; + padding: 1px 9px; + border-radius: 999px; + background: var(--cv-card-fill); + box-shadow: inset 0 0 0 1px var(--cv-card-ring); +} /* The answer's action row; its height is reserved. One quiet line under the answer, where its text starts, as Claude Code desktop's: Copy, Fork, Pin and Read aloud, then the answer's age. */ diff --git a/webviews/src/agent-session/acpmux/conversation/toolOutputCollapse.experiment.ts b/webviews/src/agent-session/acpmux/conversation/toolOutputCollapse.experiment.ts new file mode 100644 index 000000000000..f577f685e24e --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/toolOutputCollapse.experiment.ts @@ -0,0 +1,54 @@ +// l10n-allow-file: experiment descriptions are gallery (developer tool) text, never shown in the pane. +import type { CSSProperties } from "react"; +import { defineExperiment, experimentArm } from "../../../experiments/experiment"; + +/// How a long tool output collapses (Leo 2026-10-22: "also the tool calling i think there should be +/// more of like a "show more" type thing for long outputs or whatnot..."): a call's output and a +/// command's Shell block show their first rows, then a control that opens them in place and closes +/// them again. The arms vary the rows shown, the cut (a bottom fade or a hard edge) and where the +/// control sits; compare them in the gallery's `agent-pane.tool-output-collapse`. An edit's diff +/// keeps the eight lines and fade of the shipped arm whatever the arm. +export const toolOutputCollapse = defineExperiment({ + id: "tool-output-collapse", + title: "Tool output collapse", + description: + "A long tool output's first rows and its Show more control: how many rows, a fade or a hard cut, and a text link, a quiet bar or a corner chip.", + arms: { + "fade-link": { + label: "8 rows, fade, link", + description: + "Eight rows under a bottom fade, with a quiet text link under the block (Claude Code, the Codex app).", + }, + "six-bar": { + label: "6 rows, fade, bar", + description: "Six rows under a bottom fade, with a full-width quiet bar along the block's foot.", + }, + "ten-chip": { + label: "10 rows, hard cut, chip", + description: "Ten rows cut with a hard edge, with a small glass chip in the block's bottom-right corner.", + }, + "fifteen-link": { + label: "15 rows, fade, link", + description: "Fifteen rows under a bottom fade, with the text link under the block.", + }, + }, + defaultArm: "fade-link", +}); + +export type ToolOutputCollapse = keyof typeof toolOutputCollapse.arms; + +/// The rows each arm shows before it cuts. +export const COLLAPSE_ROWS: Readonly> = { + "fade-link": 8, + "six-bar": 6, + "ten-chip": 10, + "fifteen-link": 15, +}; + +/// The running arm's rows, and the attributes a block's card carries for its look (conversation.css +/// `[data-collapse]`): its rows as `--cv-preview-rows`, so the CSS cut and the count agree. +export function collapseLook() { + const arm = experimentArm(toolOutputCollapse); + const rows = COLLAPSE_ROWS[arm]; + return { rows, card: { "data-collapse": arm, style: { "--cv-preview-rows": rows } as CSSProperties } }; +} diff --git a/webviews/src/agent-session/acpmux/conversation/toolOutputCollapse.gallery.ts b/webviews/src/agent-session/acpmux/conversation/toolOutputCollapse.gallery.ts new file mode 100644 index 000000000000..f010b7bcb5b5 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/toolOutputCollapse.gallery.ts @@ -0,0 +1,144 @@ +// l10n-allow-file: gallery fixtures (sample prompts, commands and tool output), not shipped UI. +// Long tool outputs (ToolRow.tsx, ShellBlock.tsx, BlockParts.tsx; Leo 2026-10-22: "also the tool +// calling i think there should be more of like a "show more" type thing for long outputs or +// whatnot..."): an opened call's output and a command's Shell block show their first rows under a +// fade, then a control that opens them in place, and Show less closes them again. A block of many +// lines says "Show all N lines"; one of a few lines that wrap to many rows (a one-line JSON +// result) says "Show more". Compare arms shows the `tool-output-collapse` experiment's looks. +import { agentPaneEntry } from "../../../gallery/format"; +import { activity, assistant, chat, tool, user } from "../../../gallery/fixtures/acpmux"; +import { minutesAgo } from "../../../gallery/clock"; +import type { AcpmuxActivity } from "../model"; +import type { PlayContext } from "../../../gallery/play"; +import { toolOutputCollapse } from "./toolOutputCollapse.experiment"; + +const times = (at: number) => ({ startedAt: minutesAgo(at), endedAt: minutesAgo(at - 0.02) }); + +/// One call in a working turn, with the agent's note after it, so it draws as its own row. +const oneCall = (prompt: string, call: AcpmuxActivity, note: string) => + chat([user(prompt, 6), activity([call], 5.8), assistant(`${note}\n\nOn to the next step.`, 5.7)], { + isWorking: true, + }); + +/// A fetch that returned one line of JSON, about 2,400 characters: it wraps to many rows. +const ONE_LINE_JSON = JSON.stringify({ + data: Array.from({ length: 40 }, (_, index) => ({ + id: `item-${index + 1}`, + status: index % 7 === 3 ? "retrying" : "ok", + attempts: 1 + (index % 3), + })), + next: null, +}); + +const jsonCall = oneCall( + "Check what the items endpoint returns", + tool("Fetch https://api.example.test/v2/items", "fetch", "completed", { output: ONE_LINE_JSON, ...times(5.8) }), + "Two items are still retrying.", +); + +/// A test run of forty lines. +const LOG = Array.from({ length: 40 }, (_, index) => `(pass) retry backoff case ${index + 1} [0.${index % 10}ms]`).join( + "\n", +); + +const logCall = oneCall( + "Run the retry tests", + tool("bun test src/net --filter retry", "execute", "completed", { + command: "bun test src/net --filter retry", + output: `${LOG}\n\n 40 pass\n 0 fail`, + exitCode: 0, + ...times(5.8), + }), + "All forty retry cases pass.", +); + +const shortCall = oneCall( + "Run the focused test", + tool("bun test src/net/retry.test.ts", "execute", "completed", { + command: "bun test src/net/retry.test.ts", + output: " 3 pass\n 0 fail", + exitCode: 0, + ...times(5.8), + }), + "The focused test passes.", +); + +/// Opens the call, to show its output under it. +const openCall = async (ctx: PlayContext) => { + await ctx.click({ selector: ".cv-tool.is-toggle" }); + await ctx.waitFor(() => ctx.find({ selector: ".cv-tool.is-toggle" }).getAttribute("aria-expanded") === "true"); + await ctx.waitFor(() => Boolean(ctx.document.querySelector(".cv-tool-output, .cv-shell__body"))); +}; + +/// Waits for the output's control to read `label`. +const control = (ctx: PlayContext, label: string | RegExp) => + ctx.waitFor(() => { + const text = ctx.document.querySelector(".cv-output-more")?.textContent ?? ""; + return typeof label === "string" ? text === label : label.test(text); + }); + +/// Opens the call, then its output with Show more or Show all N lines. +const expand = async (ctx: PlayContext) => { + await openCall(ctx); + await ctx.click({ selector: ".cv-output-more" }); + await control(ctx, "Show less"); +}; + +export default agentPaneEntry({ + id: "agent-pane.tool-output-collapse", + title: "Tool output collapse", + area: "Agent pane", + height: 640, + covers: [ + "agent-session/acpmux/conversation/BlockParts.tsx", + "agent-session/acpmux/conversation/ShellBlock.tsx", + "agent-session/acpmux/conversation/ToolRow.tsx", + ], + keywords: ["show more", "show less", "collapse", "long output", "tool output", "preview"], + variants: { + "wrapped-collapsed": { + note: "A fetch's one-line JSON result wraps to many rows: its first eight under a fade, with Show more.", + snapshot: jsonCall, + play: async (ctx) => { + await openCall(ctx); + await control(ctx, "Show more"); + }, + }, + "wrapped-expanded": { + note: "Show more opens the whole result in place and turns into Show less.", + snapshot: jsonCall, + play: expand, + }, + "lines-collapsed": { + note: "A command of forty lines: its first eight under a fade, with Show all 44 lines.", + snapshot: logCall, + play: async (ctx) => { + await openCall(ctx); + await control(ctx, /^Show all \d+ lines$/); + }, + }, + "lines-expanded": { + note: "Show all 44 lines opens every line in place and turns into Show less.", + snapshot: logCall, + play: expand, + }, + short: { + note: "An output that fits shows whole, with no control.", + snapshot: shortCall, + play: openCall, + }, + }, + experiment: { + definition: toolOutputCollapse, + script: [ + { name: "open the call", run: openCall }, + { + name: "show more", + run: async (ctx) => { + await ctx.click({ selector: ".cv-output-more" }); + await control(ctx, "Show less"); + }, + }, + ], + }, +}); diff --git a/webviews/src/experiments/registry.ts b/webviews/src/experiments/registry.ts index 0abf94c3992d..ae7c539c69c8 100644 --- a/webviews/src/experiments/registry.ts +++ b/webviews/src/experiments/registry.ts @@ -6,6 +6,7 @@ import { sessionReplay } from "../agent-session/acpmux/replay/replay.experiment" import { chiefDock } from "../agent-session/acpmux/chiefDock.experiment"; import { hoverActions } from "../ui/hoverActions.experiment"; import { toolErrorLook } from "../agent-session/acpmux/conversation/toolError.experiment"; +import { toolOutputCollapse } from "../agent-session/acpmux/conversation/toolOutputCollapse.experiment"; import { threadMinimapLayout } from "../agent-session/acpmux/threadMinimap/layout.experiment"; import { startedSessionCards } from "../agent-session/acpmux/started/startedSessions.experiment"; import { chromiumGlassUnderlay } from "../ui/glass/chromiumUnderlay.experiment"; @@ -23,6 +24,7 @@ export const EXPERIMENTS: readonly Experiment[] = [ chromiumGlassUnderlay, questionPreviewLayout, toolErrorLook, + toolOutputCollapse, ]; export function experimentById(id: string): Experiment | undefined { diff --git a/webviews/test/transcript-wrapped-output-preview.test.tsx b/webviews/test/transcript-wrapped-output-preview.test.tsx index dc47dc5db352..9de89e0c6f77 100644 --- a/webviews/test/transcript-wrapped-output-preview.test.tsx +++ b/webviews/test/transcript-wrapped-output-preview.test.tsx @@ -8,8 +8,6 @@ // layout, so its height here is its text's rows at `columns` characters a row, and its // ResizeObserver fires when the test narrows or widens it. import { afterAll, afterEach, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import path from "node:path"; import { JSDOM } from "jsdom"; import { act } from "react"; import type { Root } from "react-dom/client"; @@ -112,8 +110,8 @@ const resize = (to: number) => beforeAll(() => { scope.ResizeObserver = window.ResizeObserver = class { #fire: () => void; - constructor(fire: () => void) { - this.#fire = () => fire(); + constructor(fire: (entries: unknown[], observer: unknown) => void) { + this.#fire = () => fire([], this); } observe() { observers.add(this.#fire); @@ -158,11 +156,15 @@ async function openRow(kind: string, output: string, command?: string) { await click(host.querySelector("button.cv-tool")); } -/// A one-line JSON result of 1,200 characters: 15 rows at 80 a row. -const ONE_LINE = JSON.stringify({ items: Array.from({ length: 60 }, (_, index) => `item-${index + 1}`), ok: true }); +/// A one-line JSON result of about 1,400 characters: 18 rows at 80 a row. +const ONE_LINE = JSON.stringify({ + items: Array.from({ length: 60 }, (_, index) => `item-${index + 1}-of-the-feed`), + ok: true, +}); test("a call's one-line output that wraps past eight rows is cut under a fade with Show more", async () => { expect(ONE_LINE.includes("\n")).toBe(false); + expect(ONE_LINE.length).toBeGreaterThan(9 * 80); await openRow("fetch", ONE_LINE); const card = host.querySelector(".cv-tool-output-card")!; const body = card.querySelector(".cv-tool-output")!; @@ -244,3 +246,32 @@ test("a block opened whole for a permission ask is never cut", async () => { await render(); expect(host.querySelector(".cv-shell__body")?.hasAttribute("data-preview")).toBe(false); }); + +test("the tool-output-collapse experiment's arm sets the rows a block shows before it cuts", async () => { + const twelve = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"); + try { + // Fifteen rows: twelve lines show whole. + globalThis.cmuxExperiments = { "tool-output-collapse": "fifteen-link" }; + await openRow("fetch", twelve); + const card = host.querySelector(".cv-tool-output-card")!; + expect(card.getAttribute("data-collapse")).toBe("fifteen-link"); + expect((card as HTMLElement).style.getPropertyValue("--cv-preview-rows")).toBe("15"); + expect(card.querySelector(".cv-tool-output")?.hasAttribute("data-preview")).toBe(false); + act(() => root?.unmount()); + // Six rows: the same twelve lines are cut. + globalThis.cmuxExperiments = { "tool-output-collapse": "six-bar" }; + await openRow("fetch", twelve); + expect(host.querySelector(".cv-tool-output")?.getAttribute("data-preview")).toBe("cut"); + expect(Boolean(byText("Show all 12 lines"))).toBe(true); + } finally { + globalThis.cmuxExperiments = undefined; + } +}); + +test("the shipped arm cuts at eight rows, as before", async () => { + await openRow("fetch", Array.from({ length: 9 }, (_, index) => `line ${index + 1}`).join("\n")); + const card = host.querySelector(".cv-tool-output-card")!; + expect(card.getAttribute("data-collapse")).toBe("fade-link"); + expect(card.querySelector(".cv-tool-output")?.getAttribute("data-preview")).toBe("cut"); + expect(Boolean(byText("Show all 9 lines", card))).toBe(true); +});