SLOPSHOPPER

session-switcher

A sidebar listing this project's sessions; click one to resume it

newpanebandcommandtoastprocess
★ 4v0.3.0no licenseupdated 2026-10-08ha-ptt0601/cc-mods/session-switcher
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-switcher
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ r: Refresh x: Close s: Search loading… │ ┃ Search: : session name or worktree ⏎ resume ⏺ Read(src/auth.ts) │ ┃ type /sessions (or ctrl+x tab) to use the ⎿ Read 6 lines │ ┃ keys ⏺ 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 │ │ › /sessions │ ⎿ session-switcher: Sessions: press 1-9 or Enter to resume, x to c │ │ ☰ Sessions ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☰ Sessions
Pane · Sessions
r: Refresh x: Close s: Search loading… Search: : session name or worktree ⏎ resume first type /sessions (or ctrl+x tab) to use the keys
README

cc-mods

Small Claude Code mods: function-hook plugins that add panes, bands and commands inside Claude Code.

Mods

session-switcher

A sidebar listing the current project's sessions. Pick one to resume it.

The Sessions pane beside a conversation (example data)

  • ☰ Sessions above the prompt, or /sessions, opens the pane with the keyboard on it
  • 1–9 or Enter resumes a session, r refreshes, x closes, Esc goes back to the prompt
  • s jumps to the search field: typing filters the list by session name or worktree (case and diacritics ignored, so y tuong finds Ý tưởng), Enter resumes the first match
  • /sessions close hides the pane from the prompt
  • Sessions from the project's git worktrees are listed too, tagged with the worktree's name ([feat-search] 12m ago)
  • A session is titled by its custom title, its AI title, or its first typed prompt; one with none of them is left out
  • The open session is marked ▸ (this session)

Install

/plugin install session-switcher --marketplace ha-ptt0601/cc-mods

Answer y to add the marketplace, then pick the user scope.

Develop

Run a mod straight from its folder, reloading on save:

claude --plugin-dir ./session-switcher

Check it:

