diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index 55f063ab763d..518cfdb866a2 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -1131,15 +1131,26 @@ const lineRow = () => LINE_FRAME + toolRowHeight(); /// Ordinary tool rows use the compact rhythm. Notes and specialized MCP cards keep a conservative /// estimate until their renderer reports its measured height (a wait can contain several three-line replies). -/// A short interim note folded into a run (conversation.css `.cv-tool-note`): one 20px line with -/// the plain status gap above and below it. +/// A short interim note folded into a run (conversation.css `.cv-tool-note`): its text on 20px +/// lines at the column's width, with the plain status gap above and below it. A note that wraps +/// draws a line per wrap, so it is estimated from its text as a reply is (cmux#31148). const NOTE_LINE = 20; /// The room above and below a note (#30400's --cv-note-gap): the block gap, since a run's lines are /// otherwise flush (conversation.css `.cv-tool-note`). const noteGap = () => TRANSCRIPT_BLOCK_GAP * chromeScale; -function activityStepHeight(item: AcpmuxActivity): number { +/// The prepared texts of those notes, for their estimates; cleared past 256, as a row's are. +const notePrepared = new Map(); +function noteHeight(text: string | undefined, width: number): number { + if (notePrepared.size > 256) notePrepared.clear(); + // `.cv-tool-note` wraps with the default `white-space: normal`, so a newline or a run of spaces in + // the note draws as one space: measure the text that way, not as the replies' pre-wrap. + const flat = text?.replace(/\s+/g, " ").trim(); + const lines = flat ? textHeight(flat, width, notePrepared, undefined, NOTE_LINE) : NOTE_LINE; + return Math.max(NOTE_LINE, lines) + 2 * noteGap(); +} +function activityStepHeight(item: AcpmuxActivity, width: number): number { const tool = item.tool; - if (!tool) return item.kind === "note" ? NOTE_LINE + 2 * noteGap() : 26; + if (!tool) return item.kind === "note" ? noteHeight(item.text, width) : 26; // A message to another agent (SendMessage, `acpmux send`, a sub-session's `send_message`) is one // line on the tool rows' line (AgentMessageRow.tsx), as every one-line row is. if (agentMessage(tool)) return toolRowHeight(); @@ -1188,24 +1199,26 @@ function activityStepHeight(item: AcpmuxActivity): number { /// such run grows by its other lines when it mounts and the scroll thumb jumps (Leo, 2026-10-25: /// "when I'm seeing all of these agent messages back and forth, that's when it sort of, like, gets /// confused"). -function foldedRunSteps(row: AcpmuxRow, groups: readonly ToolGroup[]): number { +function foldedRunSteps(row: AcpmuxRow, groups: readonly ToolGroup[], width: number): number { // `.cv-tools` and `.cv-tool-run` stack their lines flush (conversation.css), 22 apart. const stretches = runStretches(row, groups); return stretches.reduce( (height, stretch) => height + - (stretch.fold ? toolRowHeight() : stretch.groups.reduce((sum, group) => sum + foldedGroupHeight(group), 0)), + (stretch.fold + ? toolRowHeight() + : stretch.groups.reduce((sum, group) => sum + foldedGroupHeight(group, width), 0)), 0, ); } /// An entry a folded run keeps on its own line: a group starts closed there (ToolGroupRow.tsx /// `folded`), one line, as a sent message's row is (SentMessageLine.tsx); anything else is its calls. -function foldedGroupHeight(group: ToolGroup): number { +function foldedGroupHeight(group: ToolGroup, width: number): number { if (group.type === "group" || group.type === "send") return toolRowHeight(); // `.cv-tool-group` lists its calls flush too. const items = "items" in group ? group.items : [group.item]; - return items.reduce((sum, item) => sum + activityStepHeight(item), 0); + return items.reduce((sum, item) => sum + activityStepHeight(item, width), 0); } /// The prepared texts of those messages, for their estimates; cleared past 256, as a row's are. @@ -1581,11 +1594,13 @@ function fallbackRowHeight( // draws its stretches; one with nothing kept in sight draws one summary line, or its one call. if (row.foldRun && row.items?.length) { const groups = toolGroups(row.items); - if (groups.some(staysInSight)) return finish(TOOL_RUN_TOP + TOOL_RUN_BOTTOM + foldedRunSteps(row, groups)); + if (groups.some(staysInSight)) return finish(TOOL_RUN_TOP + TOOL_RUN_BOTTOM + foldedRunSteps(row, groups, width)); if (isFoldedRun(row.items)) return finish(TOOL_RUN_TOP + toolRowHeight() + TOOL_RUN_BOTTOM); } const items = row.items ?? []; - const steps = items.length ? items.reduce((height, item) => height + activityStepHeight(item), 0) : toolRowHeight(); + const steps = items.length + ? items.reduce((height, item) => height + activityStepHeight(item, width), 0) + : toolRowHeight(); return finish(TOOL_RUN_TOP + TOOL_RUN_BOTTOM + steps); } // A subagent group's 48px line with 8px above it, and in an open group a 48px line per @@ -1704,8 +1719,10 @@ function textHeight( width: number, prepared: Map, used?: Set, + /// The rows' line height when it is not the prose's (a folded note's 20px lines). + lineHeight = metrics().lineHeight, ): number { - const { measureFont, lineHeight } = metrics(); + const { measureFont } = metrics(); // Keyed by font too: a new chat font measures the text again. const key = `${measureFont}\n${text}`; used?.add(key); diff --git a/webviews/src/agent-session/acpmux/rowEstimates.test.ts b/webviews/src/agent-session/acpmux/rowEstimates.test.ts index 62bd0b8d3b00..a9779bb5d00a 100644 --- a/webviews/src/agent-session/acpmux/rowEstimates.test.ts +++ b/webviews/src/agent-session/acpmux/rowEstimates.test.ts @@ -520,6 +520,32 @@ test("a turn's end keeps a reply's room: a folded reply to the person keeps it, expect(keepsReplyRoom({ ...reply, streaming: true }, next)).toBe(false); }); +test("a note folded between tool rows is estimated as the lines it wraps to at the column's width", () => { + // turns.ts mergeShortNotes folds the note into the run; conversation.css `.cv-tool-note` draws it on + // 20px lines with the 12px note gap above and below (cmux#31148). + const shell = (id: string) => ({ + kind: "tool", + text: "Bash", + tool: { id, title: "Bash", kind: "execute", status: "completed", command: "git status" }, + }); + const run = (note: string) => [ + { id: "run", version: 1, at: 0, kind: "activity", items: [shell("a"), { kind: "note", text: note }, shell("b")] }, + ]; + const at = (note: string, width: number) => layoutConversation(run(note) as never, width).heights[0]!; + // A short note is one 20px line between its gaps; the run's rows and the gaps are the rest. + const oneLine = at("Checking the tests.", 720); + const long = "Now the tests pass, so the next step is the migration and its rollback path. ".repeat(4); + // A long note wraps: its extra lines are 20px each, more of them in a narrow pane. + const wide = at(long, 720) - oneLine; + const narrow = at(long, 360) - oneLine; + expect(wide).toBeGreaterThanOrEqual(20); + expect(wide % 20).toBe(0); + expect(narrow % 20).toBe(0); + expect(narrow).toBeGreaterThan(wide); + // The note wraps with `white-space: normal`: a newline in a short note draws as a space, one line. + expect(at("Checking\nthe tests.", 720)).toBe(oneLine); +}); + // A code card's estimate (model.ts codeCardHeight) follows what the card draws. const codeWidth = 600; const codeChars = (room: number) => Math.floor(room / currentChatMetrics().codeCharWidth);