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

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.
/plain opens the pane; /plain model haiku|sonnet|opus sets the summary model; /plain now re-summarises the newest fold.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.
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.
hooks/register.tsx 565 lines1import { 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}
565types/index.d.ts 56 lines1export 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