SLOPSHOPPER

session-sidebar

A right-hand sidebar for the session: every link mentioned, things to know, and action items you tick off into a collapsible Done list.

newpaneguardcommandtooltimer
v0.1.0MITupdated 2026-10-09icedevil2001/session-sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-sidebar
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ ACTION ITEMS (0) │ ┃ Nothing to do yet. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ + : add an action item ⏎ add ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ THINGS TO KNOW (0) ⏺ Bash(bun test) │ ┃ Claude pins notes here. ⎿ 3 pass, 1 fail │ ┃ │ ┃ LINKS (0) ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Links from the session land here. │ ┃ ✻ Worked for 42s · done 4:20 PM │ │ › /sidebar │ ⎿ session-sidebar: Session sidebar opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
ACTION ITEMS (0) Nothing to do yet. + : add an action item ⏎ add THINGS TO KNOW (0) Claude pins notes here. LINKS (0) Links from the session land here.
README

session-sidebar

A Claude Code mod. Part of my wider collection, claude-mods.

A right-hand sidebar for the session with three lists: Action items, Things to know and Links. Tick an action item and it is crossed out, then folds into a collapsed Done dropdown, so it is still there but out of the way. Inspired by samaphp/session-links, which puts links in a band above the prompt; this one moves them to a side panel and adds notes and to-dos.

Install

claude plugin marketplace add icedevil2001/session-sidebar
claude plugin install session-sidebar@session-sidebar --scope user

Needs a Claude Code build with mods (tested on 2.1.295).

Use

  • The sidebar opens at session start. It docks on the right in the fullscreen terminal layout (110+ columns) and in the desktop app; on the main terminal screen it sits above the prompt. /sidebar opens it again after you close it.
  • Links: every http(s) link you or Claude mention is collected. Tool output is left out. × dismisses a link.
  • Things to know / Action items: Claude adds these with its note tool when something should outlive the turn, such as a caveat, or a step only you can do. You can add your own action items in the + box.
  • ☐ ticks an item. It stays crossed out for a moment, then moves under ▸ Done (n). Open the dropdown to see it, and press ☑ / ↺ to bring it back.
  • Everything is saved per session and comes back on --resume.

Footprint

  • The note tool's schema is in Claude's tool list, which costs a few hundred tokens of context. Each call to it is a normal tool call.
  • Link collection is local: nothing is fetched and the model never sees the list.

Limits

  • After /clear the sidebar keeps the previous session's items until the next start.
  • One store entry per session, never pruned (see the ponytail: note in hooks/register.tsx).
