A footer toggle that opens a side pane outlining the session: each prompt as an expandable thread of responses and tool calls

An outline of the current Claude Code session in a side pane.
▸ outline at the right of the prompt footer (beside mode labels such as focus) opens and closes the pane. /outline does the same from the keyboard.↳) and tool calls (» Bash npm test; ✗ on error, … while running), and expand those for up to 12 lines of text or result.3 tool calls · Read ×2, Bash ✗1); expand it to list the calls. A lone call stays a plain row.The pane docks beside the transcript in fullscreen mode at 110+ columns; otherwise it opens above the prompt. Clicking needs the fullscreen terminal's mouse support; elsewhere use /outline, ctrl+x tab into the pane and Tab through its rows.
/plugin install session-outline --marketplace emeraldwalk/claude-plugins
Agent call is one tool row.< are treated as engine-injected and skipped.hooks/register.tsx 262 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage, ToolUseSummary } from 'claude-code'
3
4const PANE = 'session-outline'
5const TITLE = 'Outline'
6
7const isOpen = atom({ plugin: 'session-outline', key: 'isOpen' } as const, false)
8const revision = atom({ plugin: 'session-outline', key: 'revision' } as const, 0)
9const expanded = atom({ plugin: 'session-outline', key: 'expanded' } as const, [])
10
11type Item =
12 | { kind: 'text'; key: string; text: string }
13 | { kind: 'tools'; key: string; uses: ToolUseSummary[] }
14
15type Thread = { key: string; prompt: string; items: Item[] }
16
17const PREVIEW_LINES = 12
18
19function isPrompt(message: SessionMessage): boolean {
20 if (message.role !== 'user' || (message.toolResults?.length ?? 0) > 0) return false
21 const text = message.text.trim()
22 // Engine-injected rows (reminders, command records) open with a tag.
23 return text !== '' && !text.startsWith('<')
24}
25
26// Tool calls with no reply text between them form one group, keyed by its
27// first call so the key holds while the group grows.
28function toThreads(messages: SessionMessage[]): Thread[] {
29 const threads: Thread[] = []
30 messages.forEach((message, index) => {
31 if (isPrompt(message)) {
32 threads.push({ key: `t${index}`, prompt: message.text.trim(), items: [] })
33 return
34 }
35 const thread = threads.at(-1)
36 if (thread === undefined || message.role !== 'assistant') return
37 if (message.text.trim() !== '') {
38 thread.items.push({ kind: 'text', key: `${thread.key}.m${index}`, text: message.text.trim() })
39 }
40 for (const use of message.toolUses) {
41 const last = thread.items.at(-1)
42 if (last?.kind === 'tools') {
43 last.uses.push(use)
44 } else {
45 thread.items.push({ kind: 'tools', key: `${thread.key}.g${use.tool_use_id}`, uses: [use] })
46 }
47 }
48 })
49 return threads
50}
51
52function toolCount(thread: Thread): number {
53 return thread.items.reduce((sum, item) => sum + (item.kind === 'tools' ? item.uses.length : 0), 0)
54}
55
56function shortName(tool: string): string {
57 return tool.startsWith('mcp__') ? (tool.split('__').at(-1) ?? tool) : tool
58}
59
60function groupSummary(uses: ToolUseSummary[]): string {
61 const counts = new Map<string, number>()
62 for (const use of uses) counts.set(shortName(use.tool), (counts.get(shortName(use.tool)) ?? 0) + 1)
63 return [...counts].map(([tool, n]) => (n > 1 ? `${tool} ×${n}` : tool)).join(', ')
64}
65function firstLine(text: string): string {
66 return text.split('\n').find(line => line.trim() !== '')?.trim() ?? ''
67}
68
69function clip(text: string, width: number): string {
70 const flat = text.replace(/\s+/g, ' ')
71 return flat.length <= width ? flat : `${flat.slice(0, Math.max(1, width - 1))}…`
72}
73
74function toolSummary(use: ToolUseSummary): string {
75 const input = use.input
76 for (const field of ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'prompt']) {
77 const value = input[field]
78 if (typeof value === 'string' && value !== '') return value
79 }
80 const anyString = Object.values(input).find(value => typeof value === 'string')
81 return typeof anyString === 'string' ? anyString : ''
82}
83
84function preview(text: string): string[] {
85 const lines = text.split('\n')
86 const shown = lines.slice(0, PREVIEW_LINES)
87 if (lines.length > PREVIEW_LINES) shown.push(`… ${lines.length - PREVIEW_LINES} more lines`)
88 return shown
89}
90
91function toggleKey(key: string) {
92 return (list: string[]) => (list.includes(key) ? list.filter(one => one !== key) : [...list, key])
93}
94
95async function togglePane($: EngineInterface) {
96 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
97 if (isUp) {
98 await $.ui.close({ id: PANE })
99 return
100 }
101 await $.ui.open({ id: PANE, title: TITLE })
102 await update($, isOpen, () => true)
103}
104
105export const register: Register = on => {
106 on('session.start', async ($, e, next) => {
107 await $.command.register({
108 name: 'outline',
109 description: 'Toggle the session outline pane',
110 })
111 // A reload keeps the pane up; keep the footer's label in step with it.
112 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
113 await update($, isOpen, () => isUp)
114
115 return next(e)
116 })
117
118 on('command.run', { command: 'outline' }, async $ => {
119 await togglePane($)
120 const isUp = await read($, isOpen)
121
122 return { text: isUp ? 'Outline pane opened.' : 'Outline pane closed.' }
123 })
124
125 on('ui.close', async ($, e, next) => {
126 const result = await next(e)
127 if (e.id === PANE) await update($, isOpen, () => false)
128
129 return result
130 }).catch(($, e, next) => next(e))
131
132 // Every row the main conversation keeps redraws the outline.
133 on('session.append', async ($, e, next) => {
134 const result = await next(e)
135 if (e.agentId === undefined) await update($, revision, n => n + 1)
136
137 return result
138 }).catch(($, e, next) => next(e))
139
140 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
141 const { Box, Text, Button } = $.ui.resolve(e)
142 const isUp = await read($, isOpen)
143 const modes = e.props.modes
144
145 return (
146 <Box>
147 {modes.length > 0 && <Text dimColor>{modes.join(' & ')} · </Text>}
148 <Button key="outline-toggle" plain onPress={() => togglePane($)}>
149 <Text dimColor={!isUp}>{isUp ? '◂ outline' : '▸ outline'}</Text>
150 </Button>
151 </Box>
152 )
153 })
154
155 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
156 const { Box, Text, Button } = $.ui.resolve(e)
157 await read($, revision)
158 const open = await read($, expanded)
159 const threads = toThreads(await $.session.messages())
160 const width = Math.max(10, e.props.bodyColumns)
161
162 const toggle = (key: string) => () => update($, expanded, toggleKey(key))
163 const allOpen = threads.length > 0 && threads.every(thread => open.includes(thread.key))
164 const mark = (key: string) => (open.includes(key) ? '▾' : '▸')
165
166 const previewRows = (text: string, indent: string) =>
167 preview(text).map(line => (
168 <Text dimColor wrap="truncate-end">
169 {indent}
170 {line}
171 </Text>
172 ))
173
174 const toolRow = (use: ToolUseSummary, key: string, indent: string) => {
175 const state = use.isError ? ' ✗' : use.text === undefined ? ' …' : ''
176 const head = `${indent}${mark(key)} » ${shortName(use.tool)}${state} `
177 return (
178 <Box key={`row:${key}`} flexDirection="column">
179 <Button key={key} plain onPress={toggle(key)}>
180 <Text color={use.isError ? 'red' : 'cyan'}>{head}</Text>
181 <Text dimColor>{clip(toolSummary(use), width - head.length)}</Text>
182 </Button>
183 {open.includes(key) && previewRows(use.text ?? '(running)', `${indent} `)}
184 </Box>
185 )
186 }
187
188 const itemRows = (thread: Thread, item: Item) => {
189 if (item.kind === 'text') {
190 const head = ` ${mark(item.key)} ↳ `
191 return (
192 <Box key={`row:${item.key}`} flexDirection="column">
193 <Button key={item.key} plain onPress={toggle(item.key)}>
194 {head}
195 {clip(firstLine(item.text), width - head.length)}
196 </Button>
197 {open.includes(item.key) && previewRows(item.text, ' ')}
198 </Box>
199 )
200 }
201 const [only] = item.uses
202 if (item.uses.length === 1 && only !== undefined) {
203 return toolRow(only, `${thread.key}.${only.tool_use_id}`, ' ')
204 }
205 const errors = item.uses.filter(use => use.isError).length
206 const isRunning = item.uses.some(use => use.text === undefined && !use.isError)
207 const head = ` ${mark(item.key)} ${item.uses.length} tool calls `
208 const state = `${errors > 0 ? ` ✗${errors}` : ''}${isRunning ? ' …' : ''}`
209 return (
210 <Box key={`row:${item.key}`} flexDirection="column">
211 <Button key={item.key} plain onPress={toggle(item.key)}>
212 <Text color="cyan">{head}</Text>
213 <Text dimColor>{clip(`· ${groupSummary(item.uses)}`, width - head.length - state.length)}</Text>
214 <Text color="red">{state}</Text>
215 </Button>
216 {open.includes(item.key) &&
217 item.uses.map(use => toolRow(use, `${thread.key}.${use.tool_use_id}`, ' '))}
218 </Box>
219 )
220 }
221
222 return (
223 <Box flexDirection="column">
224 <Box>
225 <Text dimColor>
226 {threads.length} {threads.length === 1 ? 'prompt' : 'prompts'} ·{' '}
227 </Text>
228 <Button
229 key="toggle-all"
230 plain
231 onPress={() =>
232 update($, expanded, () => (allOpen ? [] : threads.map(thread => thread.key)))
233 }
234 >
235 <Text dimColor>{allOpen ? 'collapse all' : 'expand all'}</Text>
236 </Button>
237 </Box>
238 {threads.length === 0 && <Text dimColor>No prompts yet.</Text>}
239 {threads.map((thread, n) => {
240 const isThreadOpen = open.includes(thread.key)
241 const tools = toolCount(thread)
242 const label = `${mark(thread.key)} ${n + 1}. `
243 const tail = tools > 0 ? ` (${tools})` : ''
244 return (
245 <Box key={`row:${thread.key}`} flexDirection="column">
246 <Button key={thread.key} plain onPress={toggle(thread.key)}>
247 <Text bold>{label}</Text>
248 {clip(firstLine(thread.prompt), width - label.length - tail.length)}
249 <Text dimColor>{tail}</Text>
250 </Button>
251 {isThreadOpen && thread.items.length === 0 && (
252 <Text dimColor> (no response yet)</Text>
253 )}
254 {isThreadOpen && thread.items.map(item => itemRows(thread, item))}
255 </Box>
256 )
257 })}
258 </Box>
259 )
260 })
261}
262types/index.d.ts 13 lines1/** Keys of the expanded rows: a thread's `t<index>`, or an item's `t<index>.<id>`. */
2export type ExpandedKeys = string[]
3
4declare module 'claude-code' {
5 interface PluginState {
6 'session-outline': {
7 isOpen: boolean
8 revision: number
9 expanded: ExpandedKeys
10 }
11 }
12}
13