SLOPSHOPPER

session-branches

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

newpanecommandtoastprocess
v0.1.0no licenseupdated 2026-10-26Pavelkv94/mods/session-branches
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-branches
│ ┃ Session branches ✕ › fix the failing auth test and add an audit log call │ ┃ [ Refresh ] │ ┃ No sessions in this project yet. ⏺ 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 │ │ › /branches │ ⎿ session-branches: Session branches pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session branches
[ Refresh ] No sessions in this project yet.
README

session-branches

Панель «Session branches» для Claude Code в терминале. Показывает дерево сессий текущего проекта: какая сессия от какой ответвилась (--fork-session, /branch), их названия (/rename или авто-заголовок) и сколько времени прошло с последней активности. Текущая сессия помечена ●.

Только для терминала: в десктопе, VS Code и на мобильном мод ничего не рисует и не регистрирует команду. Панель открывается сама при старте сессии: колонкой сбоку, если окно не уже 144 колонок (иначе — по /branches); на основном экране терминала (без полноэкранного режима) — под перепиской.

Как пользоваться

  • /branches открывает панель и перечитывает список.
  • Клик / Enter по строке открывает эту сессию в новой вкладке терминала (claude --resume <id>), текущая продолжает работать. Поддерживаются macOS Terminal (вкладка через System Events — нужен доступ Универсального доступа, без него откроется новое окно), iTerm2, tmux и WezTerm; в остальных команда копируется в буфер.
  • ⇄ справа от строки переключает текущее окно на эту сессию (/resume <id>).
  • ✕ удаляет сессию: после подтверждения («This one» или «With branches») её файлы переносятся в Корзину macOS, откуда их возвращает «Вернуть» в Finder. Текущую сессию окна удалить нельзя.
  • r Refresh, b Fork current (если в вашей версии есть /branch).
  • Esc возвращает фокус в поле ввода, панель остаётся.

Подключение

claude --plugin-dir /path/to/session-branches

или постоянно, в ~/.zshrc:

export CLAUDE_CODE_PLUGIN_DIRS=/path/to/session-branches

Родителя сессии мод определяет по скопированным в файл сессии сообщениям из другой сессии (~/.claude/projects/<проект>/*.jsonl); смотрятся 60 последних сессий.

Source 2 files
hooks/register.tsx 276 lines
1import { 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}
276
types/index.d.ts 19 lines
1export 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