Skip to content

Commit bcc0ba4

Browse files
committed
Show per-call subjects in coalesced tool lanes
1 parent a61cd76 commit bcc0ba4

3 files changed

Lines changed: 250 additions & 11 deletions

File tree

‎src/tui/stream.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ export interface StreamRow {
163163
* newest call.
164164
*/
165165
readonly memberIds?: readonly string[];
166+
/**
167+
* What each absorbed call was about, aligned with `memberIds` — a lane's
168+
* expanded body lines are "member — outcome" pairs, not bare "answered",
169+
* because four identical answers to four different calls say nothing.
170+
*/
171+
readonly memberLabels?: readonly string[];
166172
/**
167173
* Most recent result's full text on a coalesced lane — the Alt+C copy
168174
* source. Single rows copy `text` as before.

‎src/tui/tool-rows.test.ts‎

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,116 @@ describe("a run of identical calls", () => {
333333
expect(rows[0]?.outstanding).toBe(1);
334334
expect(rows[0]?.pending).toBe(true);
335335
});
336+
337+
const runLines = (row: StreamRow | undefined): string[] =>
338+
(row?.detail ?? []).map((line) =>
339+
line.map((segment) => segment.text).join(""),
340+
);
341+
342+
test("each member line names the call's own target, not a bare answer", () => {
343+
const rows: StreamRow[] = [];
344+
pushToolCall(rows, {
345+
name: "mcp__linear__save_comment",
346+
arguments: JSON.stringify({ issueId: "CL-7386", body: "looks good" }),
347+
callId: "m1",
348+
});
349+
pushToolCall(rows, {
350+
name: "mcp__linear__save_comment",
351+
arguments: JSON.stringify({ issueId: "CL-7390", body: "done" }),
352+
callId: "m2",
353+
});
354+
pushToolResult(rows, {
355+
name: "mcp__linear__save_comment",
356+
content: "",
357+
callId: "m1",
358+
});
359+
pushToolResult(rows, {
360+
name: "mcp__linear__save_comment",
361+
content: "",
362+
callId: "m2",
363+
});
364+
// An MCP call's painted summary is empty — the verb is the sentence — so
365+
// the lane reads the identifying argument (the issue, not the body).
366+
expect(rows[0]?.memberLabels).toEqual(["CL-7386", "CL-7390"]);
367+
expect(runLines(rows[0])).toEqual([
368+
"CL-7386 — answered",
369+
"CL-7390 — answered",
370+
]);
371+
});
372+
373+
test("a member settled before the lane keeps the call's label, not the payload's", () => {
374+
const rows: StreamRow[] = [];
375+
pushToolCall(rows, {
376+
name: "mcp__linear__save_comment",
377+
arguments: JSON.stringify({ issueId: "CL-7386", body: "first" }),
378+
callId: "m1",
379+
});
380+
pushToolResult(rows, {
381+
name: "mcp__linear__save_comment",
382+
content: '{"id":"comment-9"}',
383+
callId: "m1",
384+
});
385+
// The merge replaced the row's args with the answer payload; the label
386+
// must still name what the call acted on.
387+
pushToolCall(rows, {
388+
name: "mcp__linear__save_comment",
389+
arguments: JSON.stringify({ issueId: "CL-7390", body: "second" }),
390+
callId: "m2",
391+
});
392+
expect(rows[0]?.memberLabels).toEqual(["CL-7386", "CL-7390"]);
393+
expect(runLines(rows[0])).toEqual(['CL-7386 — {"id":"comment-9"}']);
394+
});
395+
396+
test("a failed member's line carries its target and the error", () => {
397+
const rows: StreamRow[] = [];
398+
pushToolCall(rows, {
399+
name: "mcp__linear__save_comment",
400+
arguments: JSON.stringify({ issueId: "CL-1", body: "x" }),
401+
callId: "m1",
402+
});
403+
pushToolCall(rows, {
404+
name: "mcp__linear__save_comment",
405+
arguments: JSON.stringify({ issueId: "CL-2", body: "y" }),
406+
callId: "m2",
407+
});
408+
pushToolResult(rows, {
409+
name: "mcp__linear__save_comment",
410+
content: "HTTP 401 unauthorized",
411+
isError: true,
412+
callId: "m1",
413+
});
414+
pushToolResult(rows, {
415+
name: "mcp__linear__save_comment",
416+
content: "",
417+
callId: "m2",
418+
});
419+
expect(runLines(rows[0])).toEqual([
420+
"CL-1 — HTTP 401 unauthorized",
421+
"CL-2 — answered",
422+
]);
423+
});
424+
425+
test("member labels are one-line and bounded", () => {
426+
const rows: StreamRow[] = [];
427+
pushToolCall(rows, {
428+
name: "mcp__granola__search",
429+
arguments: JSON.stringify({
430+
query: ` multi\n line ${"q".repeat(100)}`,
431+
}),
432+
callId: "m1",
433+
});
434+
pushToolCall(rows, {
435+
name: "mcp__granola__search",
436+
arguments: JSON.stringify({ query: "b" }),
437+
callId: "m2",
438+
});
439+
const labels = rows[0]?.memberLabels ?? [];
440+
expect(labels.length).toBe(2);
441+
expect(labels[0]?.length).toBeLessThanOrEqual(48);
442+
expect(labels[0]).not.toContain("\n");
443+
expect(labels[0]?.endsWith("…")).toBe(true);
444+
expect(labels[1]).toBe("b");
445+
});
336446
});
337447

338448
describe("parallel calls to the same tool", () => {
@@ -969,8 +1079,7 @@ describe("lane paint", () => {
9691079
const answers = (rows[0]?.detail ?? []).map((line) =>
9701080
line.map((segment) => segment.text).join(""),
9711081
);
972-
expect(answers).not.toEqual(["answered", "answered"]);
973-
expect(answers).toContain("a");
1082+
expect(answers).toEqual(["echo a — a", "echo b — b"]);
9741083
expect(rows[0]?.resultText).toBe("b");
9751084
expect(rows[0]?.previewLines).toEqual(["b"]);
9761085
});

‎src/tui/tool-rows.ts‎

Lines changed: 133 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,109 @@ function runLine(text: string, fg: string = UI.text): StyledBodyLine {
3232

3333
function appendRunLine(
3434
lines: readonly StyledBodyLine[],
35-
text: string,
35+
line: StyledBodyLine,
3636
): readonly StyledBodyLine[] {
3737
if (lines.length > MAX_RUN_LINES) return lines;
3838
if (lines.length === MAX_RUN_LINES) {
3939
return [...lines, runLine("… more answers", UI.textDim)];
4040
}
41-
return [...lines, runLine(text)];
41+
return [...lines, line];
42+
}
43+
44+
/**
45+
* One member's line in a lane's expanded body: which call, then what it got.
46+
* The label is dim so the outcome — the part that changes per member — reads
47+
* first.
48+
*/
49+
function memberRunLine(label: string, outcome: string): StyledBodyLine {
50+
if (label.length === 0) return runLine(outcome);
51+
return [
52+
{ text: label, fg: UI.textDim },
53+
{ text: ` — ${outcome}`, fg: UI.text },
54+
];
55+
}
56+
57+
/**
58+
* Argument keys that name which object a call acted on, most-identifying
59+
* first. Only consulted when the call's painted summary is empty — an MCP
60+
* call's verb is already the whole sentence, so its subject lives in the
61+
* arguments (the issue id on a save_comment, not the comment body).
62+
*/
63+
const LANE_MEMBER_KEYS = [
64+
"issueId",
65+
"issue",
66+
"commentId",
67+
"id",
68+
"key",
69+
"command",
70+
"query",
71+
"url",
72+
"pattern",
73+
"path",
74+
"file_path",
75+
"name",
76+
"description",
77+
"prompt",
78+
] as const;
79+
80+
const LANE_MEMBER_SUBJECT_MAX = 48;
81+
82+
function clipMemberSubject(value: string): string {
83+
const oneLine = value.replace(/\s+/g, " ").trim();
84+
return oneLine.length <= LANE_MEMBER_SUBJECT_MAX
85+
? oneLine
86+
: `${oneLine.slice(0, LANE_MEMBER_SUBJECT_MAX - 1)}…`;
87+
}
88+
89+
function memberArgs(raw: string): Record<string, unknown> | null {
90+
let parsed: unknown;
91+
try {
92+
parsed = JSON.parse(raw);
93+
} catch {
94+
return null;
95+
}
96+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
97+
return null;
98+
}
99+
return parsed as Record<string, unknown>;
100+
}
101+
102+
/** Which object a lane member acted on, read off its painted summary or args. */
103+
function laneMemberLabel(row: StreamRow): string {
104+
// A settled row's label was recorded at merge time, while its arguments
105+
// were still on the row — re-deriving now would read keys off the answer
106+
// payload that replaced them.
107+
if (row.callId !== undefined) {
108+
const index = (row.memberIds ?? []).indexOf(row.callId);
109+
const recorded = index >= 0 ? (row.memberLabels?.[index] ?? "") : "";
110+
if (recorded.length > 0) return recorded;
111+
}
112+
const summary = row.summary?.trim() ?? "";
113+
if (summary.length > 0) return clipMemberSubject(summary);
114+
const args = memberArgs(row.text);
115+
if (args !== null) {
116+
for (const key of LANE_MEMBER_KEYS) {
117+
const value = args[key];
118+
if (typeof value === "string" && value.trim().length > 0) {
119+
return clipMemberSubject(value);
120+
}
121+
if (typeof value === "number" || typeof value === "boolean") {
122+
return String(value);
123+
}
124+
}
125+
const first = Object.values(args).find(
126+
(value) => typeof value === "string" && value.trim().length > 0,
127+
);
128+
if (typeof first === "string") return clipMemberSubject(first);
129+
}
130+
return row.verb ?? row.meta ?? row.toolName ?? "";
131+
}
132+
133+
/** The label the result's call carried when the lane absorbed it, if known. */
134+
function memberLabelFor(call: StreamRow, result: StreamRow): string {
135+
if (result.callId === undefined) return "";
136+
const index = (call.memberIds ?? []).indexOf(result.callId);
137+
return index >= 0 ? (call.memberLabels?.[index] ?? "") : "";
42138
}
43139

