From bcc0ba4fd495a5c308b41447d57769b7ba53875f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:12:07 -0700 Subject: [PATCH 1/2] Show per-call subjects in coalesced tool lanes --- src/tui/stream.ts | 6 ++ src/tui/tool-rows.test.ts | 113 +++++++++++++++++++++++++++++- src/tui/tool-rows.ts | 142 +++++++++++++++++++++++++++++++++++--- 3 files changed, 250 insertions(+), 11 deletions(-) diff --git a/src/tui/stream.ts b/src/tui/stream.ts index d01c2ba5c..9de0d0ca2 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -163,6 +163,12 @@ export interface StreamRow { * newest call. */ readonly memberIds?: readonly string[]; + /** + * What each absorbed call was about, aligned with `memberIds` — a lane's + * expanded body lines are "member — outcome" pairs, not bare "answered", + * because four identical answers to four different calls say nothing. + */ + readonly memberLabels?: readonly string[]; /** * Most recent result's full text on a coalesced lane — the Alt+C copy * source. Single rows copy `text` as before. diff --git a/src/tui/tool-rows.test.ts b/src/tui/tool-rows.test.ts index 11debda4c..be85f3fff 100644 --- a/src/tui/tool-rows.test.ts +++ b/src/tui/tool-rows.test.ts @@ -333,6 +333,116 @@ describe("a run of identical calls", () => { expect(rows[0]?.outstanding).toBe(1); expect(rows[0]?.pending).toBe(true); }); + + const runLines = (row: StreamRow | undefined): string[] => + (row?.detail ?? []).map((line) => + line.map((segment) => segment.text).join(""), + ); + + test("each member line names the call's own target, not a bare answer", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-7386", body: "looks good" }), + callId: "m1", + }); + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-7390", body: "done" }), + callId: "m2", + }); + pushToolResult(rows, { + name: "mcp__linear__save_comment", + content: "", + callId: "m1", + }); + pushToolResult(rows, { + name: "mcp__linear__save_comment", + content: "", + callId: "m2", + }); + // An MCP call's painted summary is empty — the verb is the sentence — so + // the lane reads the identifying argument (the issue, not the body). + expect(rows[0]?.memberLabels).toEqual(["CL-7386", "CL-7390"]); + expect(runLines(rows[0])).toEqual([ + "CL-7386 — answered", + "CL-7390 — answered", + ]); + }); + + test("a member settled before the lane keeps the call's label, not the payload's", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-7386", body: "first" }), + callId: "m1", + }); + pushToolResult(rows, { + name: "mcp__linear__save_comment", + content: '{"id":"comment-9"}', + callId: "m1", + }); + // The merge replaced the row's args with the answer payload; the label + // must still name what the call acted on. + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-7390", body: "second" }), + callId: "m2", + }); + expect(rows[0]?.memberLabels).toEqual(["CL-7386", "CL-7390"]); + expect(runLines(rows[0])).toEqual(['CL-7386 — {"id":"comment-9"}']); + }); + + test("a failed member's line carries its target and the error", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-1", body: "x" }), + callId: "m1", + }); + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-2", body: "y" }), + callId: "m2", + }); + pushToolResult(rows, { + name: "mcp__linear__save_comment", + content: "HTTP 401 unauthorized", + isError: true, + callId: "m1", + }); + pushToolResult(rows, { + name: "mcp__linear__save_comment", + content: "", + callId: "m2", + }); + expect(runLines(rows[0])).toEqual([ + "CL-1 — HTTP 401 unauthorized", + "CL-2 — answered", + ]); + }); + + test("member labels are one-line and bounded", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "mcp__granola__search", + arguments: JSON.stringify({ + query: ` multi\n line ${"q".repeat(100)}`, + }), + callId: "m1", + }); + pushToolCall(rows, { + name: "mcp__granola__search", + arguments: JSON.stringify({ query: "b" }), + callId: "m2", + }); + const labels = rows[0]?.memberLabels ?? []; + expect(labels.length).toBe(2); + expect(labels[0]?.length).toBeLessThanOrEqual(48); + expect(labels[0]).not.toContain("\n"); + expect(labels[0]?.endsWith("…")).toBe(true); + expect(labels[1]).toBe("b"); + }); }); describe("parallel calls to the same tool", () => { @@ -969,8 +1079,7 @@ describe("lane paint", () => { const answers = (rows[0]?.detail ?? []).map((line) => line.map((segment) => segment.text).join(""), ); - expect(answers).not.toEqual(["answered", "answered"]); - expect(answers).toContain("a"); + expect(answers).toEqual(["echo a — a", "echo b — b"]); expect(rows[0]?.resultText).toBe("b"); expect(rows[0]?.previewLines).toEqual(["b"]); }); diff --git a/src/tui/tool-rows.ts b/src/tui/tool-rows.ts index 69d54d6be..183cadf18 100644 --- a/src/tui/tool-rows.ts +++ b/src/tui/tool-rows.ts @@ -32,13 +32,109 @@ function runLine(text: string, fg: string = UI.text): StyledBodyLine { function appendRunLine( lines: readonly StyledBodyLine[], - text: string, + line: StyledBodyLine, ): readonly StyledBodyLine[] { if (lines.length > MAX_RUN_LINES) return lines; if (lines.length === MAX_RUN_LINES) { return [...lines, runLine("… more answers", UI.textDim)]; } - return [...lines, runLine(text)]; + return [...lines, line]; +} + +/** + * One member's line in a lane's expanded body: which call, then what it got. + * The label is dim so the outcome — the part that changes per member — reads + * first. + */ +function memberRunLine(label: string, outcome: string): StyledBodyLine { + if (label.length === 0) return runLine(outcome); + return [ + { text: label, fg: UI.textDim }, + { text: ` — ${outcome}`, fg: UI.text }, + ]; +} + +/** + * Argument keys that name which object a call acted on, most-identifying + * first. Only consulted when the call's painted summary is empty — an MCP + * call's verb is already the whole sentence, so its subject lives in the + * arguments (the issue id on a save_comment, not the comment body). + */ +const LANE_MEMBER_KEYS = [ + "issueId", + "issue", + "commentId", + "id", + "key", + "command", + "query", + "url", + "pattern", + "path", + "file_path", + "name", + "description", + "prompt", +] as const; + +const LANE_MEMBER_SUBJECT_MAX = 48; + +function clipMemberSubject(value: string): string { + const oneLine = value.replace(/\s+/g, " ").trim(); + return oneLine.length <= LANE_MEMBER_SUBJECT_MAX + ? oneLine + : `${oneLine.slice(0, LANE_MEMBER_SUBJECT_MAX - 1)}…`; +} + +function memberArgs(raw: string): Record | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as Record; +} + +/** Which object a lane member acted on, read off its painted summary or args. */ +function laneMemberLabel(row: StreamRow): string { + // A settled row's label was recorded at merge time, while its arguments + // were still on the row — re-deriving now would read keys off the answer + // payload that replaced them. + if (row.callId !== undefined) { + const index = (row.memberIds ?? []).indexOf(row.callId); + const recorded = index >= 0 ? (row.memberLabels?.[index] ?? "") : ""; + if (recorded.length > 0) return recorded; + } + const summary = row.summary?.trim() ?? ""; + if (summary.length > 0) return clipMemberSubject(summary); + const args = memberArgs(row.text); + if (args !== null) { + for (const key of LANE_MEMBER_KEYS) { + const value = args[key]; + if (typeof value === "string" && value.trim().length > 0) { + return clipMemberSubject(value); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + } + const first = Object.values(args).find( + (value) => typeof value === "string" && value.trim().length > 0, + ); + if (typeof first === "string") return clipMemberSubject(first); + } + return row.verb ?? row.meta ?? row.toolName ?? ""; +} + +/** The label the result's call carried when the lane absorbed it, if known. */ +function memberLabelFor(call: StreamRow, result: StreamRow): string { + if (result.callId === undefined) return ""; + const index = (call.memberIds ?? []).indexOf(result.callId); + return index >= 0 ? (call.memberLabels?.[index] ?? "") : ""; } /** Longest an answer's own words may run before they belong behind the arrow. */ @@ -187,7 +283,10 @@ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow { ...(remaining > 0 ? { pending: true } : {}), detail: appendRunLine( call.detail ?? [], - failed ? "call failed" : (addendum ?? "answered"), + memberRunLine( + memberLabelFor(call, result), + failed ? (addendum ?? "call failed") : (addendum ?? "answered"), + ), ), }; } @@ -196,6 +295,14 @@ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow { const showsPayload = payload.length > 0 && payload !== base.stat; return { ...base, + // The merge is the last moment this call's own arguments are on the + // row — record who it was so a later repeat can name it in the lane. + ...(call.callId !== undefined + ? { + memberIds: [call.callId], + memberLabels: [laneMemberLabel(call)], + } + : {}), ...(result.structured !== undefined ? { structured: result.structured } : {}), @@ -232,12 +339,20 @@ export function canCoalesceCall( /** Calls a lane remembers so a later result can still find this row. */ -function laneMembers(tail: StreamRow, next: StreamRow): string[] | undefined { - const members = [ +function laneMembers( + tail: StreamRow, + next: StreamRow, +): { ids: string[]; labels: string[] } { + const ids = [ ...(tail.memberIds ?? (tail.callId !== undefined ? [tail.callId] : [])), ...(next.callId !== undefined ? [next.callId] : []), ]; - return members.length > 0 ? members : undefined; + const labels = [ + ...(tail.memberLabels ?? + (tail.callId !== undefined ? [laneMemberLabel(tail)] : [])), + ...(next.callId !== undefined ? [laneMemberLabel(next)] : []), + ]; + return { ids, labels }; } /** @@ -252,7 +367,15 @@ export function coalesceCallRows(tail: StreamRow, next: StreamRow): StreamRow { ? (tail.detail ?? []) : tail.pending === true ? [] - : appendRunLine([], tail.stat ?? "answered"); + : appendRunLine( + [], + memberRunLine( + laneMemberLabel(tail), + tail.failed === true + ? (tail.stat ?? "call failed") + : (tail.stat ?? "answered"), + ), + ); // A run's body is the answers it collected; the argument view, table and diff // belong to a single call, which this row no longer stands alone for. const { @@ -262,11 +385,12 @@ export function coalesceCallRows(tail: StreamRow, next: StreamRow): StreamRow { ...call } = next; const inFlight = tail.outstanding ?? (tail.pending === true ? 1 : 0); - const memberIds = laneMembers(tail, next); + const members = laneMembers(tail, next); return { ...call, callCount: (tail.callCount ?? 1) + 1, - ...(memberIds !== undefined ? { memberIds } : {}), + ...(members.ids.length > 0 ? { memberIds: members.ids } : {}), + ...(members.labels.length > 0 ? { memberLabels: members.labels } : {}), coalesced: true, outstanding: inFlight + 1, ...(tail.failed === true ? { failed: true } : {}), From 5fc58cdaa24bc8686adcda1d5258dc8aea9de2e4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 11 Sep 2026 22:27:34 -0700 Subject: [PATCH 2/2] Align coalesced lane member labels and cap lane memory A lane hydrated without member labels paired later answers to the wrong member; backfilled placeholders keep ids and labels aligned, and both arrays now share the run cap instead of growing unbounded. --- src/tui/tool-rows.test.ts | 85 ++++++++++++++++++++++++++++++++++++--- src/tui/tool-rows.ts | 24 +++++++---- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/tui/tool-rows.test.ts b/src/tui/tool-rows.test.ts index be85f3fff..c76571875 100644 --- a/src/tui/tool-rows.test.ts +++ b/src/tui/tool-rows.test.ts @@ -242,7 +242,7 @@ describe("a run of identical calls", () => { expect(rows[0]?.outstanding).toBe(0); }); - test("a lane keeps every member id while the count climbs", () => { + test("a lane caps member ids and labels together at the run cap", () => { const rows: StreamRow[] = []; for (let i = 1; i <= 33; i++) { pushToolCall(rows, { @@ -253,7 +253,10 @@ describe("a run of identical calls", () => { } expect(rows.length).toBe(1); expect(rows[0]?.callCount).toBe(33); - expect(rows[0]?.memberIds?.length).toBe(33); + // The lane's memory is bounded alongside the detail cap, oldest-first + // like the answers — and both arrays together, never one alone. + expect(rows[0]?.memberIds?.length).toBe(30); + expect(rows[0]?.memberLabels?.length).toBe(30); expect(rows[0]?.memberIds?.[0]).toBe("c1"); expect(pendingCallIndex(rows, "grep", "c33")).toBe(0); expect(pendingCallIndex(rows, "grep", "c1")).toBe(0); @@ -274,16 +277,24 @@ describe("a run of identical calls", () => { expect(rows.length).toBe(1); expect(rows[0]?.pending).toBe(true); expect(rows[0]?.outstanding).toBe(32); - for (let i = 2; i <= 33; i++) { + // Members past the run cap kept no id on the lane, so their answers + // cannot pair back to it — except the newest, found by the lane's own id. + for (let i = 2; i <= 30; i++) { pushToolResult(rows, { name: "grep", content: `r${i}`, callId: `c${i}`, }); } + pushToolResult(rows, { name: "grep", content: "r33", callId: "c33" }); expect(rows.length).toBe(1); - expect(rows[0]?.pending).toBeUndefined(); - expect(rows[0]?.outstanding).toBe(0); + expect(rows[0]?.pending).toBe(true); + expect(rows[0]?.outstanding).toBe(2); + pushToolResult(rows, { name: "grep", content: "r31", callId: "c31" }); + pushToolResult(rows, { name: "grep", content: "r32", callId: "c32" }); + expect(rows.length).toBe(3); + expect(rows[0]?.pending).toBe(true); + expect(rows[0]?.outstanding).toBe(2); }); test("a different tool breaks the lane", () => { @@ -370,6 +381,70 @@ describe("a run of identical calls", () => { ]); }); + test("four members each name their own target", () => { + const rows: StreamRow[] = []; + const issues = ["CL-7386", "CL-7390", "CL-7399", "CL-7401"]; + issues.forEach((issueId, i) => { + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId, body: `note ${i}` }), + callId: `m${i}`, + }); + }); + issues.forEach((_, i) => { + pushToolResult(rows, { + name: "mcp__linear__save_comment", + content: "", + callId: `m${i}`, + }); + }); + expect(rows.length).toBe(1); + expect(rows[0]?.memberLabels).toEqual(issues); + expect(runLines(rows[0])).toEqual(issues.map((id) => `${id} — answered`)); + }); + + test("a pre-PR lane with ids but no labels coalesces with aligned placeholders", () => { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-7386", body: "first" }), + callId: "m1", + }); + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-7390", body: "second" }), + callId: "m2", + }); + // Lanes persisted before per-call labels carry memberIds without + // memberLabels; only the tail's own label is still recoverable. + const { memberLabels: _dropped, ...legacy } = defined(rows[0]); + rows[0] = legacy; + pushToolCall(rows, { + name: "mcp__linear__save_comment", + arguments: JSON.stringify({ issueId: "CL-7399", body: "third" }), + callId: "m3", + }); + expect(rows[0]?.memberIds).toEqual(["m1", "m2", "m3"]); + expect(rows[0]?.memberLabels).toEqual(["", "CL-7390", "CL-7399"]); + pushToolResult(rows, { + name: "mcp__linear__save_comment", + content: "", + callId: "m2", + }); + // Without the placeholder backfill the second result would read the + // third member's label. + expect(runLines(rows[0])).toEqual(["CL-7390 — answered"]); + pushToolResult(rows, { + name: "mcp__linear__save_comment", + content: "", + callId: "m3", + }); + expect(runLines(rows[0])).toEqual([ + "CL-7390 — answered", + "CL-7399 — answered", + ]); + }); + test("a member settled before the lane keeps the call's label, not the payload's", () => { const rows: StreamRow[] = []; pushToolCall(rows, { diff --git a/src/tui/tool-rows.ts b/src/tui/tool-rows.ts index 183cadf18..193845cce 100644 --- a/src/tui/tool-rows.ts +++ b/src/tui/tool-rows.ts @@ -343,16 +343,26 @@ function laneMembers( tail: StreamRow, next: StreamRow, ): { ids: string[]; labels: string[] } { - const ids = [ - ...(tail.memberIds ?? (tail.callId !== undefined ? [tail.callId] : [])), - ...(next.callId !== undefined ? [next.callId] : []), - ]; + const tailIds = + tail.memberIds ?? (tail.callId !== undefined ? [tail.callId] : []); + // A lane hydrated from pre-PR history carries memberIds without + // memberLabels. Backfill placeholders so the two arrays stay aligned — + // only the tail's own label is still recoverable; earlier members read as + // bare outcomes until their answers arrive. + const tailLabels = + tail.memberLabels ?? + tailIds.map((id) => (id === tail.callId ? laneMemberLabel(tail) : "")); + const ids = [...tailIds, ...(next.callId !== undefined ? [next.callId] : [])]; const labels = [ - ...(tail.memberLabels ?? - (tail.callId !== undefined ? [laneMemberLabel(tail)] : [])), + ...tailLabels, ...(next.callId !== undefined ? [laneMemberLabel(next)] : []), ]; - return { ids, labels }; + // Bound the lane's memory alongside the detail cap, oldest-first like the + // answers. Both arrays are sliced together so they never come unaligned. + return { + ids: ids.slice(0, MAX_RUN_LINES), + labels: labels.slice(0, MAX_RUN_LINES), + }; } /**