/said opens a pane listing every message you sent this session; press one to scroll the transcript to it

A Claude Code mod: /said opens a side panel listing every message you've sent in the session, as a timeline. Press one and the transcript scrolls back to it.

├─).✗ marks one you interrupted, and ! one that ended on an error or a refusal; it appears once the turn ends, and until then the turn's dot pulses. A pause of 10 minutes or more between turns shows on the rail (┆ 25m)./said again to close the panel.In a Claude Code terminal session:
/plugin install said --marketplace hfknight/claude-mod-said
Answer y to add the marketplace, then press Enter to install at user scope. It's active straight away and in every session after.
--resume, aren't.session.start: registers the /said command.command.run, only for said: opens or closes the panel. Other commands aren't seen.session.append: reads each message you send after Claude Code has stored it, to list it and scroll the panel down to it. It doesn't change or block anything.turn.complete: notes how long the turn took and how it ended; after you interrupt a turn, reads the conversation's messages to drop one that Esc took back into the prompt. It doesn't change the turn.ui.render of your messages in the transcript: notes which are on screen, to move the accent. They're drawn unchanged.ui.render of its own panel: draws the list.claude plugin validate . checks the manifest, marketplace and hooks module; claude plugin test . runs tests/. Run it from this folder with claude --plugin-dir ..
MIT
hooks/register.tsx 274 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Said } from '../types'
5
6const PANE = 'said'
7const prompts = atom({ plugin: 'said', key: 'prompts' } as const, [] as Said[])
8const focus = atom({ plugin: 'said', key: 'focus' } as const, null as string | null)
9const pulse = atom({ plugin: 'said', key: 'pulse' } as const, false)
10
11// Row ids of the person's messages the transcript shows now, as their rows report it.
12const shown = new Set<string>()
13// The topmost of them when the focus last followed it.
14let top: string | undefined
15
16// The focus follows the topmost of those when that changes, so a message sent below
17// it keeps the focus; with none on screen it stays where it was.
18const follow = async ($: EngineInterface) => {
19 const first = (await read($, prompts)).find(one => shown.has(one.uuid))
20 if (first?.uuid === top) return
21 top = first?.uuid
22 if (first !== undefined) await update($, focus, () => first.uuid)
23}
24
25// The message whose turn is running, its dot pulsing on the beat; a reload forgets it.
26let running: string | undefined
27let beat: Timer | undefined
28
29// The pane's window and its tree's height when it was last drawn.
30let seen: { offset: number; bodyRows: number; total: number } | undefined
31
32// Whether a turn was interrupted since the latest message was checked for.
33let isAborted = false
34
35// Esc before Claude answers takes the message back into the prompt: once the
36// conversation holds fewer messages of the latest one's text than are listed, it goes.
37const dropTaken = async ($: EngineInterface) => {
38 const list = await read($, prompts)
39 const latest = list.findLast(one => !one.isMidTurn)
40 if (latest === undefined) return
41 const words = latest.text.trim()
42 const kept = (await $.session.messages()).filter(m => m.role === 'user' && m.text.trim() === words).length
43 const listed = list.filter(one => !one.isMidTurn && one.text.trim() === words).length
44 if (kept < listed) await update($, prompts, now => now.filter(one => one.uuid !== latest.uuid))
45}
46
47// One line: whitespace collapsed, cut to the room with an ellipsis.
48export const shorten = (text: string, room: number) => {
49 const flat = text.replace(/\s+/g, ' ').trim() || '(no text)'
50 return flat.length <= room ? flat : flat.slice(0, Math.max(1, room - 1)) + '…'
51}
52
53// A message sent mid-turn reaches the model inside the engine's reminder
54// ("The user sent a new message while you were working: …", then "IMPORTANT: …"
55// or "This is how Claude Code surfaces …");
56// this is the person's own words from it.
57export const unframe = (text: string) => {
58 const sent = text.match(/new message while you were working:\s*([\s\S]*?)\s*(?:\n\s*(?:IMPORTANT:|This is how Claude Code)|<\/system-reminder>|$)/)
59 return (sent?.[1] ?? text).replace(/<\/?system-reminder>/g, '').trim()
60}
61
62// 24-hour local time, as `10:36`.
63export const clockTime = (ms: number) => {
64 const d = new Date(ms)
65 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
66}
67
68// A length of time to the second, as `38s`, `4m12s` or `1h05m`.
69export const span = (ms: number) => {
70 const s = Math.round(ms / 1000)
71 const m = Math.floor(s / 60)
72 if (s < 60) return `${s}s`
73 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
74 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
75}
76
77// A pause between turns to the minute, as `25m` or `1h05m`.
78export const pause = (ms: number) => {
79 const m = Math.round(ms / 60_000)
80 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
81}
82
83// What ends a turn's row once Claude is done: how long it took, after ✗ if you interrupted
84// it or ! if it stopped on an error or a refusal. Nothing for a mid-turn message.
85export const tail = (one: Said) => {
86 if (one.isMidTurn || one.tookMs === undefined) return undefined
87 const mark = one.ended === 'aborted' ? '✗ ' : one.ended === 'error' || one.ended === 'refusal' ? '! ' : ''
88 return mark + span(one.tookMs)
89}
90
91// Turns this far apart get the pause shown between them.
92const LONG_PAUSE = 10 * 60_000
93
94export const register: Register = on => {
95 on('session.start', async ($, e, next) => {
96 await $.command.register({
97 name: 'said',
98 description: 'Toggle a pane of the messages you sent; press one to jump to it',
99 })
100 return next(e)
101 })
102
103 on('command.run', { command: 'said' }, async $ => {
104 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
105 if (isOpen) {
106 await $.ui.close({ id: PANE })
107 return { text: 'Closed the Said pane.' }
108 }
109 const opened = await $.ui.open({ id: PANE, title: 'Said' })
110 // Opens at the latest message. The body becomes this plugin's to scroll a moment
111 // after the pane is placed ("not this plugin's site" until then), so it retries.
112 for (let tries = 0; opened.isPlaced && tries < 10; tries++) {
113 if ((await $.ui.scroll({ in: PANE, to: 'end' })).deny === undefined) break
114 await $.clock.sleep(20)
115 }
116 return { text: 'Opened the Said pane.' }
117 })
118
119 // A turn's length and ending go on the latest turn listed, once: a turn nobody sent
120 // (a background agent's report) finds it already timed.
121 // A turn interrupted may have taken its message back; it is gone from the
122 // conversation by now, or by the next message, which checks again.
123 on('turn.complete', async ($, e, next) => {
124 const done = await next(e)
125 if (e.agentId === undefined) {
126 running = undefined
127 beat?.cancel()
128 beat = undefined
129 await update($, pulse, () => false)
130 await update($, prompts, list => {
131 const i = list.findLastIndex(one => !one.isMidTurn)
132 if (i === -1 || list[i]?.tookMs !== undefined) return list
133 return list.map((one, j) => (j === i ? { ...one, tookMs: e.durationMs, ended: e.reason } : one))
134 })
135 }
136 if (e.isAborted && e.agentId === undefined) {
137 isAborted = true
138 await dropTaken($)
139 }
140 return done
141 })
142
143 // The person's own prompts on the main conversation, kept with the row id
144 // the transcript draws them under; one sent mid-turn comes in as a delivery.
145 on('session.append', async ($, e, next) => {
146 const isPersons = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
147 const isPrompt = e.door === 'prompt' || e.door === 'delivery'
148 // Checked before this one is stored, so the same words sent again aren't counted.
149 if (isPrompt && isPersons && e.agentId === undefined && isAborted) {
150 isAborted = false
151 await dropTaken($)
152 }
153 const stored = await next(e)
154 if (isPrompt && isPersons && e.agentId === undefined && stored.deny === undefined) {
155 const text = stored.message.content.map(block => (block.type === 'text' ? block.text : '')).join(' ')
156 const at = await $.clock.now()
157 const isMidTurn = e.door === 'delivery'
158 await update($, prompts, list => [...list, { uuid: stored.uuid, text, at, isMidTurn }])
159 await update($, focus, () => stored.uuid)
160 // The pane follows it down once it is drawn with it; with the pane closed, the
161 // scroll is refused and nothing moves.
162 $.clock.after(100, () => void $.ui.scroll({ in: PANE, to: 'end' }))
163 if (!isMidTurn) {
164 running = stored.uuid
165 beat ??= $.clock.every(800, () => void update($, pulse, isLit => !isLit))
166 }
167 }
168 return stored
169 })
170
171 // Passes every row through as drawn, noting only whether it is on screen. A render
172 // hook may not write, so the change is handed to a timer.
173 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
174 const isPersons = e.props.origin.kind === 'composer' || e.props.origin.kind === 'bridge'
175 if (isPersons && e.props.onScreen !== undefined) {
176 const was = shown.has(e.requestId)
177 if (e.props.onScreen === null) shown.delete(e.requestId)
178 else shown.add(e.requestId)
179 if (was !== shown.has(e.requestId)) $.clock.after(0, () => void follow($))
180 }
181 return next(e)
182 })
183
184 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185 const { Box, Text, Button } = $.ui.resolve(e)
186 const list = await read($, prompts)
187 if (list.length === 0) return <Text dimColor>Nothing said yet.</Text>
188
189 const columns = e.props.bodyColumns
190 // The accent marks the turn the transcript is at: the focus, or a mid-turn focus's
191 // turn; the latest turn while there is none.
192 const current = await read($, focus)
193 let accent = list.findIndex(one => one.uuid === current)
194 if (accent === -1) accent = list.findLastIndex(one => !one.isMidTurn)
195 while (accent > 0 && list[accent]?.isMidTurn) accent--
196 const midTurns = list.filter(one => one.isMidTurn).length
197 const working = list.reduce((sum, one) => sum + (one.tookMs ?? 0), 0)
198 // Rows the tree takes: one per message, the rail between turns, the rule and the footer.
199 const total = list.length + list.filter((one, i) => i > 0 && !one.isMidTurn).length + 2
200 // A pane at the end stays there when it gets shorter (the prompt grows, a notice shows)
201 // or the list longer, so the footer stays in view; scrolling up moves neither.
202 const { offset, bodyRows } = e.props.scroll
203 const wasAtEnd = seen !== undefined && seen.offset + seen.bodyRows >= seen.total
204 const isResized = seen !== undefined && (seen.bodyRows !== bodyRows || seen.total !== total)
205 if (wasAtEnd && isResized && offset + bodyRows < total) $.clock.after(0, () => void $.ui.scroll({ in: PANE, to: 'end' }))
206 seen = { offset, bodyRows, total }
207 const isLit = running === undefined || (await read($, pulse))
208 return (
209 // Docked, it fills the pane so the footer sits at the bottom under a short list.
210 <Box flexDirection="column" minHeight={e.props.placement === 'dock' ? bodyRows : undefined}>
211 {list.flatMap((one, i) => {
212 const jump = async () => {
213 await update($, focus, () => one.uuid)
214 const moved = await $.ui.scroll({ to: { requestId: one.uuid }, block: 'start' })
215 if (moved.deny !== undefined) $.ui.toast(`Couldn't jump there: ${moved.deny}`)
216 }
217 // A turn is a dot on the rail; a message sent mid-turn branches off it.
218 const marker = one.isMidTurn ? (
219 <Text color="subtle">{' ├─ '}</Text>
220 ) : (
221 <Text>
222 <Text color={i === accent ? 'claude' : 'subtle'}>
223 {one.at === undefined ? ' ' : clockTime(one.at)}
224 {` ${(i === accent ? '●○' : '•◦')[one.uuid === running && !isLit ? 1 : 0]} `}
225 </Text>
226 </Text>
227 )
228 const width = one.isMidTurn ? 9 : 8
229 // The end of the row sits at the right edge, dot leaders running up to it.
230 const end = tail(one)
231 const label = shorten(one.isMidTurn ? unframe(one.text) : one.text, columns - width - (end === undefined ? 0 : end.length + 3))
232 const after =
233 end === undefined
234 ? []
235 : [
236 <Text key={`t-${one.uuid}`}>
237 <Text color="subtle">{` ${'·'.repeat(Math.max(1, columns - width - label.length - end.length - 2))} `}</Text>
238 <Text color="subtle">{end}</Text>
239 </Text>,
240 ]
241 const row = (
242 <Box key={`r-${one.uuid}`} flexDirection="row">
243 {marker}
244 <Button key={`m-${one.uuid}`} plain label={label} dimColor={one.isMidTurn} hover={{ color: 'claude' }} onPress={jump} />
245 {after}
246 </Box>
247 )
248 if (i === 0 || one.isMidTurn) return [row]
249 // A long pause since the turn before shows on the rail between them.
250 const before = list.slice(0, i).findLast(other => !other.isMidTurn)
251 const idle = one.at === undefined || before?.at === undefined ? 0 : one.at - before.at - (before.tookMs ?? 0)
252 return [
253 <Text key={`g-${one.uuid}`} color="subtle">
254 {idle >= LONG_PAUSE ? ` ┆ ${pause(idle)}` : ' │'}
255 </Text>,
256 row,
257 ]
258 })}
259 <Box key="spacer" flexGrow={1} />
260 {/* Under the list, where the pane sits, so it stays in view. */}
261 <Text key="rule" color="subtle">
262 {'─'.repeat(columns)}
263 </Text>
264 <Text key="footer">
265 <Text bold>said</Text>
266 <Text color="inactive">
267 {` · ${list.length} sent${midTurns > 0 ? ` · ${midTurns} mid-turn` : ''}${working > 0 ? ` · ${span(working)} working` : ''}`}
268 </Text>
269 </Text>
270 </Box>
271 )
272 })
273}
274types/index.d.ts 19 lines1// `at` and `isMidTurn` are absent on rows kept before they were recorded. `tookMs` and `ended`
2// are how long Claude took over a turn and why it stopped, absent until it does.
3export type Said = {
4 uuid: string
5 text: string
6 at?: number
7 isMidTurn?: boolean
8 tookMs?: number
9 ended?: 'answer' | 'aborted' | 'refusal' | 'error'
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 // `focus` is the row id of the message the transcript is at, or null before one is known.
15 // `pulse` flips while a turn runs, its message's dot drawn hollow when it is false.
16 said: { prompts: Said[]; focus: string | null; pulse: boolean }
17 }
18}
19