Folds Bash and Agent results and agents' reports in the transcript to one line; a click on [+] unfolds them in place

Folds the noisy rows of the transcript to one dim line; a click on [+] unfolds a row in place, [−] folds it back (fullscreen, where the terminal reports clicks). Only the drawing changes: the model reads every result whole.
● Bash(ls -la claude/mods)
[+] 7 lignes · drwxr-xr-x - kpr 9 oct. 15:09 agents
› [+] Explore · 2 lignes · - hooks.json
ToolUse row) and Agent results (ToolResult).UserMessage from peer or task-notification), without the harness's hand-back frame.lsp-first folds its own refusals.hooks/register.tsx 104 lines1// Folds Agent and Bash results and agents' messages in the transcript to one dim line with a [+] button; [−] folds them back.
2// Only the drawing changes: the model reads the whole text.
3import { atom, read, update } from "claude-code";
4import type { EngineInterface, Register } from "claude-code";
5
6import { foldSummary, reportBody, resultText } from "./lib/fold";
7
8// the user-role rows another party wrote: a background task's notification, a message from an agent
9const FOLDED_MESSAGE_ORIGINS = new Set(["task-notification", "peer"]);
10
11// ponytail: grows with every row unfolded in the session, pruned on session.start if it ever matters
12const unfolded = atom({ plugin: "fold-transcript", key: "unfolded" } as const, []);
13
14const isUnfolded = async ($: EngineInterface, rowId: string) => (await read($, unfolded)).includes(rowId);
15
16function setUnfolded($: EngineInterface, rowId: string, shouldUnfold: boolean) {
17 return update($, unfolded, (ids) => (shouldUnfold ? [...ids, rowId] : ids.filter((id) => id !== rowId)));
18}
19
20export const register: Register = (on) => {
21 // An Agent's result has its own ToolResult block under the call's row.
22 on("ui.render", { component: "ToolResult", props: { tool: "Agent" } }, async ($, event, next) => {
23 const { tool_use_id: toolUseId, output, isErrored } = event.props;
24 const summary = isErrored ? null : foldSummary(resultText(output));
25 if (!summary) return next(event);
26
27 const { Box, Button, Text } = $.ui.resolve(event);
28 if (await isUnfolded($, toolUseId)) {
29 return (
30 <Box flexDirection="column">
31 {await next(event)}
32 <Button key={`fold-${toolUseId}`} plain onPress={() => setUnfolded($, toolUseId, false)}>
33 <Text dimColor>[−] replier</Text>
34 </Button>
35 </Box>
36 );
37 }
38 return (
39 <Button key={`fold-${toolUseId}`} plain onPress={() => setUnfolded($, toolUseId, true)}>
40 <Text dimColor wrap="truncate-end">[+] {summary}</Text>
41 </Button>
42 );
43 });
44
45 // Bash raises no ToolResult (seen live): its output is drawn inside the call's ToolUse row, so the row is
46 // drawn with an emptied output, the `Bash(...)` line alone, and the fold line under it.
47 on("ui.render", { component: "ToolUse", props: { tool: "Bash" } }, async ($, event, next) => {
48 const { tool_use_id: toolUseId, output, isRunning, isErrored } = event.props;
49 const summary = isRunning || isErrored ? null : foldSummary(resultText(output));
50 if (!summary) return next(event);
51
52 const { Box, Button, Text } = $.ui.resolve(event);
53 if (await isUnfolded($, toolUseId)) {
54 return (
55 <Box flexDirection="column">
56 {await next(event)}
57 <Button key={`fold-${toolUseId}`} plain onPress={() => setUnfolded($, toolUseId, false)}>
58 <Text dimColor>[−] replier</Text>
59 </Button>
60 </Box>
61 );
62 }
63 const emptiedOutput = { ...(output as Record<string, unknown>), stdout: "", stderr: "" };
64 return (
65 <Box flexDirection="column">
66 {await next({ ...event, props: { ...event.props, output: emptiedOutput } })}
67 <Button key={`fold-${toolUseId}`} plain onPress={() => setUnfolded($, toolUseId, true)}>
68 <Text dimColor wrap="truncate-end">[+] {summary}</Text>
69 </Button>
70 </Box>
71 );
72 });
73
74 // An agent's report or a task's notification. Folded even when isExpanded: that is true for ctrl+o but also for
75 // any body short enough to fit under the sender's label (seen live), so ctrl+o shows it folded too, [+] still works.
76 on("ui.render", { component: "UserMessage" }, async ($, event, next) => {
77 const { origin, text, from } = event.props;
78 if (!FOLDED_MESSAGE_ORIGINS.has(origin.kind)) return next(event);
79 const summary = foldSummary(reportBody(text));
80 const rowId = event.requestId;
81 if (!summary || !rowId) return next(event);
82
83 const { Box, Button, Text } = $.ui.resolve(event);
84 if (await isUnfolded($, rowId)) {
85 return (
86 <Box flexDirection="column">
87 {await next(event)}
88 <Button key={`fold-${rowId}`} plain onPress={() => setUnfolded($, rowId, false)}>
89 <Text dimColor>[−] replier</Text>
90 </Button>
91 </Box>
92 );
93 }
94 return (
95 <Button key={`fold-${rowId}`} plain onPress={() => setUnfolded($, rowId, true)}>
96 <Text dimColor wrap="truncate-end">
97 [+] {from ? `${from.name} · ` : ""}
98 {summary}
99 </Text>
100 </Button>
101 );
102 });
103};
104hooks/lib/fold.ts 42 lines1type TextBlock = { type?: unknown; text?: unknown };
2
3const blocksText = (blocks: unknown[]) =>
4 blocks
5 .map((block) => (block && typeof (block as TextBlock).text === "string" ? (block as TextBlock).text : ""))
6 .filter(Boolean)
7 .join("\n");
8
9// The text a tool result shows, from the shapes seen so far: a string, Bash's { stdout, stderr },
10// an Agent's { content: [{ text }] } or a bare block list. Empty when the shape is unknown: not folded.
11export function resultText(output: unknown): string {
12 if (typeof output === "string") return output;
13 if (Array.isArray(output)) return blocksText(output);
14 if (!output || typeof output !== "object") return "";
15 const record = output as Record<string, unknown>;
16 if (Array.isArray(record.content)) return blocksText(record.content);
17 return [record.stdout, record.stderr].filter((part) => typeof part === "string" && part !== "").join("\n");
18}
19
20// The harness frames a subagent's hand-back in a paragraph that ends with this, then indents the report under it.
21const HAND_BACK_FRAME_END = "The report follows:";
22
23// A message's own words: a subagent's report without the harness frame around it, any other text as it is.
24export function reportBody(text: string): string {
25 const frameEnd = text.indexOf(HAND_BACK_FRAME_END);
26 if (frameEnd === -1) return text;
27 return text
28 .slice(frameEnd + HAND_BACK_FRAME_END.length)
29 .split("\n")
30 .map((line) => line.replace(/^ {2}/, ""))
31 .join("\n")
32 .replace(/^\n+/, "");
33}
34
35// The one line a result folds to, or null when it has no text: nothing to fold.
36export function foldSummary(text: string): string | null {
37 if (text.trim() === "") return null;
38 const lines = text.replace(/\n+$/, "").split("\n");
39 const firstLine = lines.find((line) => line.trim() !== "")?.trim() ?? "";
40 return `${lines.length} ligne${lines.length > 1 ? "s" : ""} · ${firstLine}`;
41}
42types/index.d.ts 11 lines1// the id of each folded row the person unfolded (a tool_use_id, or a message's id), in click order
2export type UnfoldedRows = string[]
3
4declare module 'claude-code' {
5 interface PluginState {
6 'fold-transcript': {
7 unfolded: UnfoldedRows
8 }
9 }
10}
11