SLOPSHOPPER

plain-summary

A side pane that folds each thing you did and what Claude did after it into one plain line

newpaneguardcommandpromptmodel
v0.3.1no licenseupdated 2026-10-26jamesbarker185/claude-mods/plain-summary
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plain-summary
│ ┃ Plain summary ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Since your last message ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ fb-f1760000000020 fix the failing auth tes ⏺ Update(src/auth.ts) │ ┃ │ audit log call ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ ┌────────────────────────────────────┐ ⎿ 3 pass, 1 fail │ ┃ │ │ 08:53 Write cache.ts │ │ ┃ │ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ 08:53 Run the test suite │ │ ┃ │ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ 08:53 Show working tree status │ │ ┃ │ │ │ › /plain │ ┃ │ │ 08:53 Clean build output and push │ ⎿ plain-summary: Plain summary pane opened. │ ┃ │ │ │ │ ┃ │ │ 08:53 Show environment file │ │ ┃ │ └────────────────────────────────────┘ │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plain summary
╭─────────────────────────────────────────────────────────── │ Since your last message │ │ fb-f1760000000020 fix the failing auth test and add an aud │ │ ┌────────────────────────────────────────────────────┐ │ │ 08:53 Write cache.ts │ │ │ │ │ │ 08:53 Run the test suite │ │ │ │ │ │ 08:53 Show working tree status │ │ │ │ │ │ 08:53 Clean build output and push │ │ │ │ │ │ 08:53 Show environment file │ │ └────────────────────────────────────────────────────┘ ╰───────────────────────────────────────────────────────────
README

plain-summary

A Claude Code mod that adds a Plain summary pane beside the chat. It explains what Claude does in ASD-STE100 Simplified Technical English, without putting that instruction into Claude's own context.

  • One fold per thing you did: each message you send, shell command you run, or side task you start begins a fold. The fold holds everything Claude did until your next one. Notifications and system messages never start a fold.
  • Folded, a fold is two lines: your words, then "→ outcome" in one short line that says plainly when something failed or is not done. The newest fold is open; the rest stay folded until you open them (or "Open all").
  • Open, it shows detail one level at a time: Claude's tool calls grouped by purpose (a blocked or failed group opens itself and says why), each step with "Show command", Claude's reply in 1 to 3 short lines, and "Show full reply" for the original text.
  • Summaries are written by Haiku once Claude's turn ends: one call per fold, kept out of the main conversation. A prompt rule keeps warnings and instructions to you meaning exactly what Claude said. A quick chat answer needs no model call.
  • /plain opens the pane; /plain model haiku|sonnet|opus sets the summary model; /plain now re-summarises the newest fold.
  • History is kept per session in the mod's local store (the 20 most recent sessions), so it survives an app restart.

Install

At the prompt of a terminal session:

/plugin install plain-summary --marketplace jamesbarker185/claude-mods

From a shell:

claude plugin install plain-summary --marketplace jamesbarker185/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jamesbarker185/claude-mods, then Add plugin.

Where it works

Plain summary works in local sessions: claude in a terminal, the Code tab of the Claude Desktop app, and the VS Code extension.

It doesn't work in cloud sessions (claude.ai/code, the mobile app, or a cloud session started from the Desktop app). A cloud session doesn't install plugins that a repository turns on in .claude/settings.json, and it doesn't show what a mod draws, such as this pane. See Where mods run and What carries over. To use the pane on a repository, open a local session on it.

