SLOPSHOPPER

said

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

newpanerowscommandtoasttimer
v0.1.4MITupdated 2026-10-07hfknight/claude-mod-said
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · said
│ ┃ Said ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing said yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /said │ ⎿ said: Opened the Said pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Said
Nothing said yet.
README

said

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.

The /said panel beside a Claude Code transcript: thirteen messages on a timeline with how long each turn took, the accented dot on the turn the transcript is scrolled to, two mid-turn messages branching off their turns, an interrupted turn, an 11-minute pause, and a footer of counts and working time

  • Each turn is a dot with the time you sent it; a message you sent while Claude was still working branches off its turn (├─).
  • On the right, how long Claude took over the turn: ✗ 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).
  • The accented dot follows the transcript: it marks the message you jumped to or just sent, or the topmost of your messages on screen as you scroll.
  • Under the list: how many messages you've sent, and how long Claude has worked on them.
  • Run /said again to close the panel.

Install

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.

Notes

  • Jumping needs Claude Code's fullscreen layout, where Claude Code scrolls the transcript itself. In the normal layout your terminal owns the scrollback.
  • Only messages sent after the mod loads are listed; messages from before, or from a --resume, aren't.
  • The list is per session and isn't saved.

What it hooks

  • 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.

Develop

claude plugin validate . checks the manifest, marketplace and hooks module; claude plugin test . runs tests/. Run it from this folder with claude --plugin-dir ..

License

MIT

Source 2 files
hooks/register.tsx 274 lines
1import { 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}
274
types/index.d.ts 19 lines
1// `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