claude plugin validate ./session-switcher
claude plugin test ./session-switcher
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 { SessionRow } from '../types'
5
6const PANE = 'sessions'
7const LIMIT = 30
8const sessions = atom({ plugin: 'session-switcher', key: 'sessions' } as const, [])
9const isLoading = atom({ plugin: 'session-switcher', key: 'isLoading' } as const, false)
10const note = atom({ plugin: 'session-switcher', key: 'note' } as const, '')
11const query = atom({ plugin: 'session-switcher', key: 'query' } as const, '')
12
13/**
14 * The text of a transcript's user message, or '' when it is not a typed prompt
15 * (a tool result, a command's output, a hook's injected context).
16 *
17 * @param line one JSONL line of a transcript
18 */
19export function promptText(line: string): string {
20  try {
21    const row = JSON.parse(line) as { type?: string; isMeta?: boolean; message?: { content?: unknown } }
22    if (row.type !== 'user' || row.isMeta) return ''
23    const content = row.message?.content
24    const text = typeof content === 'string'
25      ? content
26      : Array.isArray(content)
27        ? content.map(b => (b && typeof b === 'object' && 'text' in b ? String(b.text) : '')).join(' ')
28        : ''
29    const trimmed = text.trim()
30
31    return trimmed.startsWith('<') ? '' : trimmed.replace(/\s+/g, ' ')
32  } catch {
33    return ''
34  }
35}
36
37/**
38 * The value of `key` on a JSONL line, or '' when the line does not parse.
39 */
40function field(line: string, key: string): string {
41  try {
42    return String((JSON.parse(line) as Record<string, unknown>)[key] ?? '')
43  } catch {
44    return ''
45  }
46}
47
48/**
49 * A transcript's title: its last custom title, else its last AI title, else
50 * its first typed prompt; '' when it has none of them (nothing worth listing).
51 * A session started by a slash command has no typed prompt but is titled.
52 *
53 * @param titleLines the transcript's custom-title and ai-title lines
54 * @param userLines the transcript's first user lines
55 */
56export function sessionTitle(titleLines: string[], userLines: string[]): string {
57  const custom = titleLines.map(l => field(l, 'customTitle')).filter(Boolean).at(-1)
58  const ai = titleLines.map(l => field(l, 'aiTitle')).filter(Boolean).at(-1)
59
60  return custom || ai || userLines.map(promptText).find(Boolean) || ''
61}
62
63/**
64 * Groups `grep -H` output (`path:line`) by the transcript's session id.
65 */
66function byFile(stdout: string): Map<string, string[]> {
67  const found = new Map<string, string[]>()
68  for (const hit of stdout.split('\n')) {
69    const at = hit.indexOf('.jsonl:')
70    if (at < 0) continue
71    const id = hit.slice(0, at).split('/').pop() ?? ''
72    found.set(id, [...(found.get(id) ?? []), hit.slice(at + 7)])
73  }
74
75  return found
76}
77
78/**
79 * The paths of every worktree in `git worktree list --porcelain` output, the
80 * main one first.
81 */
82export function worktreePaths(porcelain: string): string[] {
83  return porcelain
84    .split('\n')
85    .filter(line => line.startsWith('worktree '))
86    .map(line => line.slice('worktree '.length).trim())
87    .filter(Boolean)
88}
89
90/**
91 * The folder Claude Code keeps a directory's transcripts in.
92 */
93export function projectDir(home: string, path: string): string {
94  return `${home}/.claude/projects/${path.replace(/[^a-zA-Z0-9]/g, '-')}`
95}
96
97/**
98 * Lists this project's sessions, its worktrees' included, newest first: one
99 * row per transcript that holds at least one typed prompt, titled by its
100 * custom or AI title.
101 */
102async function loadSessions($: EngineInterface, trace: string[]): Promise<SessionRow[]> {
103  const root = await $.session.root()
104  const env = await $.process.run(['printenv', 'HOME']).catch(() => ({ stdout: '' }))
105  const home = env.stdout.trim() || (/^\/(?:Users|home)\/[^/]+/.exec(root)?.[0] ?? '')
106  const git = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root })
107    .catch(() => ({ exitCode: 1, stdout: '' }))
108  const listed = git.exitCode === 0 ? worktreePaths(git.stdout) : []
109  const trees = listed.includes(root) ? listed : [root, ...listed]
110  trace.push(`root=${root}`, `home=${home}`, `dir=${projectDir(home, root)}`, `worktrees=${trees.length}`)
111
112  const found = await Promise.all(trees.map(async tree => {
113    const dir = projectDir(home, tree)
114    if (!(await $.fs.exists(dir))) return []
115
116    return (await $.fs.list(dir))
117      .filter(f => f.kind === 'file' && f.name.endsWith('.jsonl'))
118      .map(f => ({ ...f, path: `${dir}/${f.name}`, worktree: tree === root ? undefined : tree.split('/').pop() }))
119  }))
120  const files = found.flat().sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, LIMIT)
121  trace.push(`transcripts=${files.length}`)
122  if (files.length === 0) return []
123
124  const paths = files.map(f => f.path)
125  const grep = async (pattern: string, max: string) =>
126    byFile((await $.process.run(['grep', '-H', '-m', max, '-e', pattern, ...paths])).stdout)
127  const [titles, prompts] = await Promise.all([
128    grep('"type":"\\(custom-title\\|ai-title\\)"', '50'),
129    grep('"type":"user"', '20'),
130  ])
131  trace.push(`titled=${titles.size}`, `with prompts=${prompts.size}`)
132
133  return files.flatMap(f => {
134    const id = f.name.slice(0, -'.jsonl'.length)
135    const title = sessionTitle(titles.get(id) ?? [], prompts.get(id) ?? [])
136
137    return title ? [{ id, title, mtimeMs: f.mtimeMs, worktree: f.worktree }] : []
138  })
139}
140
141/**
142 * Lowercase text with Vietnamese and other diacritics removed, so `y tuong`
143 * finds `Ý tưởng`.
144 */
145function fold(text: string): string {
146  return text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/gi, 'd').toLowerCase()
147}
148
149/**
150 * Whether a session matches a search: every word of `search` is in its title
151 * or its worktree's name, ignoring case and diacritics. An empty search
152 * matches every session.
153 */
154export function matches(row: Pick<SessionRow, 'title' | 'worktree'>, search: string): boolean {
155  const haystack = fold(`${row.title} ${row.worktree ?? ''}`)
156
157  return fold(search).split(/\s+/).filter(Boolean).every(word => haystack.includes(word))
158}
159
160/**
161 * Resumes a session, saying so in a toast.
162 */
163function resume($: EngineInterface, row: SessionRow) {
164  $.ui.toast(`Resuming: ${row.title.slice(0, 40)}`)
165  void $.command.run({ command: 'resume', args: row.id })
166}
167
168/**
169 * How long ago `ms` was, in the largest whole unit: `5m`, `3h`, `2d`.
170 */
171export function ago(ms: number, now: number): string {
172  const minutes = Math.max(0, Math.round((now - ms) / 60000))
173  if (minutes < 60) return `${minutes}m`
174  if (minutes < 60 * 24) return `${Math.round(minutes / 60)}h`
175
176  return `${Math.round(minutes / 60 / 24)}d`
177}
178
179/**
180 * Reloads the session list into state, flagging the load while it runs.
181 */
182async function refresh($: EngineInterface) {
183  const trace: string[] = []
184  await update($, isLoading, () => true)
185  try {
186    const rows = await loadSessions($, trace)
187    await update($, sessions, () => rows)
188    await update($, note, () => (rows.length === 0 ? `No sessions found in ${trace[2] ?? '?'}` : ''))
189  } catch (error) {
190    trace.push(`error: ${String(error)}`)
191    await update($, note, () => `Could not list sessions: ${String(error)}`)
192  } finally {
193    await update($, isLoading, () => false)
194  }
195}
196
197/**
198 * Opens the sessions pane, or brings an open one forward, with the keyboard
199 * on it and the list reloaded.
200 *
201 * @returns a line saying what to press next
202 */
203async function show($: EngineInterface): Promise<string> {
204  await update($, query, () => '')
205  await $.ui.open({ id: PANE, title: 'Sessions', focus: true, columns: 60 })
206  void refresh($)
207
208  return 'Sessions: press 1-9 or Enter to resume, x to close, Esc to go back (/sessions close hides it).'
209}
210
211export const register: Register = on => {
212  on('session.start', async ($, e, next) => {
213    const started = await next(e)
214    await $.command.register({
215      name: 'sessions',
216      description: "Open this project's sessions and pick one to resume (close: hide it)",
217      argumentHint: '[close]',
218    })
219
220    return started
221  })
222
223  on('command.run', { command: 'sessions' }, async ($, e) => {
224    if (/^(close|hide|x)$/i.test(e.args.trim())) {
225      await $.ui.close({ id: PANE })
226
227      return { text: 'Sessions pane closed.' }
228    }
229
230    return { text: await show($) }
231  })
232
233  // The "icon": one small button above the prompt that opens the pane.
234  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
235    if (e.props.hasSurvey) return next(e)
236    const { Box, Button } = $.ui.resolve(e)
237
238    return (
239      <Box>
240        <Button key="toggle" plain label="☰ Sessions" dimColor onPress={() => void show($)} />
241      </Box>
242    )
243  })
244
245  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
246    const { Box, Button, Input, Text } = $.ui.resolve(e)
247    const search = await read($, query)
248    const rows = (await read($, sessions)).filter(row => matches(row, search))
249    const current = await $.session.id()
250    const now = await $.clock.now()
251    const width = Math.max(10, (e.props.bodyColumns ?? 40) - 6)
252    const others = rows.filter(row => row.id !== current)
253
254    return (
255      <Box flexDirection="column">
256        <Box>
257          <Button key="refresh" plain hotkey="r" label="Refresh" dimColor onPress={() => void refresh($)} />
258          <Text>  </Text>
259          <Button key="close" plain hotkey="x" label="Close" dimColor onPress={() => void $.ui.close({ id: PANE })} />
260          <Text>  </Text>
261          <Button key="find" plain hotkey="s" label="Search" dimColor onPress={() => void $.ui.focus({ requestId: PANE, key: 'search' })} />
262          {(await read($, isLoading)) && <Text dimColor>  loading…</Text>}
263        </Box>
264        <Input
265          key="search"
266          label="Search: "
267          placeholder="session name or worktree"
268          value={search}
269          submitLabel="resume first"
270          onInput={value => void update($, query, () => value)}
271          onSubmit={value => {
272            const first = others.find(row => matches(row, value))
273            if (first) resume($, first)
274          }}
275        />
276        {e.props.isFocused
277          ? <Text dimColor>1-9 resume · s search · r refresh · x close · Esc back</Text>
278          : <Text dimColor>type /sessions (or ctrl+x tab) to use the keys</Text>}
279        {(await read($, note)) !== '' && <Text color="yellow">{await read($, note)}</Text>}
280        {search !== '' && rows.length === 0 && <Text dimColor>No session matches "{search}"</Text>}
281        {rows.map(row => {
282          const isCurrent = row.id === current
283          const when = `${row.worktree ? `[${row.worktree}] ` : ''}${ago(row.mtimeMs, now)} ago`
284          const title = row.title.slice(0, Math.max(5, width - when.length - 4))
285          if (isCurrent) {
286            return (
287              <Box key={row.id}>
288                <Text color="green" bold>▸ {title}</Text>
289                <Text dimColor>  (this session)</Text>
290              </Box>
291            )
292          }
293          const slot = others.indexOf(row)
294
295          return (
296            <Box key={row.id}>
297              <Button
298                key={row.id}
299                plain
300                hotkey={slot < 9 ? String(slot + 1) : undefined}
301                autoFocus={slot === 0 ? true : undefined}
302                label={slot < 9 ? title : `   ${title}`}
303                onPress={() => resume($, row)}
304              />
305              <Text dimColor>  {when}</Text>
306            </Box>
307          )
308        })}
309      </Box>
310    )
311  })
312}
313
types/index.d.ts 8 lines
1export type SessionRow = { id: string; title: string; mtimeMs: number; worktree?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'session-switcher': { sessions: SessionRow[]; isLoading: boolean; note: string; query: string }
6  }
7}
8