Source 2 files
hooks/register.tsx 565 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fold, FoldKind, Group, Say, Step, Summary } from '../types'
5
6const PANE = 'plain-summary'
7const TITLE = 'Plain summary'
8
9const folds = atom({ plugin: 'plain-summary', key: 'folds' } as const, [])
10const summarizing = atom({ plugin: 'plain-summary', key: 'summarizing' } as const, [])
11const opened = atom({ plugin: 'plain-summary', key: 'opened' } as const, [])
12const closed = atom({ plugin: 'plain-summary', key: 'closed' } as const, [])
13const openGroups = atom({ plugin: 'plain-summary', key: 'openGroups' } as const, [])
14const shownCmds = atom({ plugin: 'plain-summary', key: 'shownCmds' } as const, [])
15
16const DEFAULT_MODEL = 'haiku'
17const MODELS = ['haiku', 'sonnet', 'opus']
18// A fold this small (no tool calls, a short answer) gets no model call.
19const SMALL_ANSWER = 600
20const MAX_INPUT = 60000
21const KEEP_FOLDS = 200
22const KEEP_SESSIONS = 20
23const SAVE_DELAY_MS = 1000
24
25const SYSTEM = `You summarise one stretch of a coding session for a person who is learning to code.
26The stretch starts with what the user did and holds everything the AI assistant did until the user's next message.
27Write in ASD-STE100 Simplified Technical English: short sentences (at most 15 words), active voice, common words, one fact per sentence.
28Read less, know more: say only what the user needs to keep up. Never invent facts that are not in the input.
29Fidelity rule: keep every warning, prohibition and instruction to the user with the same meaning and polarity as the source.
30If the assistant tells the user to do something, the reply says to do it. If it tells the user not to do something, the reply says not to.
31Keep direct instructions to the user close to the source wording. Do not add safety advice of your own.
32
33Answer with one JSON object and nothing else:
34{"outcome": string, "groups": [{"title": string, "status": "ok"|"blocked"|"failed", "note"?: string, "tools": number[]}], "reply": string[]}
35- outcome: one line, at most 12 words: what came of this stretch. Say if something failed or is not done.
36- groups: tool calls grouped by purpose, in order, at most 5. Empty if there were no tool calls. title: at most 8 words, starts with a past-tense verb. note: only if blocked or failed, why, one sentence. tools: indexes of the tool calls in the group.
37- reply: 1 to 3 lines: the assistant's message to the user, rewritten in STE. Keep anything the user must do or decide.`
38
39// --- what the user did ---------------------------------------------------
40
41const HUMAN_ORIGINS = new Set(['composer', 'sdk', 'bridge'])
42
43type Classified = { kind: FoldKind; you: string } | undefined
44
45function classify(text: string): Classified {
46  const bash = text.match(/<bash-input>([\s\S]*?)<\/bash-input>/)
47  if (bash) return { kind: 'ran', you: `You ran a command: ${(bash[1] ?? '').trim()}` }
48  const task = text.match(/started your suggested background task[^(]*\("([^"]+)"\)/)
49  if (task) return { kind: 'task', you: `You started a side task: ${task[1] ?? ''}` }
50  const you = cleanText(text)
51  if (you === '') return undefined
52
53  return { kind: 'msg', you }
54}
55
56function cleanText(text: string): string {
57  return text
58    .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
59    .replace(/<pasted_content[^>]*>[\s\S]*?<\/pasted_content>/g, '[pasted text]')
60    .replace(/<bash-(stdout|stderr)>[\s\S]*?<\/bash-\1>/g, '')
61    .replace(/<\/?[a-z][\w-]*(\s[^>]*)?>/g, '')
62    .replace(/\s+/g, ' ')
63    .trim()
64}
65
66// --- recording -----------------------------------------------------------
67
68const LABEL_FIELDS = ['description', 'file_path', 'path', 'pattern', 'query', 'url', 'skill']
69const ENVELOPE_FIELDS = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta'])
70
71function stepLabel(e: Record<string, unknown>): string {
72  const tool = String(e.tool)
73  const field = LABEL_FIELDS.find(name => typeof e[name] === 'string')
74  if (field === undefined) return tool
75  const value = String(e[field])
76  const shown = field === 'description' ? value : value.split('/').pop() ?? value
77
78  return field === 'description' ? shown : `${tool} ${shown}`
79}
80
81function stepArgs(e: Record<string, unknown>): string {
82  const args = Object.fromEntries(Object.entries(e).filter(([name]) => !ENVELOPE_FIELDS.has(name)))
83  if (typeof args.command === 'string') return args.command
84
85  return JSON.stringify(args, null, 1).slice(0, 1200)
86}
87
88function textOf(content: readonly unknown[]): string {
89  return content
90    .map(block => (block as { type?: string; text?: string }))
91    .filter(block => block.type === 'text' && typeof block.text === 'string')
92    .map(block => block.text)
93    .join('\n')
94    .trim()
95}
96
97async function current($: EngineInterface): Promise<Fold | undefined> {
98  const list = await read($, folds)
99
100  return list[list.length - 1]
101}
102
103async function changeFold($: EngineInterface, id: string, change: (fold: Fold) => Fold) {
104  await update($, folds, list => list.map(fold => (fold.id === id ? change(fold) : fold)))
105  scheduleSave($)
106}
107
108// --- summarising ---------------------------------------------------------
109
110function foldText(fold: Fold): string {
111  type Event = { at: number; line: string }
112  const events: Event[] = [
113    ...fold.steps.map((step, i): Event => ({
114      at: step.at,
115      line: `[tool ${i}] ${step.isHelper ? '(helper agent) ' : ''}${step.tool} ${step.args.slice(0, 400)}${
116        step.result ? `\n  result: ${step.result.slice(0, 300)}` : ''
117      }`,
118    })),
119    ...fold.says.map((say): Event => ({ at: say.at, line: `ASSISTANT SAYS: ${say.text.slice(0, 6000)}` })),
120  ].sort((a, b) => a.at - b.at)
121  const head = `USER (${fold.kind === 'ran' ? 'ran a shell command' : fold.kind === 'task' ? 'started a side task' : 'message'}): ${fold.you}`
122
123  return [head, '', ...events.map(event => event.line)].join('\n').slice(0, MAX_INPUT)
124}
125
126function sentences(text: string): string[] {
127  return cleanText(text.replace(/[`*_#>]/g, ''))
128    .split(/(?<=[.!?])\s+/)
129    .map(s => s.trim())
130    .filter(s => s !== '')
131}
132
133function fallback(fold: Fold, model: string, at: number): Summary {
134  const said = sentences(fold.says.map(say => say.text).join(' '))
135  const byTool = new Map<string, number[]>()
136  fold.steps.forEach((step, i) => byTool.set(step.tool, [...(byTool.get(step.tool) ?? []), i]))
137  const groups: Group[] = [...byTool].map(([tool, tools]) => ({
138    title: `Used ${tool}`,
139    status: tools.some(i => fold.steps[i]?.isError === true) ? 'failed' : 'ok',
140    tools,
141  }))
142
143  return {
144    outcome: (said[0] ?? 'No reply.').slice(0, 120),
145    groups: groups.slice(0, 5),
146    reply: said.slice(0, 3).map(s => s.slice(0, 240)),
147    terms: [],
148    model,
149    at,
150    isFallback: true,
151  }
152}
153
154function parse(text: string, fold: Fold, model: string, at: number): Summary | undefined {
155  const start = text.indexOf('{')
156  const end = text.lastIndexOf('}')
157  if (start < 0 || end <= start) return undefined
158  try {
159    const raw = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
160    if (typeof raw.outcome !== 'string') return undefined
161    const groups = (Array.isArray(raw.groups) ? raw.groups : [])
162      .map(g => g as Record<string, unknown>)
163      .filter(g => typeof g.title === 'string')
164      .map(
165        (g): Group => ({
166          title: String(g.title),
167          status: g.status === 'blocked' || g.status === 'failed' ? g.status : 'ok',
168          note: typeof g.note === 'string' && g.note !== '' ? g.note : undefined,
169          tools: (Array.isArray(g.tools) ? g.tools : []).filter(
170            (i): i is number => typeof i === 'number' && i >= 0 && i < fold.steps.length,
171          ),
172        }),
173      )
174      .slice(0, 5)
175    const reply = (Array.isArray(raw.reply) ? raw.reply : []).filter((l): l is string => typeof l === 'string').slice(0, 3)
176
177    return { outcome: raw.outcome, groups, reply, terms: [], model, at }
178  } catch {
179    return undefined
180  }
181}
182
183async function chosenModel($: EngineInterface): Promise<string> {
184  const stored = await $.store.get('model')
185
186  return typeof stored === 'string' && MODELS.includes(stored) ? stored : DEFAULT_MODEL
187}
188
189// Like the chat, the pane follows its bottom: "end" keeps up as the tree grows, until the person scrolls.
190function followNewest($: EngineInterface) {
191  void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)
192}
193
194async function summarize($: EngineInterface, id: string) {
195  const fold = (await read($, folds)).find(one => one.id === id)
196  if (fold === undefined) return
197  const model = await chosenModel($)
198  const at = await $.clock.now()
199  const saidLength = fold.says.reduce((n, say) => n + say.text.length, 0)
200  if (fold.steps.length === 0 && saidLength < SMALL_ANSWER) {
201    await changeFold($, id, one => ({ ...one, summary: { ...fallback(one, 'none', at), isFallback: false } }))
202    return
203  }
204  await update($, summarizing, list => [...list.filter(one => one !== id), id])
205  try {
206    // A failed or garbled answer falls back to Claude's own words; the fold never shows an error.
207    const reply = await $.model
208      .complete({ model, system: SYSTEM, prompt: foldText(fold), maxTokens: 2000, effort: 'low' })
209      .catch(() => undefined)
210    const summary = (reply?.isAnswered ? parse(reply.text, fold, model, at) : undefined) ?? fallback(fold, model, at)
211    await changeFold($, id, one => ({ ...one, summary }))
212    followNewest($)
213  } finally {
214    await update($, summarizing, list => list.filter(one => one !== id))
215  }
216}
217
218// --- saving ----------------------------------------------------------------
219
220let historyKey: string | undefined
221let saveTimer: { cancel: () => void } | undefined
222
223async function restore($: EngineInterface) {
224  const id = await $.session.id()
225  historyKey = `history:${id}`
226  const index = ((await $.store.get('sessions')) as string[] | undefined) ?? []
227  const kept = [...index.filter(one => one !== id), id]
228  for (const old of kept.slice(0, -KEEP_SESSIONS)) await $.store.delete(`history:${old}`)
229  await $.store.set('sessions', kept.slice(-KEEP_SESSIONS))
230
231  // A hot reload keeps the session's state; only an empty one is filled from disk.
232  if ((await read($, folds)).length > 0) return
233  const saved = (await $.store.get(historyKey)) as { folds?: Fold[]; opened?: string[]; closed?: string[] } | undefined
234  const savedFolds = saved?.folds
235  if (saved === undefined || !Array.isArray(savedFolds)) return
236  await update($, folds, () => savedFolds.map(fold => ({ ...fold, isWorking: false })))
237  await update($, opened, () => saved.opened ?? [])
238  await update($, closed, () => saved.closed ?? [])
239}
240
241function scheduleSave($: EngineInterface) {
242  saveTimer?.cancel()
243  saveTimer = $.clock.after(SAVE_DELAY_MS, () => void save($))
244}
245
246async function save($: EngineInterface) {
247  saveTimer = undefined
248  if (historyKey === undefined) return
249  await $.store.set(historyKey, { folds: await read($, folds), opened: await read($, opened), closed: await read($, closed) })
250}
251
252// --- helpers for drawing --------------------------------------------------
253
254function clockTime(ms: number): string {
255  const d = new Date(ms)
256
257  return [d.getHours(), d.getMinutes()].map(n => String(n).padStart(2, '0')).join(':')
258}
259
260const GLYPH: Record<FoldKind, string> = { msg: '●', ran: '○', task: '■' }
261
262function toggleIn(list: string[], id: string): string[] {
263  return list.includes(id) ? list.filter(one => one !== id) : [...list, id]
264}
265
266// --- hooks ---------------------------------------------------------------
267
268export const register: Register = on => {
269  on('session.start', async ($, e, next) => {
270    await $.command.register({
271      name: 'plain',
272      description: 'Open the plain summary pane; "/plain model haiku|sonnet|opus" sets the model; "/plain now" re-summarises the newest fold',
273      argumentHint: '[now | model <name>]',
274    })
275    await restore($)
276    void $.ui.open({ id: PANE, title: TITLE }).then(() => followNewest($))
277
278    return next(e)
279  })
280
281  on('command.run', { command: 'plain' }, async ($, e) => {
282    await $.ui.open({ id: PANE, title: TITLE })
283    const [word, value] = e.args.trim().split(/\s+/)
284    if (word === 'model') {
285      if (!MODELS.includes(value ?? '')) return { text: `Summary model: ${await chosenModel($)}. Choose one of: ${MODELS.join(', ')}.` }
286      await $.store.set('model', value)
287      return { text: `New summaries use ${value}.` }
288    }
289    if (word === 'now') {
290      const fold = await current($)
291      if (fold === undefined) return { text: 'Nothing to summarise yet.' }
292      $.clock.after(0, () => void summarize($, fold.id))
293      return { text: 'A new summary of the newest fold is on its way.' }
294    }
295
296    return { text: 'Plain summary pane opened.' }
297  })
298
299  // A fold starts with each thing the person does: a message, a shell command, a side task.
300  on('prompt.submit', async ($, e, next) => {
301    const result = await next(e)
302    if (e.command !== undefined || !HUMAN_ORIGINS.has(e.origin.kind)) return result
303    const found = classify(e.text)
304    if (found === undefined || (e.isMeta === true && found.kind !== 'task')) return result
305    const at = await $.clock.now()
306    const fold: Fold = { id: `f${at}`, kind: found.kind, at, you: found.you, steps: [], says: [], isWorking: found.kind !== 'ran' }
307    await update($, folds, list => [...list, fold].slice(-KEEP_FOLDS))
308    // The newest fold opens by default, so a new one clears any choice made on it.
309    await update($, closed, list => list.filter(one => one !== fold.id))
310    scheduleSave($)
311    followNewest($)
312
313    return result
314  }).catch(($, e, next) => next(e))
315
316  // The rows the conversation keeps: Claude's text, for summarising and "Show full reply".
317  on('session.append', async ($, e, next) => {
318    const stored = await next(e)
319    if (e.agentId !== undefined) return stored
320    const fold = await current($)
321    if (fold === undefined) return stored
322    if (e.door === 'prompt' && fold.rowId === undefined && HUMAN_ORIGINS.has(e.origin.kind)) {
323      await changeFold($, fold.id, one => ({ ...one, rowId: e.uuid }))
324    }
325    if (e.door === 'response' && e.message.role === 'assistant') {
326      const text = textOf(e.message.content)
327      if (text !== '') {
328        const say: Say = { text, rowId: e.uuid, at: await $.clock.now() }
329        await changeFold($, fold.id, one => ({ ...one, says: [...one.says, say] }))
330      }
331    }
332
333    return stored
334  }).catch(($, e, next) => next(e))
335
336  on('tool.call', async ($, e, next) => {
337    const fold = await current($)
338    if (fold === undefined) return next(e)
339    const fields = e as unknown as Record<string, unknown>
340    const step: Step = {
341      id: e.tool_use_id,
342      tool: String(e.tool),
343      label: stepLabel(fields),
344      args: stepArgs(fields),
345      isHelper: e.agentId !== undefined,
346      at: await $.clock.now(),
347    }
348    await changeFold($, fold.id, one => ({ ...one, steps: [...one.steps, step].slice(-400) }))
349    const ran = await next(e)
350    const out = ran as unknown as { text?: unknown; isError?: unknown; deny?: unknown }
351    const result = typeof out.text === 'string' ? out.text.slice(0, 300) : typeof out.deny === 'string' ? `Denied: ${out.deny}` : undefined
352    const isError = out.isError === true || typeof out.deny === 'string'
353    await changeFold($, fold.id, one => ({
354      ...one,
355      steps: one.steps.map(s => (s.id === step.id ? { ...s, result, isError } : s)),
356    }))
357
358    return ran
359  }).catch(($, e, next) => next(e))
360
361  on('turn.complete', async ($, e, next) => {
362    const done = await next(e)
363    if (e.agentId !== undefined) return done
364    const fold = await current($)
365    if (fold === undefined) return done
366    await changeFold($, fold.id, one => ({ ...one, isWorking: false }))
367    // The summary waits until the turn has fully ended.
368    $.clock.after(250, () => void summarize($, fold.id))
369
370    return done
371  })
372
373  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
374    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
375    const list = await read($, folds)
376    const busy = await read($, summarizing)
377    const openList = await read($, opened)
378    const closedList = await read($, closed)
379    const groupsOpen = await read($, openGroups)
380    const shown = await read($, shownCmds)
381    const newest = list[list.length - 1]
382    const isOpen = (fold: Fold) => (fold.id === newest?.id ? !closedList.includes(fold.id) : openList.includes(fold.id))
383    const allOpen = list.length > 0 && list.every(isOpen)
384
385    const toggleFold = (fold: Fold) =>
386      fold.id === newest?.id ? update($, closed, l => toggleIn(l, fold.id)) : update($, opened, l => toggleIn(l, fold.id))
387
388    const toggleAll = async () => {
389      if (allOpen) {
390        await update($, opened, () => [])
391        if (newest) await update($, closed, () => [newest.id])
392      } else {
393        await update($, opened, () => list.map(fold => fold.id))
394        await update($, closed, () => [])
395      }
396      scheduleSave($)
397    }
398
399    const toggleShown = (key: string) => update($, shownCmds, l => toggleIn(l, key))
400
401    const groupView = (fold: Fold, group: Group, gi: number) => {
402      const key = `${fold.id}:${gi}`
403      const isProblem = group.status !== 'ok'
404      const isGroupOpen = isProblem ? !groupsOpen.includes(key) : groupsOpen.includes(key)
405      const steps = group.tools.map(i => fold.steps[i]).filter((s): s is Step => s !== undefined)
406
407      return (
408        <Box key={`g-${key}`} flexDirection="column">
409          <Box flexDirection="row" gap={1}>
410            <Button key={`gb-${key}`} plain onPress={() => update($, openGroups, l => toggleIn(l, key))}>
411              <Text color={isProblem ? 'error' : 'success'}>{isProblem ? '!' : '✓'}</Text>
412              <Text dimColor>{isGroupOpen ? ' ▾' : ' ▸'}</Text>
413            </Button>
414            <Box flexShrink={1} flexDirection="column">
415              <Text wrap="wrap">{group.title}</Text>
416              <Text dimColor size="caption">{`${steps.length} step${steps.length === 1 ? '' : 's'}`}</Text>
417            </Box>
418          </Box>
419          {isProblem && group.note !== undefined && (
420            <Box paddingLeft={4}>
421              <Text color="error" wrap="wrap">
422                {group.note}
423              </Text>
424            </Box>
425          )}
426          {/* A group's steps sit in a bordered box under it, so each step reads as that group's child. */}
427          {isGroupOpen && (
428            <Box flexDirection="column" marginLeft={2} marginTop={1} paddingX={1} gap={1} borderStyle="single" borderDimColor>
429              {steps.map(step => (
430                <Box key={`s-${step.id}`} flexDirection="column">
431                  <Text wrap="wrap">
432                    <Text dimColor>{`${clockTime(step.at)}  `}</Text>
433                    {step.label}
434                    {step.isError ? <Text color="error">{'  (error)'}</Text> : ''}
435                  </Text>
436                  <Button key={`sc-${step.id}`} plain dimColor onPress={() => toggleShown(step.id)}>
437                    {shown.includes(step.id) ? 'Hide command' : 'Show command'}
438                  </Button>
439                  {shown.includes(step.id) && (
440                    <Box paddingLeft={2}>
441                      <Text mono dimColor wrap="wrap">
442                        {step.args}
443                      </Text>
444                    </Box>
445                  )}
446                </Box>
447              ))}
448            </Box>
449          )}
450        </Box>
451      )
452    }
453
454    // A fold reads like the chat: your words on top, Claude's work under them, the result last.
455    const foldView = (fold: Fold) => {
456      const isFoldOpen = isOpen(fold)
457      const isNewest = fold.id === newest?.id
458      const summary = fold.summary
459      const isBusy = busy.includes(fold.id)
460      const latest = fold.steps[fold.steps.length - 1]
461      const problems = summary?.groups.filter(g => g.status !== 'ok') ?? []
462      const originalKey = `original:${fold.id}`
463      const original = fold.says.map(say => say.text).join('\n\n')
464      const status = fold.isWorking
465        ? `Working: ${latest?.label ?? 'thinking'}`
466        : isBusy
467          ? 'Writing the summary…'
468          : summary !== undefined
469            ? `→ ${summary.outcome}`
470            : fold.kind === 'ran'
471              ? '→ Done.'
472              : ''
473
474      return (
475        <Box
476          key={`f-${fold.id}`}
477          flexDirection="column"
478          gap={1}
479          paddingX={1}
480          borderStyle="round"
481          borderColor={isNewest ? 'claude' : undefined}
482          borderDimColor={!isNewest}
483        >
484          {isNewest && (
485            <Text size="caption" color="claude">
486              Since your last message
487            </Text>
488          )}
489          <Box flexDirection="row" gap={1}>
490            <Button key={`fb-${fold.id}`} plain onPress={() => toggleFold(fold)}>
491              <Text dimColor>{`${clockTime(fold.at)} `}</Text>
492              <Text color={isNewest ? 'claude' : 'text'}>{GLYPH[fold.kind]}</Text>
493              <Text dimColor>{isFoldOpen ? ' ▾' : ' ▸'}</Text>
494            </Button>
495            <Box flexShrink={1}>
496              <Text bold={fold.kind === 'msg'} wrap="wrap">
497                {fold.you.length > 300 ? `${fold.you.slice(0, 297)}…` : fold.you}
498              </Text>
499            </Box>
500          </Box>
501          {/* Claude's part of the fold hangs off a rail, indented under your words. */}
502          {isFoldOpen && (summary !== undefined || fold.steps.length > 0) && (
503            <Box flexDirection="column" marginLeft={2} paddingLeft={1} gap={1} borderStyle="single" borderDimColor>
504              {summary !== undefined && summary.groups.map((group, gi) => groupView(fold, group, gi))}
505              {summary === undefined &&
506                fold.steps.slice(-5).map(step => (
507                  <Text key={`ls-${step.id}`} dimColor wrap="wrap">{`${clockTime(step.at)}  ${step.label}`}</Text>
508                ))}
509              {summary !== undefined && summary.reply.length > 0 && (
510                <Box flexDirection="column" gap={1}>
511                  <Text dimColor size="caption">
512                    Claude replied
513                  </Text>
514                  {summary.reply.map((line, li) => (
515                    <Text key={`r-${fold.id}-${li}`} wrap="wrap">
516                      {line}
517                    </Text>
518                  ))}
519                </Box>
520              )}
521              {summary !== undefined && original !== '' && (
522                <Box flexDirection="column" gap={1}>
523                  <Button key={`ob-${fold.id}`} plain onPress={() => toggleShown(originalKey)}>
524                    <Text color="suggestion">{shown.includes(originalKey) ? 'Hide full reply' : 'Show full reply'}</Text>
525                  </Button>
526                  {shown.includes(originalKey) && <Markdown key={`om-${fold.id}`} text={original} />}
527                </Box>
528              )}
529            </Box>
530          )}
531          {status !== '' && (
532            <Box flexDirection="column">
533              <Text dimColor={fold.isWorking || isBusy} wrap="wrap">
534                {status}
535              </Text>
536              {problems.length > 0 && (
537                <Text color="error" size="caption">
538                  {problems.length === 1
539                    ? `One part ${problems[0]?.status === 'blocked' ? 'was blocked' : 'failed'}: ${problems[0]?.title ?? ''}`
540                    : `${problems.length} parts failed or were blocked`}
541                </Text>
542              )}
543            </Box>
544          )}
545        </Box>
546      )
547    }
548
549    // Oldest at the top, newest at the bottom, with the controls under it: the order of the chat.
550    return (
551      <Box flexDirection="column" gap={1}>
552        {list.length === 0 && <Text dimColor>Your next message starts the timeline.</Text>}
553        {list.map(foldView)}
554        {list.length > 1 && (
555          <Box flexDirection="row" justifyContent="flex-end">
556            <Button key="all" plain onPress={toggleAll}>
557              <Text color="suggestion">{allOpen ? 'Fold all' : 'Open all'}</Text>
558            </Button>
559          </Box>
560        )}
561      </Box>
562    )
563  })
564}
565
types/index.d.ts 56 lines
1export type FoldKind = 'msg' | 'ran' | 'task'
2
3export type Step = {
4  id: string
5  tool: string
6  label: string
7  args: string
8  result?: string
9  isError?: boolean
10  isHelper: boolean
11  at: number
12}
13
14export type Say = { text: string; rowId?: string; at: number }
15
16export type GroupStatus = 'ok' | 'blocked' | 'failed'
17
18export type Group = { title: string; status: GroupStatus; note?: string; tools: number[] }
19
20export type Term = { term: string; meaning: string }
21
22export type Summary = {
23  outcome: string
24  groups: Group[]
25  reply: string[]
26  terms: Term[]
27  model: string
28  at: number
29  isFallback?: boolean
30}
31
32export type Fold = {
33  id: string
34  kind: FoldKind
35  at: number
36  you: string
37  rowId?: string
38  steps: Step[]
39  says: Say[]
40  isWorking: boolean
41  summary?: Summary
42}
43
44declare module 'claude-code' {
45  interface PluginState {
46    'plain-summary': {
47      folds: Fold[]
48      summarizing: string[]
49      opened: string[]
50      closed: string[]
51      openGroups: string[]
52      shownCmds: string[]
53    }
54  }
55}
56