SLOPSHOPPER

thread-panel

A live Thread pane: each prompt you typed in this conversation, with the first paragraph of the answer under it; click a prompt to jump to it in the…

newpaneguardcommandtoastprompt
v0.1.1MITupdated 2026-10-26eranw2000/claude-code-mods/thread-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thread-panel
│ ┃ Thread ✕ › fix the failing auth test and add an audit log call │ ┃ 1 prompt in this conversation │ ┃ ⏺ Read(src/auth.ts) │ ┃ #1 You · 0s ago ⎿ Read 6 lines │ ┃ fix the failing auth test and add an audit ⏺ Update(src/auth.ts) │ ┃ log call ⎿ Added 2 lines, removed 1 line │ ┃ Claude · 0s ago ⏺ Bash(bun test) │ ┃ Done. I made `refresh` reject expired ⎿ 3 pass, 1 fail │ ┃ claims, added an audit call, and created │ ┃ `src/audit.ts`. One test still fails ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ (`revokes on logout`), which looks │ ┃ unrelated to this change. ✻ Worked for 42s · done 4:20 PM │ │ › /thread-panel │ ⎿ thread-panel: Thread pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Thread
1 prompt in this conversation #1 You · 0s ago fix the failing auth test and add an audit log call Claude · 0s ago Done. I made `refresh` reject expired claims, added an audit call, and created `src/audit.ts`. One test still fails (`revokes on logout`), which looks unrelated to this change.
README

Claude Code mods

