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…

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.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./thread-panel opens it too.files-panel: a live Files pane./files-panel opens the pane.The panes start over when you /clear or start a new conversation.

The diagram's source is docs/mods-flow.drawio.
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
Each mod has switches in /config:
agents-panelfiles-panelthread-panel/thread-panel says it is off.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.
MIT. See LICENSE.
hooks/register.tsx 313 lines1import { 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}
313types/index.d.ts 32 lines1// 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