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

Small Claude Code mods: function-hook plugins that add panes, bands and commands inside Claude Code.
A sidebar listing the current project's sessions. Pick one to resume it.
☰ Sessions above the prompt, or /sessions, opens the pane with the keyboard on it1–9 or Enter resumes a session, r refreshes, x closes, Esc goes back to the prompts 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[feat-search] 12m ago)▸ (this session)/plugin install session-switcher --marketplace ha-ptt0601/cc-mods
Answer y to add the marketplace, then pick the user scope.
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-switcherhooks/register.tsx 313 lines1import { 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}
313types/index.d.ts 8 lines1export 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