Three mods for Claude Code that draw three live side panes (Agents, Thread and Files), showing what Claude and its helpers are doing while they work. A mod is a Claude Code plugin whose hooks draw on Claude's own screen, so these panes update by themselves as the session runs.

  • agents-panel: a live Agents pane.
  • One card per helper agent: its task, what it is doing now, its recent actions, its plan steps, tokens, an approximate cost and its time. Each helper type wears its own pixel crab (a developer with headphones and a laptop, a reviewer in a graduation cap, a planner in a captain's hat, and so on).
  • A progress bar for the current batch of helpers at the top of the pane: tasks done, percent and time left. It can also be drawn above the prompt.
  • What the main loop is doing, and every shell command of the session, with background commands kept until they report back.
  • A card for each Codex run started through codex_delegate.py (from claude-release-workflow): what it reviews, its model and effort, live tokens from its session log, time against its limit or against how long similar past runs took, and its end in words (finished, timed out, refused, and so on).
  • /agents-panel opens the pane.
  • thread-panel: a live Thread pane, your conversation at a glance.
  • Each prompt you typed in this conversation, oldest first, with the first paragraph of Claude's answer under it, and how long ago each was.
  • A long prompt is folded; press to open it whole.
  • Click a prompt to scroll the conversation to it.
  • It opens with your first prompt, and /thread-panel opens it too.
  • files-panel: a live Files pane.
  • Every file Claude reads, edits or writes this session, as a tree, with what is happening to each one and whether git has it committed.
  • Files outside the project folder are folded under their own heading.
  • /files-panel opens the pane.

The panes start over when you /clear or start a new conversation.

Three panes beside the conversation: Agents opens when a helper starts or with your first prompt, Thread lists your prompts and jumps to one on a click, and Files shows every file the session reads or edits

The diagram's source is docs/mods-flow.drawio.

Install

Add this repository as a plugin marketplace, then install the mods you want:

claude plugin marketplace add https://github.com/eranw2000/claude-code-mods
claude plugin install agents-panel@claude-code-mods
claude plugin install files-panel@claude-code-mods
claude plugin install thread-panel@claude-code-mods

Restart Claude Code to load them. The panes open by themselves when there is something to show (Agents and Thread with your first prompt), or on /agents-panel, /thread-panel and /files-panel.

If you installed agents-panel before version 0.7.0, the Thread pane was part of it. It is now thread-panel: install it as above to keep the pane.

To update later:

claude plugin marketplace update claude-code-mods
claude plugin update agents-panel@claude-code-mods
claude plugin update files-panel@claude-code-mods
claude plugin update thread-panel@claude-code-mods

Settings

Each mod has switches in /config:

  • agents-panel
  • Show the Agents pane (on by default). Off: the pane never opens by itself.
  • Show the progress bar above the prompt too (off by default). The bar is always at the top of the Agents pane; on, it is also drawn above the prompt while helpers run.
  • files-panel
  • Show the files panel (on by default). Off: nothing is tracked and the pane never opens.
  • thread-panel
  • Show the Thread pane (on by default). Off: the pane never opens by itself and /thread-panel says it is off.

What the mods run

The Codex card reads a run's own files (.codex-runs/<run>/request.json, receipt.json and child-process.json beside the run's --out file) and uses four read-only programs on macOS: find, printenv, lsof and tail. The Files pane asks git whether a file is committed. The Thread pane runs no programs. None of the mods writes files or changes anything in your repositories.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 313 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ThreadEntry } from '../types'
5
6// The Thread pane: its own id, so the engine draws it as its own tab beside
7// the other panes.
8const PANE = 'thread-panel'
9const thread = atom({ plugin: 'thread-panel', key: 'thread' } as const, [] as ThreadEntry[])
10const openPrompts = atom({ plugin: 'thread-panel', key: 'openPrompts' } as const, [] as string[])
11const sessionId = atom({ plugin: 'thread-panel', key: 'sessionId' } as const, '')
12const now = atom({ plugin: 'thread-panel', key: 'now' } as const, 0)
13
14// The pane keeps this many prompts, shows this much of a prompt folded, and
15// this much of an answer's first paragraph.
16const KEEP = 200
17const PROMPT_FOLD = 300
18const ANSWER_KEEP = 800
19const ORANGE = '#d9734e'
20// The "ago" labels move on every second while the newest prompt is still being
21// worked on, and every half minute after its answer.
22const WORKING_MS = 1000
23const IDLE_MS = 30_000
24
25// A cut that keeps a line on one row: the text, or its head and an ellipsis.
26function fit(text: string, cols: number): string {
27  return text.length <= cols ? text : `${text.slice(0, Math.max(1, cols - 1))}…`
28}
29
30function oneLine(text: string): string {
31  return text.replace(/\s+/g, ' ').trim()
32}
33
34// The text up to its first blank line, on one line.
35function firstParagraph(text: string): string {
36  return oneLine(text.trim().split(/\n\s*\n/)[0] ?? '')
37}
38
39// A span as the status line writes it: 40s, 5m14s, 2h3m.
40function span(ms: number): string {
41  const s = Math.max(0, Math.round(ms / 1000))
42  if (s < 60) return `${s}s`
43  const m = Math.floor(s / 60)
44  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
45  return `${Math.floor(m / 60)}h${m % 60}m`
46}
47
48function plural(n: number, word: string): string {
49  return `${n} ${word}${n === 1 ? '' : 's'}`
50}
51
52function str(value: unknown): string {
53  return typeof value === 'string' ? value : ''
54}
55
56async function togglePrompt($: EngineInterface, id: string) {
57  await update($, openPrompts, ids => ids.includes(id) ? ids.filter(i => i !== id) : [...ids, id])
58}
59
60// The stored row carries no prompt number, so it goes to the oldest prompt with
61// the same text still without a row, else the newest one without a row: the
62// row is stored right after its prompt.submit.
63async function noteRow($: EngineInterface, text: string, rowId: string) {
64  await update($, thread, list => {
65    if (list.some(x => x.rowId === rowId)) return list
66    const open = list.filter(x => !x.rowId)
67    const target = open.find(x => x.prompt === text) ?? open.at(-1)
68    return target ? list.map(x => x === target ? { ...x, rowId } : x) : list
69  })
70}
71
72// Scrolls the transcript to the prompt's row. The engine moves a transcript row
73// only for the person's own press, and says why when it cannot.
74async function jumpTo($: EngineInterface, rowId: string) {
75  let why: string | undefined
76  try {
77    why = (await $.ui.scroll({ to: { requestId: rowId }, block: 'start' })).deny
78  } catch (err) {
79    why = err instanceof Error ? err.message : String(err)
80  }
81  if (why) await $.ui.toast(fit(`Thread: cannot jump to that message (${oneLine(why)})`, 160))
82}
83
84// The timer that moves the "ago" labels on: one second, writing the time only
85// as often as the newest prompt's state asks for. It stops when the list is empty.
86let ticker: { cancel: () => void } | undefined
87
88async function tick($: EngineInterface) {
89  const t = await $.clock.now()
90  const list = await read($, thread)
91  const last = list.at(-1)
92  if (!last) {
93    ticker?.cancel()
94    ticker = undefined
95    return
96  }
97  const isWorking = !last.answer && !last.isStopped
98  const was = await read($, now)
99  if (isWorking ? t - was >= WORKING_MS : t - was >= IDLE_MS) await update($, now, () => t)
100}
101
102function wake($: EngineInterface) {
103  if (!ticker) ticker = $.clock.every(WORKING_MS, () => tick($))
104}
105
106async function openPane($: EngineInterface) {
107  await $.ui.open({ id: PANE, title: 'Thread' })
108}
109
110// Drops every entry: what an earlier conversation left.
111async function forget($: EngineInterface) {
112  await update($, thread, () => [])
113  await update($, openPrompts, () => [])
114}
115
116// A /clear or a resume keeps the process, and so this state, but starts another
117// conversation under a new id, and no session.start fires for it. session.end
118// resets at once; this is the fallback for a switch it did not see (a hot
119// reload in between). Only the main loop's events ask: a helper's hook may read
120// its own id. The decision is made inside one update, so of two events that
121// race only one sees the old id and drops.
122async function fresh($: EngineInterface, agentId?: string) {
123  if (agentId) return
124  let id = ''
125  try {
126    id = await $.session.id()
127  } catch {
128    return
129  }
130  let moved = false
131  await update($, sessionId, was => {
132    moved = was !== '' && was !== id
133    return id
134  })
135  if (moved) await forget($)
136}
137
138export const register: Register = (on, options) => {
139  // The switch in plugin.json's userConfig: the pane is on unless switched off.
140  const showPane = options.showPane !== false
141
142  on('session.start', async ($, e, next) => {
143    await $.command.register({
144      name: 'thread-panel',
145      description: 'Show the Thread pane: each prompt you typed, with the first paragraph of the answer',
146    })
147    const result = await next(e)
148    await fresh($)
149    // A reload starts the module over, timer included.
150    if ((await read($, thread)).length > 0) wake($)
151    return result
152  })
153
154  on('command.run', { command: 'thread-panel' }, async $ => {
155    if (!showPane) {
156      return { text: 'The Thread pane is switched off. Turn "Show the Thread pane" back on in /config.' }
157    }
158    await openPane($)
159    return { text: 'Thread pane opened.' }
160  })
161
162  // A /clear or a resume ends this conversation while the process goes on: drop
163  // its prompts now, so the pane never shows them in the next one.
164  on('session.end', async ($, e, next) => {
165    if (e.reason === 'clear' || e.reason === 'resume') {
166      try {
167        await forget($)
168        // '' is "not known yet": the next event adopts the new id without dropping.
169        await update($, sessionId, () => '')
170      } catch {}
171    }
172    return next(e)
173  })
174
175  // The main loop's answer goes under the newest prompt. A later turn with no
176  // prompt of its own (a background job's notice) replaces it, so the pane
177  // shows where the work ended up. A helper's turn writes nothing here.
178  on('turn.complete', async ($, e, next) => {
179    await fresh($, e.agentId)
180    if (!e.agentId) {
181      const answer = fit(firstParagraph(e.answer), ANSWER_KEEP)
182      if (answer || e.isAborted) {
183        const at = await $.clock.now()
184        await update($, thread, list => {
185          const last = list.at(-1)
186          if (!last) return list
187          const entry = answer
188            ? { ...last, answer, answerAt: at, isStopped: e.isAborted }
189            : { ...last, isStopped: true }
190          return [...list.slice(0, -1), entry]
191        })
192      }
193    }
194    return next(e)
195  })
196
197  // A conversation switch can first show on any main-loop event, so these ask too,
198  // and the Thread pane drops the old prompts as soon as the Agents pane does.
199  on('agent.spawn', async ($, e, next) => {
200    await fresh($, e.parentAgentId)
201    return next(e)
202  })
203
204  on('tool.call', async ($, e, next) => {
205    await fresh($, e.agentId)
206    return next(e)
207  })
208
209  on('turn.step', async function* ($, e, next) {
210    await fresh($, e.agentId)
211    return yield* next(e)
212  })
213
214  // The owner's own prompts.
215  on('prompt.submit', async ($, e, next) => {
216    await fresh($)
217    const kind = e.origin.kind
218    if ((kind === 'composer' || kind === 'bridge') && !e.isMeta) {
219      const prompt = e.text.trim()
220      if (prompt) {
221        const at = await $.clock.now()
222        // The conversation's first prompt opens the pane, so the tab is there from
223        // the start. Only the first: a pane the owner closes stays closed.
224        // Decided inside the one update, so of two prompts that race only one opens it.
225        let isFirst = false
226        await update($, thread, list => {
227          isFirst = list.length === 0
228          return [
229            ...list,
230            { n: (list.at(-1)?.n ?? 0) + 1, prompt, promptAt: at, answer: '', answerAt: 0, isStopped: false },
231          ].slice(-KEEP)
232        })
233        wake($)
234        if (isFirst && showPane) void openPane($)
235      }
236    }
237    return next(e)
238  }).catch(($, e, next) => next(e))
239
240  // The engine storing the owner's prompt as a transcript row: tie the row to
241  // its prompt, so a press on the prompt can scroll to it.
242  on('session.append', async ($, e, next) => {
243    await fresh($, e.agentId)
244    const kind = e.origin.kind
245    if (!e.agentId && e.door === 'prompt' && (kind === 'composer' || kind === 'bridge') && !e.message.isMeta) {
246      const text = e.message.content
247        .map(b => (b as { type: string; text?: unknown }).type === 'text' ? str((b as { text?: unknown }).text) : '')
248        .join('\n')
249        .trim()
250      await noteRow($, text, e.uuid)
251    }
252    return next(e)
253  }).catch(($, e, next) => next(e))
254
255  // Each prompt the owner typed, oldest first, with the first paragraph of the
256  // main loop's answer under it.
257  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
258    const { Box, Text, Button } = $.ui.resolve(e)
259    if (!showPane) {
260      return <Text dimColor>The Thread pane is switched off in /config.</Text>
261    }
262    const entries = await read($, thread)
263    const opened = await read($, openPrompts)
264    const t = Math.max(await read($, now), await $.clock.now())
265    const entry = (x: ThreadEntry) => {
266      const id = `thread-${x.n}`
267      const isOpen = opened.includes(id)
268      const asked = oneLine(x.prompt)
269      const isLong = asked.length > PROMPT_FOLD
270      // Click the prompt to scroll the conversation to it.
271      const shown = isOpen ? x.prompt : isLong ? `${asked.slice(0, PROMPT_FOLD)}…` : asked
272      const reply = x.answer
273        ? x.answer
274        : x.isStopped ? '(stopped before an answer)' : '(working…)'
275      return (
276        <Box key={id} flexDirection="column" marginTop={1}>
277          <Text wrap="truncate-end">
278            <Text color={ORANGE} bold>#{x.n} You</Text>
279            <Text dimColor> · {span(t - x.promptAt)} ago</Text>
280          </Text>
281          {x.rowId
282            ? (
283              <Button key={`jump-${id}`} plain onPress={() => jumpTo($, x.rowId!)}>
284                <Text>{shown}</Text>
285              </Button>
286            )
287            : <Text>{shown}</Text>}
288          {isLong && (
289            <Button
290              key={`open-${id}`}
291              plain
292              label={isOpen ? '▾ Hide the prompt' : '▸ Show the whole prompt'}
293              onPress={() => togglePrompt($, id)}
294            />
295          )}
296          <Text wrap="truncate-end">
297            <Text color="success" bold>Claude</Text>
298            {x.answerAt ? <Text dimColor> · {span(t - x.answerAt)} ago{x.isStopped ? ' · stopped' : ''}</Text> : null}
299          </Text>
300          <Text dimColor={!x.answer}>{reply}</Text>
301        </Box>
302      )
303    }
304    return (
305      <Box flexDirection="column" paddingX={1}>
306        <Text bold>{plural(entries.length, 'prompt')} in this conversation</Text>
307        {entries.length === 0 && <Text dimColor>Nothing typed yet in this conversation.</Text>}
308        {entries.map(entry)}
309      </Box>
310    )
311  })
312}
313
types/index.d.ts 32 lines
1// One prompt the owner typed, and the first paragraph of the main loop's
2// latest answer after it.
3export type ThreadEntry = {
4  // Counts from 1 in the conversation.
5  n: number
6  prompt: string
7  promptAt: number
8  // '' until a turn after the prompt ends with visible text.
9  answer: string
10  answerAt: number
11  // True when the newest turn after the prompt was interrupted.
12  isStopped: boolean
13  // The transcript row the prompt was stored as, so a click can scroll to it.
14  // Absent until the row is stored.
15  rowId?: string
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'thread-panel': {
21      // The owner's prompts, oldest first.
22      thread: ThreadEntry[]
23      // Ids of the entries whose prompt is shown whole.
24      openPrompts: string[]
25      // The conversation the entries belong to; a new id clears them.
26      sessionId: string
27      // The time the pane last moved its "ago" labels on.
28      now: number
29    }
30  }
31}
32