Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 61 additions & 11 deletions webviews/src/agent-session/acpmux/conversation/BlockParts.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -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<typeof usePreview> }) {
const t = useT();
if (!preview.long) return null;
Expand All @@ -63,11 +67,57 @@ export function PreviewMore({ preview }: { preview: ReturnType<typeof usePreview
data-find-skip=""
onClick={preview.toggle}
>
{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 })}
</button>
);
}

/// 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<HTMLElement | null>,
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<HTMLElement | null>) {
useLayoutEffect(() => {
Expand Down
20 changes: 15 additions & 5 deletions webviews/src/agent-session/acpmux/conversation/ShellBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLPreElement>();
// 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<HTMLPreElement>();
return (
<div className="cv-shell" id={id} ref={menuPartRef({ command, output: shown.text })}>
<div className="cv-shell" id={id} ref={menuPartRef({ command, output: shown.text })} {...look.card}>
<div className="cv-shell__label">
<span className="cv-shell__title">
{t("shell.label")}
Expand Down
18 changes: 12 additions & 6 deletions webviews/src/agent-session/acpmux/conversation/ToolRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLPreElement>();
// 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 (
<div id={id} className="cv-tool-output-card" ref={menuPartRef({ output: shown.text })}>
<div id={id} className="cv-tool-output-card" ref={menuPartRef({ output: shown.text })} {...look.card}>
<div className="cv-tool-output__box">
<span className="cv-tool-output__actions">
<WrapButton kind="output" />
Expand Down
35 changes: 33 additions & 2 deletions webviews/src/agent-session/acpmux/conversation/conversation.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<ToolOutputCollapse, number>> = {
"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 } };
}
Loading
Loading