SLOPSHOPPER

session-outline

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

panespinnercommand
v0.1.0no licenseupdated 2026-10-16emeraldwalk/claude-plugins/plugins/session-outline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-outline
│ ┃ Outline ✕ › fix the failing auth test and add an audit log call │ ┃ 1 prompt · toggle-all │ ┃ t0 ⏺ 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 │ │ › /outline │ ⎿ session-outline: Outline pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits · outline-toggle

Draws

Pane · Outline
1 prompt · toggle-all t0
README

session-outline

An outline of the current Claude Code session in a side pane.

  • Footer toggle: ▸ 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.
  • Threads: one row per prompt, with its first line and tool-call count. Expand a row to see Claude's replies (↳) and tool calls (» Bash npm test; ✗ on error, … while running), and expand those for up to 12 lines of text or result.
  • Tool groups: consecutive tool calls with no reply between them collapse into one row (3 tool calls · Read ×2, Bash ✗1); expand it to list the calls. A lone call stays a plain row.
  • expand all / collapse all at the top.

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.

Install

/plugin install session-outline --marketplace emeraldwalk/claude-plugins

Limitations

  • Subagent transcripts are not broken out: an Agent call is one tool row.
  • User rows starting with < are treated as engine-injected and skipped.
Source 2 files
hooks/register.tsx 262 lines
1import { 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}
262
types/index.d.ts 13 lines
1/** 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