Source 2 files
hooks/register.tsx 295 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, Kind } from '../types'
5
6const PANE = 'session-sidebar'
7const TOOL = 'mcp__session-sidebar__note'
8// How long a ticked item stays crossed out in place before it folds into Done.
9const SETTLE_MS = 1500
10const URL_RE = /https?:\/\/[^\s<>"'`\\^{}|]+/gi
11
12const sessionId = atom({ plugin: 'session-sidebar', key: 'sessionId' } as const, '')
13const items = atom({ plugin: 'session-sidebar', key: 'items' } as const, [])
14const showDone = atom({ plugin: 'session-sidebar', key: 'showDone' } as const, false)
15const settling = atom({ plugin: 'session-sidebar', key: 'settling' } as const, [])
16
17type Engine = EngineInterface
18type Row = { agentId?: string; message: { content: unknown } }
19
20const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`)
21const tally = (text: string, mark: string): number => text.split(mark).length - 1
22
23/** Drops sentence punctuation off a URL's end; a `)` or `]` stays when the URL opened it (`/wiki/Foo_(bar)`). */
24function trimTail(url: string): string {
25  for (;;) {
26    const last = url.at(-1) ?? ''
27    const isLoose =
28      last === ')' ? tally(url, ')') > tally(url, '(') : last === ']' ? tally(url, ']') > tally(url, '[') : '.,;:!?*_~'.includes(last)
29
30    if (url === '' || !isLoose) {
31      return url
32    }
33
34    url = url.slice(0, -1)
35  }
36}
37
38/** The http(s) links a text mentions, normalized, each once. Injected reminders are not the conversation. */
39export function extractUrls(text: string): string[] {
40  const found = new Set<string>()
41  const said = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
42
43  for (const [raw] of said.matchAll(URL_RE)) {
44    const url = trimTail(raw)
45
46    // `https://host/…` was shortened by its writer and opens nothing.
47    if (url.includes('…') || !URL.canParse(url)) {
48      continue
49    }
50
51    const { hostname, port, href } = new URL(url)
52
53    if (hostname.includes('.') || hostname === 'localhost' || port !== '') {
54      found.add(href)
55    }
56  }
57
58  return [...found]
59}
60
61/** Host and path, the query folded: that is where tokens ride. */
62const displayOf = (url: string, max: number): string => {
63  const { host, pathname, search } = new URL(url)
64
65  return clip(host.replace(/^www\./, '') + pathname.replace(/\/$/, '') + (search === '' ? '' : '?…'), max)
66}
67
68async function save($: Engine): Promise<void> {
69  const id = await read($, sessionId)
70
71  // ponytail: one store key per session, never pruned; prune by age if $.store's 4 MiB fills.
72  if (id !== '') {
73    await $.store.set(`session:${id}`, await read($, items))
74  }
75}
76
77async function add($: Engine, kind: Kind, texts: readonly string[], from: Item['from']): Promise<number> {
78  let added = 0
79
80  await update($, items, list => {
81    const next = [...list]
82
83    for (const raw of texts) {
84      const text = raw.replace(/\p{Cc}/gu, ' ').trim().slice(0, 300)
85      const id = kind === 'link' ? `link:${text}` : `${kind}:${crypto.randomUUID()}`
86
87      // A link mentioned again is the same item, even dismissed: a mention never undoes a decision.
88      if (text !== '' && !next.some(item => item.id === id)) {
89        next.push({ id, kind, text, from })
90        added += 1
91      }
92    }
93
94    return next
95  })
96
97  if (added > 0) {
98    await save($)
99  }
100
101  return added
102}
103
104/** Ticks a todo or dismisses a link/note; the item stays crossed out in place for a moment, then folds into Done. */
105async function setDone($: Engine, id: string, done: boolean): Promise<void> {
106  await update($, items, list => list.map(item => (item.id === id ? { ...item, done } : item)))
107  await save($)
108
109  if (done) {
110    await update($, settling, ids => [...ids, id])
111    $.clock.after(SETTLE_MS, () => void update($, settling, ids => ids.filter(one => one !== id)))
112  }
113}
114
115async function capture($: Engine, row: Row, from: Item['from']): Promise<void> {
116  // A subagent's conversation is its own; what matters of it reaches the main one.
117  if (row.agentId !== undefined || !Array.isArray(row.message.content)) {
118    return
119  }
120
121  const text = row.message.content
122    .map(block => (block?.type === 'text' && typeof block.text === 'string' ? block.text : ''))
123    .join('\n')
124
125  await add($, 'link', extractUrls(text), from)
126}
127
128/** Never let a sidebar failure cost the conversation a row or a turn. */
129async function quietly($: Engine, what: string, work: () => Promise<unknown>): Promise<void> {
130  try {
131    await work()
132  } catch (error) {
133    $.ui.log(`could not ${what}: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
134  }
135}
136
137const openPane = ($: Engine) => $.ui.open({ id: PANE, title: 'Session' })
138
139export const register: Register = on => {
140  on('session.start', async ($, e, next) => {
141    const started = await next(e)
142    const id = await $.session.id()
143    const saved = await $.store.get(`session:${id}`)
144
145    // A hot reload keeps the host's state: only a different session reloads from the store.
146    if ((await read($, sessionId)) !== id) {
147      await update($, items, () => (Array.isArray(saved) ? (saved as Item[]) : []))
148      await update($, sessionId, () => id)
149    }
150
151    await $.command.register({ name: 'sidebar', description: 'Open the session sidebar: links, things to know, action items' })
152    await $.tool.register({
153      name: 'note',
154      isDeferred: false,
155      description:
156        "Pin short items to the person's session sidebar. kind 'todo': an action item the PERSON must do themselves " +
157        "(rotate a key, run a command on their machine, reply to someone); never work you will do. kind 'know': a fact, " +
158        'caveat or decision they should keep in mind after this turn scrolls away. One line each, under 100 characters. ' +
159        'Use sparingly, only for things that outlive the current turn. Links are collected automatically; do not add them.',
160      inputSchema: {
161        type: 'object',
162        properties: {
163          items: {
164            type: 'array',
165            items: {
166              type: 'object',
167              properties: { kind: { type: 'string', enum: ['todo', 'know'] }, text: { type: 'string' } },
168              required: ['kind', 'text'],
169            },
170          },
171        },
172        required: ['items'],
173      },
174    })
175    void openPane($)
176
177    return started
178  })
179
180  on('tool.call', { tool: TOOL }, async ($, e) => {
181    const given = (e as unknown as { items?: unknown }).items
182    const list = Array.isArray(given) ? (given as { kind?: unknown; text?: unknown }[]) : []
183    let added = 0
184
185    for (const kind of ['todo', 'know'] as const) {
186      const texts = list.filter(one => one?.kind === kind && typeof one.text === 'string').map(one => one.text as string)
187      added += await add($, kind, texts, 'claude')
188    }
189
190    return { result: `Added ${added} item${added === 1 ? '' : 's'} to the sidebar.` }
191  })
192
193  // What the person typed and what Claude answered. Tool output stays out: one search result would bury the rest.
194  on('session.append', { door: 'prompt' }, async ($, e, next) => {
195    await quietly($, 'collect links', () => capture($, e, 'you'))
196
197    return next(e)
198  })
199
200  on('session.append', { door: 'response' }, async ($, e, next) => {
201    await quietly($, 'collect links', () => capture($, e, 'claude'))
202
203    return next(e)
204  })
205
206  on('command.run', { command: 'sidebar' }, async $ => {
207    const opened = await openPane($)
208
209    return { text: opened.isPlaced ? 'Session sidebar opened.' : `Session sidebar is waiting for room: ${opened.reason}` }
210  })
211
212  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
213    const kit = $.ui.resolve(e)
214    const { Box, Button, Link, Text } = kit
215    // The mobile app draws no text field: there the add box is left out.
216    const Field = 'Input' in kit ? kit.Input : undefined
217    const all = await read($, items)
218    const fresh = new Set(await read($, settling))
219    const isDoneOpen = await read($, showDone)
220    const width = Math.max(12, e.props.bodyColumns - 5)
221    const run = (what: string, work: () => Promise<unknown>) => () => quietly($, what, work)
222    const shown = (kind: Kind) => all.filter(item => item.kind === kind && (!item.done || fresh.has(item.id)))
223    const done = all.filter(item => item.done && !fresh.has(item.id))
224
225    // The control leads the row so a narrow dock never pushes it out of reach.
226    const row = (item: Item, isInDone: boolean) => {
227      const isStruck = item.done === true
228      const mark = item.kind === 'todo' ? (isStruck ? ' ☑ ' : ' ☐ ') : isStruck ? ' ↺ ' : ' × '
229
230      return (
231        <Box key={`row:${item.id}`} flexDirection="row">
232          <Button
233            key={`mark:${item.id}`}
234            plain
235            dimColor={item.kind !== 'todo' && !isInDone}
236            label={mark}
237            hover={{ color: item.kind === 'todo' ? 'success' : 'error' }}
238            onPress={run('update the item', () => setDone($, item.id, !isStruck))}
239          />
240          {item.kind === 'link' && !isStruck ? (
241            <Link href={item.text} label={displayOf(item.text, width)} />
242          ) : (
243            <Text strikethrough={isStruck} dimColor={isStruck}>
244              {item.kind === 'link' ? displayOf(item.text, width) : item.text}
245            </Text>
246          )}
247        </Box>
248      )
249    }
250
251    const section = (kind: Kind, title: string, color: 'claude' | 'suggestion' | 'warning', empty: string) => {
252      const list = shown(kind)
253
254      return (
255        <Box key={`section:${kind}`} flexDirection="column" marginBottom={1}>
256          <Text bold color={color}>{`${title} (${list.filter(item => !item.done).length})`}</Text>
257          {list.length === 0 && <Text dimColor>{empty}</Text>}
258          {list.map(item => row(item, false))}
259        </Box>
260      )
261    }
262
263    return (
264      <Box flexDirection="column">
265        {section('todo', 'ACTION ITEMS', 'warning', 'Nothing to do yet.')}
266        {Field !== undefined && (
267          <Box key="add" marginBottom={1}>
268            <Field
269              key="add"
270              label="+ "
271              placeholder="add an action item"
272              submitLabel="add"
273              onSubmit={value => quietly($, 'add the item', () => add($, 'todo', [value], 'you'))}
274            />
275          </Box>
276        )}
277        {section('know', 'THINGS TO KNOW', 'suggestion', 'Claude pins notes here.')}
278        {section('link', 'LINKS', 'claude', 'Links from the session land here.')}
279        {done.length > 0 && (
280          <Box key="done" flexDirection="column">
281            <Button
282              key="toggle-done"
283              plain
284              dimColor
285              label={`${isDoneOpen ? '▾' : '▸'} Done (${done.length})`}
286              onPress={run('toggle Done', () => update($, showDone, open => !open))}
287            />
288            {isDoneOpen && done.map(item => row(item, true))}
289          </Box>
290        )}
291      </Box>
292    )
293  })
294}
295
types/index.d.ts 27 lines
1/** `link`: a URL the conversation mentioned. `know`: a thing to keep in mind. `todo`: an action item for the person. */
2export type Kind = 'link' | 'know' | 'todo'
3
4export type Item = {
5  /** `link:<url>` for links, so a second mention is the same item. */
6  id: string
7  kind: Kind
8  /** The URL for a link, the sentence otherwise. */
9  text: string
10  from: 'you' | 'claude'
11  /** Set when ticked off (todo) or dismissed (link, know): the item moves under Done. */
12  done?: boolean
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'session-sidebar': {
18      sessionId: string
19      items: Item[]
20      /** Whether the Done dropdown is open. */
21      showDone: boolean
22      /** Ids just ticked off: drawn crossed out in place for a moment before they fold into Done. */
23      settling: string[]
24    }
25  }
26}
27