SLOPSHOPPER

fold-transcript

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

newrows
v0.1.0no licenseupdated 2026-10-12djavrell/dotfile/claude/mods/fold-transcript
A shopper browsing a rack in a slop shop
README

fold-transcript

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
  • Bash results (drawn inside the call's ToolUse row) and Agent results (ToolResult).
  • Agents' reports and task notifications (UserMessage from peer or task-notification), without the harness's hand-back frame.
  • Errored results stay whole; lsp-first folds its own refusals.
Source 3 files
hooks/register.tsx 104 lines
1// 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};
104
hooks/lib/fold.ts 42 lines
1type 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}
42
types/index.d.ts 11 lines
1// 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