Terminal-only pane with the tree of this project's sessions and their forks; press a row to switch to it

Панель «Session branches» для Claude Code в терминале. Показывает дерево сессий текущего проекта: какая сессия от какой ответвилась (--fork-session, /branch), их названия (/rename или авто-заголовок) и сколько времени прошло с последней активности. Текущая сессия помечена ●.
Только для терминала: в десктопе, VS Code и на мобильном мод ничего не рисует и не регистрирует команду. Панель открывается сама при старте сессии: колонкой сбоку, если окно не уже 144 колонок (иначе — по /branches); на основном экране терминала (без полноэкранного режима) — под перепиской.
/branches открывает панель и перечитывает список.claude --resume <id>), текущая продолжает работать. Поддерживаются macOS Terminal (вкладка через System Events — нужен доступ Универсального доступа, без него откроется новое окно), iTerm2, tmux и WezTerm; в остальных команда копируется в буфер.⇄ справа от строки переключает текущее окно на эту сессию (/resume <id>).✕ удаляет сессию: после подтверждения («This one» или «With branches») её файлы переносятся в Корзину macOS, откуда их возвращает «Вернуть» в Finder. Текущую сессию окна удалить нельзя.r Refresh, b Fork current (если в вашей версии есть /branch).claude --plugin-dir /path/to/session-branches
или постоянно, в ~/.zshrc:
export CLAUDE_CODE_PLUGIN_DIRS=/path/to/session-branches
Родителя сессии мод определяет по скопированным в файл сессии сообщениям из другой сессии (~/.claude/projects/<проект>/*.jsonl); смотрятся 60 последних сессий.
hooks/register.tsx 276 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3
4import type { SessionRow } from '../types'
5
6const PANE = 'session-branches'
7const TITLE = 'Session branches'
8const LIMIT = '60'
9
10const sessions = atom({ plugin: 'session-branches', key: 'sessions' } as const, [])
11const current = atom({ plugin: 'session-branches', key: 'current' } as const, '')
12const branchCommand = atom({ plugin: 'session-branches', key: 'branchCommand' } as const, null)
13const confirming = atom({ plugin: 'session-branches', key: 'confirming' } as const, null)
14const error = atom({ plugin: 'session-branches', key: 'error' } as const, null)
15
16// Turns scan.sh output into rows: "S\t<id>", "M\t<mtime>", "P\t<parent>",
17// then the session's last title and prompt rows as JSON lines.
18export const parseScan = (text: string): SessionRow[] => {
19 const rows: SessionRow[] = []
20 let row: (SessionRow & { custom?: string; ai?: string; prompt?: string }) | undefined
21 const flush = () => {
22 if (!row) return
23 const { custom, ai, prompt, ...rest } = row
24 const title = custom || ai || prompt || rest.id.slice(0, 8)
25 rows.push({ ...rest, title: title.replace(/\s+/g, ' ').trim() })
26 }
27 for (const line of text.split('\n')) {
28 if (line.startsWith('S\t')) {
29 flush()
30 row = { id: line.slice(2), parent: null, title: '', mtimeMs: 0 }
31 } else if (!row) {
32 continue
33 } else if (line.startsWith('M\t')) {
34 row.mtimeMs = Number(line.slice(2)) * 1000 || 0
35 } else if (line.startsWith('P\t')) {
36 row.parent = line.slice(2) || null
37 } else if (line.startsWith('{')) {
38 try {
39 const entry = JSON.parse(line)
40 if (entry.type === 'custom-title') row.custom = entry.customTitle
41 if (entry.type === 'ai-title') row.ai = entry.aiTitle
42 if (entry.type === 'last-prompt') row.prompt = entry.lastPrompt
43 } catch {}
44 }
45 }
46 flush()
47
48 return rows
49}
50
51// Depth-first order, newest first on each level; a parent outside the scanned
52// window makes its child a root.
53export const toTree = (rows: SessionRow[]) => {
54 const ids = new Set(rows.map(r => r.id))
55 const children = new Map<string | null, SessionRow[]>()
56 for (const r of rows) {
57 const key = r.parent && ids.has(r.parent) ? r.parent : null
58 children.set(key, [...(children.get(key) ?? []), r])
59 }
60 const out: { row: SessionRow; depth: number; isOrphan: boolean }[] = []
61 const walk = (key: string | null, depth: number) => {
62 const list = [...(children.get(key) ?? [])].sort((a, b) => b.mtimeMs - a.mtimeMs)
63 for (const row of list) {
64 out.push({ row, depth, isOrphan: depth === 0 && row.parent !== null })
65 walk(row.id, depth + 1)
66 }
67 }
68 walk(null, 0)
69
70 return out
71}
72
73// Every session below `id` in the tree, nearest first.
74export const descendants = (rows: SessionRow[], id: string) => {
75 const out: string[] = []
76 const walk = (parent: string) => {
77 for (const r of rows) {
78 if (r.parent === parent && !out.includes(r.id)) {
79 out.push(r.id)
80 walk(r.id)
81 }
82 }
83 }
84 walk(id)
85
86 return out
87}
88
89const ago = (ms: number, now: number) => {
90 const m = Math.max(0, Math.round((now - ms) / 60000))
91 if (m < 60) return `${m}m`
92 if (m < 60 * 24) return `${Math.round(m / 60)}h`
93 return `${Math.round(m / 60 / 24)}d`
94}
95
96type Dollar = Parameters<Hook<'session.start'>>[0]
97
98// Re-reads the session id and the project's session files.
99async function refresh($: Dollar) {
100 const id = await $.session.id()
101 await update($, current, () => id)
102 try {
103 const cwd = await $.session.root()
104 const scan = await $.process.run(['/bin/sh', `${$.plugin.root}/bin/scan.sh`, cwd, LIMIT])
105 if (scan.exitCode !== 0) throw new Error(scan.stderr.trim() || `scan exited ${scan.exitCode}`)
106 await update($, sessions, () => parseScan(scan.stdout))
107 await update($, error, () => null)
108 } catch (err) {
109 await update($, error, () => String(err))
110 }
111}
112
113// Moves sessions to the Trash (bin/trash.sh), never the one this window runs.
114async function trash($: Dollar, ids: string[]) {
115 await update($, confirming, () => null)
116 const here = await read($, current)
117 const cwd = await $.session.root()
118 let moved = 0
119 for (const id of ids.filter(one => one !== here)) {
120 const done = await $.process.run(['/bin/sh', `${$.plugin.root}/bin/trash.sh`, cwd, id])
121 if (done.exitCode !== 0) {
122 $.ui.toast(`Could not delete ${id.slice(0, 8)}: ${done.stderr.trim() || done.exitCode}`)
123 break
124 }
125 moved += 1
126 }
127 if (moved > 0) $.ui.toast(moved === 1 ? 'Session moved to Trash' : `${moved} sessions moved to Trash`)
128 await refresh($)
129}
130
131// Opens the session in a new tab of the terminal claude runs in; where the
132// terminal is not one open-tab.sh knows, the command goes to the clipboard.
133async function openTab($: Dollar, id: string) {
134 const cwd = await $.session.root()
135 const command = `claude --resume ${id}`
136 try {
137 const opened = await $.process.run(['/bin/sh', `${$.plugin.root}/bin/open-tab.sh`, cwd, id])
138 if (opened.exitCode === 0) {
139 const how = opened.stdout.trim()
140 $.ui.toast(how === 'terminal-window' ? 'Session opened in a new Terminal window' : 'Session opened in a new tab')
141 return
142 }
143 if (opened.exitCode !== 2) throw new Error(opened.stderr.trim() || `open-tab exited ${opened.exitCode}`)
144 } catch (err) {
145 $.ui.toast(`Could not open a tab: ${String(err)}`)
146 }
147 const copied = await $.ui.copy({ text: command, surface: 'terminal' })
148 $.ui.toast(copied.isCopied ? `Copied: ${command}` : `Open a tab and run: ${command}`)
149}
150
151export const register: Register = on => {
152 // Terminal only. The pane opens with the session and stays: a docked
153 // column in the fullscreen layout, inline on the main screen.
154 on('session.start', async ($, e, next) => {
155 const started = await next(e)
156 if (e.surface !== 'terminal') return started
157
158 await $.command.register({
159 name: 'branches',
160 description: 'Open the tree of session branches (forks) of this project',
161 })
162 const names = (await $.command.list()).map(c => c.name)
163 const fork = ['branch', 'fork'].find(n => names.includes(n)) ?? null
164 await update($, branchCommand, () => fork)
165 await refresh($)
166 void $.ui.open({ id: PANE, title: TITLE })
167
168 return started
169 })
170
171 on('command.run', { command: 'branches' }, async $ => {
172 await refresh($)
173 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
174
175 return { text: opened.isPlaced ? 'Session branches pane opened.' : `The pane did not open: ${opened.reason}` }
176 })
177
178 // The list changes when a turn writes the session file or a fork appears.
179 on('turn.complete', async ($, e, next) => {
180 const done = await next(e)
181 if (e.agentId === undefined) void refresh($)
182
183 return done
184 })
185
186 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
187 if (e.surface !== 'terminal') return next(e)
188 const { Box, Text, Button } = $.ui.resolve(e)
189 const all = await read($, sessions)
190 const rows = toTree(all)
191 const asking = await read($, confirming)
192 const here = await read($, current)
193 const fork = await read($, branchCommand)
194 const failure = await read($, error)
195 const now = await $.clock.now()
196 const width = Math.max(20, e.props.bodyColumns)
197 const room = Math.max(1, (e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 24) - 3)
198 const at = Math.max(0, rows.findIndex(r => r.row.id === here))
199 const start = Math.max(0, Math.min(at - Math.floor(room / 2), rows.length - room))
200
201 const run = async (command: string, args?: string) => {
202 try {
203 await $.command.run({ command, args })
204 } catch (err) {
205 $.ui.toast(`/${command}: ${String(err)}`)
206 }
207 }
208
209 return (
210 <Box flexDirection="column">
211 <Box flexDirection="row" gap={1}>
212 <Button key="refresh" hotkey="r" onPress={() => void refresh($)}>Refresh</Button>
213 {fork && (
214 <Button key="fork" hotkey="b" variant="primary" onPress={() => void run(fork)}>
215 Fork current
216 </Button>
217 )}
218 </Box>
219 {failure && <Text color="red">{failure}</Text>}
220 {rows.length === 0 && !failure && <Text dimColor>No sessions in this project yet.</Text>}
221 {rows.slice(start, start + room).map(({ row, depth, isOrphan }) => {
222 const isHere = row.id === here
223 const lead = `${' '.repeat(depth)}${depth > 0 ? '└ ' : isOrphan ? '↳ ' : ''}${isHere ? '● ' : ''}`
224 const when = ago(row.mtimeMs, now)
225 const max = Math.max(4, width - lead.length - when.length - 7)
226 const title = row.title.length > max ? `${row.title.slice(0, max - 1)}…` : row.title
227 const below = asking === row.id ? descendants(all, row.id).filter(id => id !== here) : []
228 return [
229 <Box key={`row:${row.id}`} flexDirection="row">
230 <Button
231 key={row.id}
232 plain
233 onPress={() => (isHere ? $.ui.toast('You are already in this session') : void openTab($, row.id))}
234 >
235 {lead}
236 <Text bold={isHere}>{title}</Text> <Text dimColor>{when}</Text>
237 </Button>
238 {!isHere && (
239 <Button key={`here:${row.id}`} plain dimColor onPress={() => void run('resume', row.id)}>
240 {' ⇄'}
241 </Button>
242 )}
243 {!isHere && (
244 <Button
245 key={`del:${row.id}`}
246 plain
247 dimColor
248 onPress={() => void update($, confirming, was => (was === row.id ? null : row.id))}
249 >
250 {' ✕'}
251 </Button>
252 )}
253 </Box>,
254 asking === row.id && (
255 <Box key={`ask:${row.id}`} flexDirection="row" gap={1}>
256 <Text color="red">{`${' '.repeat(depth + 1)}Move to Trash?`}</Text>
257 <Button key={`yes:${row.id}`} onPress={() => void trash($, [row.id])}>
258 This one
259 </Button>
260 {below.length > 0 && (
261 <Button key={`all:${row.id}`} onPress={() => void trash($, [row.id, ...below])}>
262 {`With branches (${below.length + 1})`}
263 </Button>
264 )}
265 <Button key={`no:${row.id}`} onPress={() => void update($, confirming, () => null)}>
266 Cancel
267 </Button>
268 </Box>
269 ),
270 ]
271 })}
272 </Box>
273 )
274 })
275}
276types/index.d.ts 19 lines1export type SessionRow = {
2 id: string
3 parent: string | null
4 title: string
5 mtimeMs: number
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'session-branches': {
11 sessions: SessionRow[]
12 current: string
13 branchCommand: string | null
14 error: string | null
15 confirming: string | null
16 }
17 }
18}
19