44140
/** 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 {
187283
...(remaining > 0 ? { pending: true } : {}),
188284
detail: appendRunLine(
189285
call.detail ?? [],
190-
failed ? "call failed" : (addendum ?? "answered"),
286+
memberRunLine(
287+
memberLabelFor(call, result),
288+
failed ? (addendum ?? "call failed") : (addendum ?? "answered"),
289+
),
191290
),
192291
};
193292
}
@@ -196,6 +295,14 @@ export function mergeToolRows(call: StreamRow, result: StreamRow): StreamRow {
196295
const showsPayload = payload.length > 0 && payload !== base.stat;
197296
return {
198297
...base,
298+
// The merge is the last moment this call's own arguments are on the
299+
// row — record who it was so a later repeat can name it in the lane.
300+
...(call.callId !== undefined
301+
? {
302+
memberIds: [call.callId],
303+
memberLabels: [laneMemberLabel(call)],
304+
}
305+
: {}),
199306
...(result.structured !== undefined
200307
? { structured: result.structured }
201308
: {}),
@@ -232,12 +339,20 @@ export function canCoalesceCall(
232339

233340
/** Calls a lane remembers so a later result can still find this row. */
234341

235-
function laneMembers(tail: StreamRow, next: StreamRow): string[] | undefined {
236-
const members = [
342+
function laneMembers(
343+
tail: StreamRow,
344+
next: StreamRow,
345+
): { ids: string[]; labels: string[] } {
346+
const ids = [
237347
...(tail.memberIds ?? (tail.callId !== undefined ? [tail.callId] : [])),
238348
...(next.callId !== undefined ? [next.callId] : []),
239349
];
240-
return members.length > 0 ? members : undefined;
350+
const labels = [
351+
...(tail.memberLabels ??
352+
(tail.callId !== undefined ? [laneMemberLabel(tail)] : [])),
353+
...(next.callId !== undefined ? [laneMemberLabel(next)] : []),
354+
];
355+
return { ids, labels };
241356
}
242357

243358
/**
@@ -252,7 +367,15 @@ export function coalesceCallRows(tail: StreamRow, next: StreamRow): StreamRow {
252367
? (tail.detail ?? [])
253368
: tail.pending === true
254369
? []
255-
: appendRunLine([], tail.stat ?? "answered");
370+
: appendRunLine(
371+
[],
372+
memberRunLine(
373+
laneMemberLabel(tail),
374+
tail.failed === true
375+
? (tail.stat ?? "call failed")
376+
: (tail.stat ?? "answered"),
377+
),
378+
);
256379
// A run's body is the answers it collected; the argument view, table and diff
257380
// belong to a single call, which this row no longer stands alone for.
258381
const {
@@ -262,11 +385,12 @@ export function coalesceCallRows(tail: StreamRow, next: StreamRow): StreamRow {
262385
...call
263386
} = next;
264387
const inFlight = tail.outstanding ?? (tail.pending === true ? 1 : 0);
265-
const memberIds = laneMembers(tail, next);
388+
const members = laneMembers(tail, next);
266389
return {
267390
...call,
268391
callCount: (tail.callCount ?? 1) + 1,
269-
...(memberIds !== undefined ? { memberIds } : {}),
392+
...(members.ids.length > 0 ? { memberIds: members.ids } : {}),
393+
...(members.labels.length > 0 ? { memberLabels: members.labels } : {}),
270394
coalesced: true,
271395
outstanding: inFlight + 1,
272396
...(tail.failed === true ? { failed: true } : {}),

0 commit comments

Comments
 (0)