diff --git a/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx b/webviews/src/agent-session/acpmux/conversation/BlockParts.tsx index 45aa219bcc8f..92a839d5c955 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 `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, 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; + 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 <= rows, 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,57 @@ 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 `rows` 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, + rows = PREVIEW_LINES, +) { + const watch = wrap && lines <= rows && 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) > 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, rows]); + 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..9f756708e274 100644 --- a/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx @@ -7,7 +7,8 @@ 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 { collapseLook } from "./toolOutputCollapse.experiment"; import { commandFolder } from "./commandFolder"; import { CopyButton } from "./CopyButton"; import { menuPartRef } from "./menuParts"; @@ -40,13 +41,22 @@ 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 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); - const body = useSidewaysRef(); 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 6d0d79511169..f30e73049195 100644 --- a/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx +++ b/webviews/src/agent-session/acpmux/conversation/ToolRow.tsx @@ -22,7 +22,8 @@ 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 { collapseLook } from "./toolOutputCollapse.experiment"; import { cleanText } from "./mcpCalls"; import { useFold } from "./fold"; import { WriteLabel, writeCall } from "./WriteLabel"; @@ -182,17 +183,22 @@ 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 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 new file mode 100644 index 000000000000..9de89e0c6f77 --- /dev/null +++ b/webviews/test/transcript-wrapped-output-preview.test.tsx @@ -0,0 +1,277 @@ +// 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 { 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: (entries: unknown[], observer: unknown) => void) { + this.#fire = () => fire([], this); + } + 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 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")!; + 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); +}); + +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); +});