OpenCode-style session tabs for the Claude Code terminal: a sidebar of your sessions that stays until you close a tab, opening each in tmux

OpenCode-style session tabs for the Claude Code terminal UI: a sidebar with a tab for each of your Claude Code sessions, titled by what you asked in them. A tab stays until you close it, even after its session ends, and clicking it takes you there in tmux.
│4 sessions · 1 needs you
│────────────────────────────────────────
│⠹ Refactor auth middleware ✕
│ backend
│────────────────────────────────────────
│● Docs pass for v2 release ✕
│ docs
│────────────────────────────────────────
│– Flaky CI tests ✕
│ web
│────────────────────────────────────────
│✓ Nightly dependency cleanup ✕
│ infra
│────────────────────────────────────────
│+ New session
A spinner means working, a yellow ● waiting for you, ○ idle, – exited, ✓ done (a background session). The session you're in is in bold.
It's a Claude Code mod, a plugin of function hooks. Built and tested on Claude Code 2.1.294, on Linux with tmux 3.4.
At the prompt of a Claude Code terminal session:
/plugin install session-tabs --marketplace NguyenPhan2810/claude-session-tabs
Answer y to add the marketplace, then pick a scope (user scope runs it in every session).
/tabs 2 / /tabs docs (a title prefix), to go to that session:claude --resume) in a new tmux window, and you jump thereclaude attach) in a new tmux window, or the window already showing it/tabs new starts claude in a new tmux window, in this session's folder./tabs close 2 closes a tab. Like OpenCode, that only hides it: a background session keeps running, and any conversation can still be resumed. /tabs reopen brings back the last tab you closed./tabs toggles the sidebar. If you close the sidebar, it stays closed in new sessions until you run /tabs again.Ctrl+X then Tab), then Tab and Enter to open tabs from the keyboard.A tab shows the name you gave its session with /rename, or one Claude Code gave it. Otherwise, after its first turn, each session titles itself from your first prompt, the way OpenCode does: it asks a small model (Haiku) for a 3 to 5 word title, once per session, through your own Claude plan or API key. Where that isn't available, the first line of the prompt is the title.
Tabs are shared by all your sessions and kept across restarts. A tab closes on its own only when there's nothing left to open: a session that ended before its first message, or a background session you deleted (claude rm, or Ctrl+X twice in claude agents). Past 40 tabs, the ones whose sessions ended longest ago close.
Like OpenCode's background server, Claude Code can run sessions in a background service that outlives your terminal: start one with claude --bg "task", or press ← on an empty prompt (or run /bg) to move the current one there. Its tab shows its progress, and clicking it attaches. A plain claude session lives in its terminal: when you exit, its tab stays as exited and clicking it resumes the conversation.
Claude Code docks a mod's pane beside the transcript only in fullscreen rendering. Under the classic renderer the list sits in a compact block above the prompt instead. Switch with /tui fullscreen, or start with CLAUDE_CODE_NO_FLICKER=1 claude. The sidebar opens by itself once the terminal is at least 144 columns wide.
A mod can't move your terminal from one session to another, so tabs open through tmux: a session's own pane, or a new window. Outside tmux, opening a tab tells you the command to run instead.
~/.claude/sessions/<pid>.json (or under $CLAUDE_CONFIG_DIR). It reads only those .json files and never the .key files beside them. With the sidebar closed it polls nothing.claude agents --json --all. Every session shares one answer through the mod's store, so the machine runs it at most once per 10 seconds./proc/<pid>/stat; elsewhere it uses the claude agents answer.~/.claude/plugins/store/), one entry per tab, with closing and titles kept apart so two sessions refreshing at once can't undo a close or lose a title.@session-tabs, so a second click goes to the same window.The registry format is internal to Claude Code and may change between releases. If it does, the list may come up empty until this mod is updated.
Mods are not sandboxed: this one runs with your user's permissions. It reads the registry folder, /proc/<pid>/stat, whether a session's transcript exists, and its own session's first prompt (to title it). It runs claude agents --json --all, tmux, claude, claude --resume or claude attach in the windows it opens, and ps on systems without /proc.
claude --plugin-dir . # run it from this folder; edits hot-reload
claude plugin validate . # what the engine would load or refuse
claude plugin test . # tests/*.test.ts
Claude Code writes the API types to .claude-plugin/types/ the first time it loads the mod; after that, npx -p typescript tsc -p . type-checks it.
MIT
hooks/register.tsx 685 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import {
4 basename,
5 cleanTitle,
6 clientShowing,
7 descendsFrom,
8 firstPrompt,
9 isConversation,
10 isJobId,
11 isRegistryFile,
12 isSessionId,
13 openTabs,
14 orderSessions,
15 parseProcessTable,
16 parseRoster,
17 parseSession,
18 parseStat,
19 pick,
20 projectDirName,
21 SPIN_MS,
22 spinnerFrame,
23 syncTabs,
24 tabBadge,
25 tabTitle,
26 titleFromPrompt,
27 tmuxLiteral,
28 tmuxPane,
29 windowName,
30 type Job,
31 type Session,
32 type Tab,
33 type TabRecord,
34} from './model'
35
36const PANE = 'session-tabs'
37const TITLE = 'Sessions'
38const WIDTH = 40
39const REFRESH_MS = 2_000
40/** How old the shared `claude agents` answer, or a failed attempt, may be before a session asks again. */
41const ROSTER_MAX_AGE_MS = 10_000
42const CLOSED_KEEP_MS = 30 * 24 * 3_600_000
43/** How a session asks a small model for its title, once, after its first turn. */
44const TITLE_SYSTEM =
45 "You name coding-assistant chat sessions. Reply with only a title of 3 to 5 words, at most 40 characters, for the conversation that starts with the user's message below: sentence case, no quotes, no trailing punctuation."
46/** The tmux pane option that marks a window this mod opened for a session. */
47const PANE_TAG = '@session-tabs'
48
49/** The shared `claude agents --json --all` answer; no `jobs` when the last attempt failed. */
50type Roster = { at: number; jobs?: Job[]; livePids?: number[] }
51
52// Module state: rebuilt from disk and the store on every refresh, so losing it on reload is harmless.
53let configDir = ''
54let registryDir = ''
55let home = ''
56/** Linux: liveness and process ancestry come from /proc. Elsewhere: `claude agents --json` and `ps`. */
57let hasProc = false
58let selfId: string | undefined
59let tabs: Tab[] = []
60/** The titles sessions gave themselves, by session id. */
61let titles = new Map<string, string>()
62let spinner: Timer | null = null
63let isTitling = false
64/** Sessions this module already asked the model about: one call each, even if saving the title failed. */
65const asked = new Set<string>()
66/** The sessions taken as running. One missing for less than MISS_GRACE_MS (a registry file caught mid-write) is carried over. */
67let carried = new Map<string, { session: Session; missingSince?: number }>()
68const MISS_GRACE_MS = 1_500
69let drawnKey = ''
70let refreshing: Promise<void> | null = null
71let isStale = false
72let rosterFetch: Promise<Roster> | null = null
73
74const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error))
75const tabKey = (sessionId: string) => `tab:${sessionId}`
76/** Closing lives under a key of its own that a refresh never writes, so another session's refresh can't undo it. */
77const closedKey = (sessionId: string) => `closed:${sessionId}`
78/** A session's title, written only by that session, so it never races another's write. */
79const titleKey = (sessionId: string) => `title:${sessionId}`
80
81/** Whether the sidebar is open, and whether it is actually on screen (placed, and the pane in front). */
82async function paneState($: EngineInterface): Promise<{ isOpen: boolean; isVisible: boolean }> {
83 const pane = (await $.ui.panes()).find(p => p.id === PANE)
84 return { isOpen: pane !== undefined, isVisible: pane !== undefined && pane.isPlaced && pane.isShown }
85}
86
87/** Re-reads everything; never rejects. A call made while one runs makes that one go round again. */
88function refresh($: EngineInterface): Promise<void> {
89 if (registryDir === '') return Promise.resolve()
90 if (refreshing !== null) {
91 isStale = true
92 return refreshing
93 }
94 refreshing = (async () => {
95 do {
96 isStale = false
97 try {
98 await load($)
99 } catch (error) {
100 $.ui.log(`session-tabs: refresh failed: ${errorText(error)}`, { to: 'debug' })
101 }
102 } while (isStale)
103 })().finally(() => {
104 refreshing = null
105 })
106 return refreshing
107}
108
109async function load($: EngineInterface): Promise<void> {
110 selfId = await $.session.id()
111 const now = await $.clock.now()
112 const roster = await getRoster($, now)
113 const jobs = roster.jobs ?? null
114 const found = await liveSessions($, roster)
115 const live = found === null ? null : carryOver(found, now)
116
117 // The tabs are kept in the store, one key each, shared by every session running the mod.
118 const keys = await $.store.keys()
119 const records = new Map<string, TabRecord>()
120 const closed = new Map<string, number>()
121 const named = new Map<string, string>()
122 for (const key of keys) {
123 const value = await $.store.get(key)
124 if (key.startsWith('tab:') && isTabRecord(value)) records.set(value.sessionId, value)
125 if (key.startsWith('closed:') && typeof value === 'number') closed.set(key.slice('closed:'.length), value)
126 if (key.startsWith('title:') && typeof value === 'string') named.set(key.slice('title:'.length), value)
127 }
128 titles = named
129
130 // Without a registry listing, nothing can be told about what ended, so the tabs are left as they are.
131 if (live !== null) {
132 const liveIds = new Set(live.map(s => s.sessionId))
133 const { upserts, closes } = syncTabs([...records.values()], new Set(closed.keys()), live, liveIds, jobs, now)
134 for (const id of closes) {
135 await $.store.set(closedKey(id), now)
136 closed.set(id, now)
137 }
138 for (const next of upserts) {
139 // An interactive session that ended before its first message left nothing to reopen: its tab goes.
140 if (next.kind === 'interactive' && next.endedAt === now && (await hasTranscript($, next)) === false) {
141 await $.store.set(closedKey(next.sessionId), now)
142 closed.set(next.sessionId, now)
143 }
144 await $.store.set(tabKey(next.sessionId), next)
145 records.set(next.sessionId, next)
146 }
147
148 // A month after closing, a tab is forgotten, unless its session is around and would only come back.
149 const around = new Set([...liveIds, ...(jobs ?? []).map(j => j.sessionId)])
150 for (const [id, closedAt] of closed) {
151 if (now - closedAt <= CLOSED_KEEP_MS || around.has(id)) continue
152 await $.store.delete(tabKey(id))
153 await $.store.delete(closedKey(id))
154 await $.store.delete(titleKey(id))
155 records.delete(id)
156 closed.delete(id)
157 }
158 }
159 tabs = openTabs([...records.values()], new Set(closed.keys()), live ?? [], jobs ?? [])
160 const isWorking = tabs.some(t => tabBadge(t).tone === 'working')
161 animate($, isWorking && (await paneState($)).isVisible)
162
163 // Redraw when something visible or something a press acts on changed.
164 const key = JSON.stringify([
165 selfId,
166 tabs.map(t => [
167 t.record.sessionId,
168 tabTitle(t.record, titles.get(t.record.sessionId)),
169 t.record.cwd,
170 t.record.kind,
171 t.record.jobId,
172 t.live?.pid,
173 t.live?.tmux,
174 tabBadge(t).label,
175 ]),
176 ])
177 if (key !== drawnKey) {
178 drawnKey = key
179 $.ui.invalidate('ui.render')
180 }
181}
182
183/** Runs the spinner only while a tab is working and the sidebar is on screen: a redraw per frame, nothing otherwise. */
184function animate($: EngineInterface, isOn: boolean): void {
185 if (isOn && spinner === null) spinner = $.clock.every(SPIN_MS, () => $.ui.invalidate('ui.render'))
186 if (!isOn && spinner !== null) {
187 spinner.cancel()
188 spinner = null
189 }
190}
191
192/**
193 * Gives this session a title once it has a first prompt, as OpenCode titles its sessions: a few
194 * words from a small model, or the prompt's first line when that isn't available.
195 */
196async function ensureTitle($: EngineInterface): Promise<void> {
197 if (isTitling) return
198 isTitling = true
199 try {
200 const id = await $.session.id()
201 if ((await $.store.get(titleKey(id))) !== undefined) return
202 const prompt = firstPrompt(await $.session.messages())
203 if (prompt === undefined || asked.has(id)) return
204 asked.add(id)
205 let title = titleFromPrompt(prompt)
206 try {
207 const reply = await $.model.complete({ model: 'haiku', system: TITLE_SYSTEM, prompt: prompt.slice(0, 2_000), maxTokens: 24, timeoutMs: 15_000 })
208 if (reply.isAnswered) title = cleanTitle(reply.text) ?? title
209 } catch {
210 // No small model here (another provider, or blocked): the prompt's first line will do.
211 }
212 await $.store.set(titleKey(id), title)
213 void refresh($)
214 } finally {
215 isTitling = false
216 }
217}
218
219/** What a window the mod opens needs from this session's environment: PATH, config folder and renderer. */
220async function forwardedEnv($: EngineInterface): Promise<string[]> {
221 const path = await $.env.get('PATH')
222 const fullscreen = await $.env.get('CLAUDE_CODE_NO_FLICKER')
223 return [
224 ...(path === undefined ? [] : ['-e', `PATH=${path}`]),
225 ...(configDir === `${home}/.claude` ? [] : ['-e', `CLAUDE_CONFIG_DIR=${configDir}`]),
226 ...(fullscreen === undefined ? [] : ['-e', `CLAUDE_CODE_NO_FLICKER=${fullscreen}`]),
227 ]
228}
229
230/** Starts a new Claude Code session in a new tmux window, in this session's folder, and shows it. */
231async function newSession($: EngineInterface): Promise<void> {
232 try {
233 if ((await $.env.get('TMUX')) === undefined) {
234 $.ui.toast('Not inside tmux: run claude in a new terminal.')
235 return
236 }
237 const cwd = await $.session.cwd()
238 const win = await $.process.run([
239 'tmux', 'new-window', '-d', '-P', '-F', '#{pane_id}', '-c', tmuxLiteral(cwd),
240 ...(await forwardedEnv($)),
241 '--', 'claude',
242 ])
243 const pane = win.stdout.trim()
244 if (win.exitCode !== 0 || !/^%\d+$/.test(pane)) {
245 $.ui.toast(`tmux: ${win.stderr.trim() || "couldn't open a new window"}`)
246 return
247 }
248 await switchToPane($, pane)
249 } catch (error) {
250 $.ui.toast(`Couldn't start a session: ${errorText(error)}`)
251 }
252}
253
254/** The running sessions, plus any missed for the first time (a registry file caught mid-write), as last seen. */
255function carryOver(found: Session[], now: number): Session[] {
256 const next = new Map<string, { session: Session; missingSince?: number }>(found.map(session => [session.sessionId, { session }]))
257 // A process now running another conversation (`/clear`) moved on; that isn't a missed read.
258 const processes = new Set(found.map(s => `${s.pid}:${s.procStart}`))
259 for (const [id, held] of carried) {
260 if (next.has(id) || processes.has(`${held.session.pid}:${held.session.procStart}`)) continue
261 const missingSince = held.missingSince ?? now
262 if (now - missingSince < MISS_GRACE_MS) next.set(id, { session: held.session, missingSince })
263 }
264 carried = next
265 return [...next.values()].map(held => held.session)
266}
267
268function isTabRecord(v: unknown): v is TabRecord {
269 if (v === null || typeof v !== 'object') return false
270 const r = v as Record<string, unknown>
271 return (
272 isSessionId(r.sessionId) &&
273 typeof r.name === 'string' &&
274 typeof r.cwd === 'string' &&
275 (r.kind === 'interactive' || r.kind === 'background') &&
276 typeof r.openedAt === 'number'
277 )
278}
279
280/** Whether Claude Code saved the session's conversation; undefined when its project folder isn't where expected. */
281async function hasTranscript($: EngineInterface, r: TabRecord): Promise<boolean | undefined> {
282 const dir = `${configDir}/projects/${projectDirName(r.cwd)}`
283 if (!(await $.fs.exists(dir).catch(() => false))) return undefined
284 return $.fs.exists(`${dir}/${r.sessionId}.jsonl`).catch(() => undefined)
285}
286
287/** The running sessions from Claude Code's registry: one record each, conversations only, processes alive. Null when the registry can't be listed. */
288async function liveSessions($: EngineInterface, roster: Roster): Promise<Session[] | null> {
289 const entries = await $.fs.list(registryDir).catch(() => null)
290 if (entries === null) return null
291 const found = await Promise.all(
292 entries
293 .filter(entry => entry.kind === 'file' && !entry.isLink && isRegistryFile(entry.name))
294 .map(entry =>
295 $.fs
296 .read(`${registryDir}/${entry.name}`)
297 .then(text => (typeof text === 'string' ? parseSession(text) : null))
298 .catch(() => null),
299 ),
300 )
301 const records = orderSessions(found.filter((s): s is Session => s !== null && isConversation(s)))
302 const alive = await Promise.all(records.map(s => isAlive($, s, roster)))
303 return records.filter((_, i) => alive[i])
304}
305
306/** A registry file can outlive a crashed session, and its pid can be reused: check the process is that session. */
307async function isAlive($: EngineInterface, s: Session, roster: Roster): Promise<boolean> {
308 if (s.sessionId === selfId) return true
309 if (hasProc) {
310 const stat = await $.fs.read(`/proc/${s.pid}/stat`).catch(() => null)
311 if (typeof stat !== 'string') return false
312 return s.procStart === undefined || parseStat(stat)?.startTime === s.procStart
313 }
314 if (roster.jobs === undefined || roster.livePids === undefined) return true
315 return s.kind === 'bg' ? roster.jobs.some(j => j.sessionId === s.sessionId) : roster.livePids.includes(s.pid)
316}
317
318/**
319 * The background sessions, from `claude agents --json --all`. Every session running the mod shares
320 * one answer through the store, a failed attempt included, so the machine runs the command at most
321 * about once per 10 seconds.
322 */
323async function getRoster($: EngineInterface, now: number): Promise<Roster> {
324 const cached = (await $.store.get('roster')) as Roster | undefined
325 if (cached !== undefined && typeof cached.at === 'number' && now - cached.at < ROSTER_MAX_AGE_MS) return cached
326 rosterFetch ??= (async () => {
327 let roster: Roster = { at: now }
328 try {
329 const run = await $.process.run(['claude', 'agents', '--json', '--all'], { timeoutMs: 10_000 })
330 const parsed = run.exitCode === 0 ? parseRoster(run.stdout) : null
331 if (parsed !== null) roster = { at: now, jobs: parsed.jobs, livePids: [...parsed.livePids] }
332 } catch {
333 // `claude` not on PATH or too slow: recorded as a failure, tried again in 10 seconds.
334 }
335 await $.store.set('roster', roster).catch(() => {})
336 return roster
337 })().finally(() => {
338 rosterFetch = null
339 })
340 return rosterFetch
341}
342
343async function parentOf($: EngineInterface): Promise<(pid: number) => Promise<number | undefined>> {
344 if (hasProc) {
345 return async pid => {
346 const stat = await $.fs.read(`/proc/${pid}/stat`).catch(() => null)
347 return typeof stat === 'string' ? parseStat(stat)?.ppid : undefined
348 }
349 }
350 const run = await $.process.run(['ps', '-A', '-o', 'pid=,ppid='])
351 const table = parseProcessTable(run.stdout)
352 return async pid => table.get(pid)
353}
354
355/** Shows `pane` in the terminal that shows this session, not whichever one tmux would guess. */
356async function switchToPane($: EngineInterface, pane: string): Promise<void> {
357 const ownPane = await $.env.get('TMUX_PANE')
358 const clients =
359 ownPane === undefined
360 ? undefined
361 : await $.process.run(['tmux', 'list-clients', '-F', '#{client_activity} #{client_name} #{pane_id}'])
362 const client = clients?.exitCode === 0 && ownPane !== undefined ? clientShowing(clients.stdout, ownPane) : undefined
363 const sw = await $.process.run(['tmux', 'switch-client', ...(client === undefined ? [] : ['-c', client]), '-t', pane])
364 if (sw.exitCode !== 0) $.ui.toast(`tmux: ${sw.stderr.trim() || 'switch failed'}`)
365}
366
367/** A pane this mod opened for the session earlier and that is still there. */
368async function taggedPane($: EngineInterface, sessionId: string): Promise<string | undefined> {
369 const run = await $.process.run(['tmux', 'list-panes', '-a', '-F', `#{pane_id} #{${PANE_TAG}}`])
370 if (run.exitCode !== 0) return undefined
371 for (const line of run.stdout.split('\n')) {
372 const [pane, tag] = line.trim().split(' ')
373 if (tag === sessionId && pane !== undefined && /^%\d+$/.test(pane)) return pane
374 }
375 return undefined
376}
377
378/** How to open a tab's session: attach to a background one, resume an interactive one. */
379function openCommand(r: TabRecord): string[] {
380 return r.kind === 'background' && isJobId(r.jobId) ? ['claude', 'attach', r.jobId] : ['claude', '--resume', r.sessionId]
381}
382
383/**
384 * Brings a tab's session up in this terminal: jumps to the tmux pane it runs or shows in, or opens
385 * it in a new tmux window (attaching to a background session, resuming one that exited).
386 */
387async function openTab($: EngineInterface, tab: Tab): Promise<void> {
388 const r = tab.record
389 try {
390 if (r.sessionId === selfId) {
391 $.ui.toast("You're already in this session.")
392 return
393 }
394 if (!isSessionId(r.sessionId)) return
395 if (r.kind === 'background' && !isJobId(r.jobId)) {
396 $.ui.toast(`${r.name} is still starting in the background. Try again in a moment.`)
397 return
398 }
399 if ((await $.env.get('TMUX')) === undefined) {
400 $.ui.toast(`Not inside tmux. To open ${r.name}, run: ${openCommand(r).join(' ')}`)
401 return
402 }
403
404 // An interactive session running right now: its own pane, if that pane is on this tmux server.
405 if (tab.live !== undefined && tab.live.kind !== 'bg') {
406 const pane = tmuxPane(tab.live.tmux)
407 if (pane === null) {
408 $.ui.toast(`${r.name} is running in a terminal outside tmux.`)
409 return
410 }
411 const where = await $.process.run(['tmux', 'display-message', '-p', '-t', pane, '#{pane_pid}'])
412 const panePid = Number(where.stdout.trim())
413 const isThere =
414 where.exitCode === 0 && Number.isInteger(panePid) && (await descendsFrom(tab.live.pid, panePid, await parentOf($)))
415 if (!isThere) {
416 $.ui.toast(`${r.name} isn't in a pane of this tmux server.`)
417 return
418 }
419 await switchToPane($, pane)
420 return
421 }
422
423 // A window opened for it before, then a new one.
424 const opened = await taggedPane($, r.sessionId)
425 if (opened !== undefined) {
426 await switchToPane($, opened)
427 return
428 }
429 if (r.kind === 'interactive' && (await hasTranscript($, r)) === false) {
430 $.ui.toast(`${r.name} ended before its first message, so there is nothing to reopen.`)
431 await closeTab($, r.sessionId)
432 return
433 }
434 // `--resume` finds a conversation by the folder it ran in.
435 if (r.cwd === '' || !(await $.fs.exists(r.cwd).catch(() => false))) {
436 $.ui.toast(`${r.name}'s folder ${r.cwd || '(unknown)'} is gone, so it can't be reopened from here.`)
437 return
438 }
439 // The new window runs with tmux's environment: hand it this session's.
440 const forwarded = await forwardedEnv($)
441 const win = await $.process.run([
442 'tmux',
443 'new-window',
444 '-d',
445 '-P',
446 '-F',
447 '#{pane_id}',
448 '-n',
449 windowName(r.name),
450 '-c',
451 tmuxLiteral(r.cwd),
452 ...forwarded,
453 '--',
454 ...openCommand(r),
455 ])
456 const pane = win.stdout.trim()
457 if (win.exitCode !== 0 || !/^%\d+$/.test(pane)) {
458 $.ui.toast(`tmux: ${win.stderr.trim() || `couldn't open ${r.name}`}`)
459 return
460 }
461 const tagged = await $.process.run(['tmux', 'set-option', '-p', '-t', pane, PANE_TAG, r.sessionId])
462 if (tagged.exitCode !== 0) {
463 $.ui.toast(`${r.name} closed as it opened. Try in a shell: ${openCommand(r).join(' ')}`)
464 return
465 }
466 await switchToPane($, pane)
467 // A command that fails a moment later takes its window with it: say so rather than leave a flash.
468 $.clock.after(2_000, () => {
469 void $.process
470 .run(['tmux', 'display-message', '-p', '-t', pane, '#{pane_id}'])
471 .then(check => {
472 if (check.exitCode !== 0) $.ui.toast(`${r.name} closed as it opened. Try in a shell: ${openCommand(r).join(' ')}`)
473 })
474 .catch(() => {})
475 })
476 } catch (error) {
477 $.ui.toast(`Couldn't open ${r.name}: ${errorText(error)}`)
478 }
479}
480
481/** Hides a tab. The session itself is left alone: a background one keeps running, and any can be resumed. */
482async function closeTab($: EngineInterface, sessionId: string): Promise<void> {
483 try {
484 await $.store.set(closedKey(sessionId), await $.clock.now())
485 const stack = await $.store.get('reopen')
486 const ids = Array.isArray(stack) ? stack.filter(isSessionId).filter(id => id !== sessionId) : []
487 await $.store.set('reopen', [...ids, sessionId].slice(-25))
488 await refresh($)
489 } catch (error) {
490 $.ui.toast(`Couldn't close the tab: ${errorText(error)}`)
491 }
492}
493
494/** Opens the most recently closed tab again. */
495async function reopenTab($: EngineInterface): Promise<void> {
496 try {
497 const stack = await $.store.get('reopen')
498 const ids = Array.isArray(stack) ? stack.filter(isSessionId) : []
499 while (ids.length > 0) {
500 const id = ids.pop()!
501 if ((await $.store.get(closedKey(id))) === undefined || !isTabRecord(await $.store.get(tabKey(id)))) continue
502 await $.store.delete(closedKey(id))
503 await $.store.set('reopen', ids)
504 await refresh($)
505 return
506 }
507 await $.store.set('reopen', [])
508 $.ui.toast('No closed tabs to reopen.')
509 } catch (error) {
510 $.ui.toast(`Couldn't reopen the tab: ${errorText(error)}`)
511 }
512}
513
514const clip = (text: string, room: number): string =>
515 text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`
516
517const findTab = (query: string) => pick(tabs, query, t => t.record.name)
518
519export const register: Register = on => {
520 on('session.start', async ($, e, next) => {
521 // Headless runs (`claude -p`, the SDK) draw nothing, so there is nothing to keep fresh.
522 if (!e.isInteractive) return next(e)
523
524 try {
525 home = (await $.env.get('HOME')) ?? ''
526 configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
527 registryDir = `${configDir}/sessions`
528 hasProc = await $.fs.exists('/proc/self/stat').catch(() => false)
529
530 // Poll only while the sidebar is open; `/tabs` refreshes on demand.
531 $.clock.every(REFRESH_MS, () => {
532 void paneState($)
533 .then(pane => {
534 if (pane.isVisible) return refresh($)
535 animate($, false)
536 })
537 .catch(() => {})
538 })
539 await refresh($)
540
541 if ((await $.store.get('autoOpen')) !== false) {
542 void $.ui
543 .open({ id: PANE, title: TITLE, columns: WIDTH })
544 .then(() => refresh($))
545 .catch(() => {})
546 }
547 } catch (error) {
548 $.ui.log(`session-tabs: start failed: ${errorText(error)}`, { to: 'debug' })
549 }
550
551 try {
552 await $.command.register({
553 name: 'tabs',
554 description: 'Session tabs: toggle the sidebar, open a tab by number or name, start, close or reopen one',
555 argumentHint: '[N | name | new | close N | reopen]',
556 immediate: true,
557 })
558 } catch (error) {
559 $.ui.log(`session-tabs: /tabs not registered: ${errorText(error)}`)
560 }
561 return next(e)
562 })
563
564 // After a turn of this session's own conversation, give it a title if it has none yet.
565 on('turn.complete', async ($, e, next) => {
566 const result = await next(e)
567 if (e.agentId === undefined && registryDir !== '') void ensureTitle($).catch(() => {})
568 return result
569 }).catch(() => undefined)
570
571 on('command.run', { command: 'tabs' }, async ($, e) => {
572 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
573 const query = rest.join(' ')
574 if (verb === '') {
575 // On screen: close it. Open but behind another pane, or closed: bring it up.
576 if ((await paneState($)).isVisible) {
577 await $.ui.close({ id: PANE })
578 } else {
579 await $.store.set('autoOpen', true)
580 await refresh($)
581 await $.ui.open({ id: PANE, title: TITLE, columns: WIDTH, focus: true })
582 void refresh($)
583 }
584 return {}
585 }
586 if (verb === 'new') {
587 await newSession($)
588 return {}
589 }
590 await refresh($)
591 if (verb === 'reopen') {
592 await reopenTab($)
593 return {}
594 }
595 if (verb === 'close') {
596 const found = query === '' ? undefined : findTab(query)
597 if (found === undefined) $.ui.toast(query === '' ? 'Usage: /tabs close N' : `No tab matches "${query}".`)
598 else await closeTab($, found.record.sessionId)
599 return {}
600 }
601 const found = findTab(e.args)
602 if (found === undefined) $.ui.toast(`No tab matches "${e.args.trim()}".`)
603 else await openTab($, found)
604 return {}
605 })
606
607 // Closing the sidebar yourself keeps it closed in new sessions until `/tabs` opens it again.
608 on('ui.close', async ($, e, next) => {
609 if (e.id === PANE) animate($, false)
610 if (e.id === PANE && e.origin.kind !== 'unload') void $.store.set('autoOpen', false).catch(() => {})
611 return next(e)
612 }).catch(() => undefined) // a failing hook must never keep the pane from closing
613
614 // OpenCode's sidebar: one card per tab, a spinner or dot and the title, the folder under it.
615 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
616 const { Box, Text, Button } = $.ui.resolve(e)
617 const now = await $.clock.now()
618 const isDocked = e.props.placement === 'dock'
619 const width = Math.max(12, e.props.bodyColumns)
620 // Room for the title: the width less the glyph, the close mark and the gaps between them.
621 const room = Math.max(8, width - 6)
622 const spin = spinnerFrame(now)
623 const waiting = tabs.filter(t => t.record.sessionId !== selfId && tabBadge(t).tone === 'needs').length
624 const rule = (key: string) => (
625 <Box key={key}>
626 <Text color="subtle">{'─'.repeat(width)}</Text>
627 </Box>
628 )
629
630 const cards = tabs.map((t, i) => {
631 const b = tabBadge(t)
632 const id = t.record.sessionId
633 const title = clip(tabTitle(t.record, titles.get(id)), room)
634 const folder = basename(t.record.cwd)
635 const name =
636 id === selfId ? (
637 <Text bold wrap="truncate-end">
638 {title}
639 </Text>
640 ) : (
641 <Button key={`go-${id}`} label={title} plain dimColor onPress={() => openTab($, t)} />
642 )
643 const head = (
644 <Box flexDirection="row" columnGap={1}>
645 <Box key={`glyph-${id}`}>
646 <Text color={b.color}>{b.tone === 'working' ? spin : b.glyph}</Text>
647 </Box>
648 <Box flexGrow={1}>{name}</Box>
649 {!isDocked && (
650 <Text dimColor wrap="truncate-end">
651 {folder}
652 </Text>
653 )}
654 <Button key={`close-${id}`} label="✕" plain dimColor onPress={() => closeTab($, id)} />
655 </Box>
656 )
657 if (!isDocked) return <Box key={`row-${id}`}>{head}</Box>
658 return (
659 <Box key={`row-${id}`} flexDirection="column">
660 {i > 0 && rule(`rule-${id}`)}
661 {head}
662 <Box key={`folder-${id}`}>
663 <Text color="inactive" wrap="truncate-end">
664 {` ${folder}`}
665 </Text>
666 </Box>
667 </Box>
668 )
669 })
670
671 return (
672 <Box flexDirection="column">
673 <Text dimColor>
674 {`${tabs.length} session${tabs.length === 1 ? '' : 's'}`}
675 {waiting > 0 ? ` · ${waiting} need${waiting === 1 ? 's' : ''} you` : ''}
676 </Text>
677 {isDocked && rule('rule-top')}
678 {tabs.length === 0 ? <Text dimColor>No sessions yet.</Text> : cards}
679 {isDocked && rule('rule-bottom')}
680 <Button key="new" label="+ New session" plain dimColor onPress={() => newSession($)} />
681 </Box>
682 )
683 })
684}
685hooks/model.ts 431 lines1// Pure logic: no `$`, so the tests can call it directly.
2
3/** One running Claude Code session, as its registry file describes it. */
4export type Session = {
5 pid: number
6 sessionId: string
7 name: string
8 cwd: string
9 /** `interactive` for a terminal session, `bg` for one the supervisor runs. */
10 kind: 'interactive' | 'bg'
11 status?: string
12 waitingFor?: string
13 startedAt: number
14 statusUpdatedAt?: number
15 /** The process's start time in clock ticks, as `/proc/<pid>/stat` field 22 has it (Linux). */
16 procStart?: string
17 /** `<session>:<window>.<pane>`, present when the session runs inside tmux. */
18 tmux?: string
19 /** A pre-started background worker with no conversation yet. */
20 spare?: boolean
21 /** Set on a terminal that handed its conversation to a background session: a client now, not a session. */
22 parkedJobId?: string
23 /** A background session's id for `claude attach`. */
24 jobId?: string
25 /** Where `name` came from: `derived` and `collision` are made up from the folder; `user`, `auto` and `hook` say something. */
26 nameSource?: string
27}
28
29/** A background session as `claude agents --json --all` lists it: running, waiting or finished. */
30export type Job = {
31 id: string
32 sessionId: string
33 name: string
34 cwd: string
35 state?: string
36 startedAt: number
37}
38
39/** A tab, kept in the mod's store until it is closed. Closing is kept apart, under its own key. */
40export type TabRecord = {
41 sessionId: string
42 name: string
43 cwd: string
44 kind: 'interactive' | 'background'
45 jobId?: string
46 /** Whether `name` says what the session is about (set with /rename, or by Claude Code), not made up from the folder. */
47 isNamed?: boolean
48 openedAt: number
49 /** The process last seen running the session, to follow a terminal that starts a new conversation. */
50 pid?: number
51 procStart?: string
52 /** When an interactive session's process was found gone. */
53 endedAt?: number
54 /** When a background session first went missing from the roster. */
55 missingSince?: number
56}
57
58/** A tab as drawn: its record, plus what is running for it now. */
59export type Tab = { record: TabRecord; live?: Session; job?: Job }
60
61export type Tone = 'working' | 'needs' | 'idle' | 'other'
62
63export type Badge = { glyph: string; color: string; label: string; tone: Tone }
64
65/** How long a background session may be missing from the roster before its tab closes. */
66export const MISSING_GRACE_MS = 60_000
67/** The most tabs kept open; past it, the tabs of sessions that ended longest ago close. */
68export const MAX_TABS = 40
69
70const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
71const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
72
73export const isJobId = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-z][0-9a-z-]{0,63}$/i.test(v)
74export const isSessionId = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f][0-9a-f-]{7,63}$/i.test(v)
75
76/** Registry files are named `<pid>.json`; the `<pid>.<hash>.key` files beside them are secrets and never read. */
77export const isRegistryFile = (name: string): boolean => /^\d+\.json$/.test(name)
78
79/** Parses one registry file; null for anything that is not a terminal or background conversation's record. */
80export function parseSession(text: string): Session | null {
81 let raw: unknown
82 try {
83 raw = JSON.parse(text)
84 } catch {
85 return null
86 }
87 if (raw === null || typeof raw !== 'object') return null
88 const o = raw as Record<string, unknown>
89 const pid = num(o.pid)
90 const sessionId = o.sessionId
91 const cwd = str(o.cwd)
92 const kind = o.kind ?? 'interactive'
93 if (pid === undefined || !isSessionId(sessionId) || cwd === undefined) return null
94 if (kind !== 'interactive' && kind !== 'bg') return null
95 const jobId = isJobId(o.jobId) ? o.jobId : undefined
96 return {
97 pid,
98 sessionId,
99 cwd,
100 name: str(o.name) ?? basename(cwd),
101 kind,
102 status: str(o.status),
103 waitingFor: str(o.waitingFor),
104 startedAt: num(o.startedAt) ?? 0,
105 statusUpdatedAt: num(o.statusUpdatedAt),
106 procStart: str(o.procStart),
107 tmux: str(o.tmux),
108 spare: o.spare === true,
109 parkedJobId: str(o.parkedJobId),
110 jobId,
111 nameSource: str(o.nameSource),
112 }
113}
114
115/** Whether a registry name says what the session is about, rather than being made up from its folder. */
116export const isMeaningfulName = (s: Session): boolean =>
117 s.nameSource !== undefined && s.nameSource !== 'derived' && s.nameSource !== 'collision'
118
119/** Whether a registry entry is a conversation worth a tab, not a spare worker or a terminal that only shows a background one. */
120export const isConversation = (s: Session): boolean => !s.spare && s.parkedJobId === undefined
121
122/** `claude agents --json --all`: the background sessions, and the pids of running interactive ones. */
123export function parseRoster(stdout: string): { jobs: Job[]; livePids: Set<number> } | null {
124 let list: unknown
125 try {
126 list = JSON.parse(stdout)
127 } catch {
128 return null
129 }
130 if (!Array.isArray(list)) return null
131 const jobs: Job[] = []
132 const livePids = new Set<number>()
133 for (const item of list) {
134 if (item === null || typeof item !== 'object') continue
135 const o = item as Record<string, unknown>
136 if (o.kind === 'background' && isJobId(o.id) && isSessionId(o.sessionId)) {
137 jobs.push({
138 id: o.id,
139 sessionId: o.sessionId,
140 name: str(o.name) ?? o.id,
141 cwd: str(o.cwd) ?? '',
142 state: str(o.state),
143 startedAt: num(o.startedAt) ?? 0,
144 })
145 } else if (o.kind === 'interactive' && num(o.pid) !== undefined) {
146 livePids.add(num(o.pid)!)
147 }
148 }
149 // The session asking is always listed, so an empty answer is not to be trusted.
150 return jobs.length === 0 && livePids.size === 0 ? null : { jobs, livePids }
151}
152
153/** Parent pid and start time from `/proc/<pid>/stat`. The name in parentheses may hold spaces, so fields count from its `)`. */
154export function parseStat(text: string): { ppid: number; startTime: string } | null {
155 const fields = text.slice(text.lastIndexOf(')') + 2).split(' ')
156 // After the name: field 3 (state) is index 0, so field 4 (ppid) is 1 and field 22 (starttime) is 19.
157 const ppid = Number(fields[1])
158 const startTime = fields[19]
159 return Number.isInteger(ppid) && startTime !== undefined && /^\d+$/.test(startTime) ? { ppid, startTime } : null
160}
161
162/** `ps -A -o pid=,ppid=` output as a pid → parent pid map. */
163export function parseProcessTable(stdout: string): Map<number, number> {
164 const parents = new Map<number, number>()
165 for (const line of stdout.split('\n')) {
166 const [pid, ppid] = line.trim().split(/\s+/).map(Number)
167 if (Number.isInteger(pid) && Number.isInteger(ppid)) parents.set(pid!, ppid!)
168 }
169 return parents
170}
171
172/** Whether `ancestor` is `pid` or one of its parents, following `parentOf` a bounded number of steps. */
173export async function descendsFrom(
174 pid: number,
175 ancestor: number,
176 parentOf: (pid: number) => Promise<number | undefined>,
177): Promise<boolean> {
178 let current: number | undefined = pid
179 for (let step = 0; step < 32 && current !== undefined && current > 1; step++) {
180 if (current === ancestor) return true
181 current = await parentOf(current)
182 }
183 return current === ancestor
184}
185
186/**
187 * One record per session id (the newest, when a resumed session left an older one behind),
188 * ordered by start time.
189 */
190export function orderSessions(all: readonly Session[]): Session[] {
191 const newest = new Map<string, Session>()
192 for (const s of all) {
193 const held = newest.get(s.sessionId)
194 const isNewer =
195 held === undefined ||
196 s.startedAt > held.startedAt ||
197 (s.startedAt === held.startedAt && (s.statusUpdatedAt ?? 0) > (held.statusUpdatedAt ?? 0))
198 if (isNewer) newest.set(s.sessionId, s)
199 }
200 return [...newest.values()].sort((a, b) => a.startedAt - b.startedAt || a.pid - b.pid)
201}
202
203export type TabChanges = { upserts: TabRecord[]; closes: string[] }
204
205/**
206 * Brings the stored tabs up to date with what is running, OpenCode-style: a tab stays until closed.
207 *
208 * - A new conversation gets a tab. One a terminal started in place of another (`/clear`, `/resume`)
209 * takes over that tab's place, and the old tab closes.
210 * - An interactive tab whose session is no longer `present` is marked ended and stays.
211 * - A background tab whose session has been missing from a readable roster for a minute closes, as
212 * OpenCode closes tabs of deleted sessions. `jobs` is null when the roster couldn't be read.
213 * - Past MAX_TABS open tabs, those whose sessions ended longest ago close.
214 *
215 * `present` holds the ids to treat as still there (the live ones, plus any missed only once, so a
216 * half-written registry file doesn't end a tab). Only records that changed are returned.
217 */
218export function syncTabs(
219 stored: readonly TabRecord[],
220 closed: ReadonlySet<string>,
221 live: readonly Session[],
222 present: ReadonlySet<string>,
223 jobs: readonly Job[] | null,
224 now: number,
225): TabChanges {
226 const original = new Map(stored.map(r => [r.sessionId, r]))
227 const changed = new Map<string, TabRecord>()
228 const closes = new Set<string>()
229 const current = (id: string) => changed.get(id) ?? original.get(id)
230 const put = (next: TabRecord) => {
231 const before = original.get(next.sessionId)
232 if (before !== undefined && sameRecord(before, next)) changed.delete(next.sessionId)
233 else changed.set(next.sessionId, next)
234 }
235 const isOpen = (id: string) => !closed.has(id) && !closes.has(id)
236 const jobById = new Map((jobs ?? []).map(j => [j.sessionId, j]))
237 const liveIds = new Set(live.map(s => s.sessionId))
238
239 for (const s of live) {
240 if (closed.has(s.sessionId)) continue
241 const job = jobById.get(s.sessionId)
242 const kind = s.kind === 'bg' || job !== undefined ? 'background' : 'interactive'
243 let held = current(s.sessionId)
244 if (held === undefined && kind === 'interactive' && s.procStart !== undefined) {
245 // The same process with a new conversation: the terminal's tab moves on to it.
246 const before = stored.find(
247 r => r.kind === 'interactive' && r.pid === s.pid && r.procStart === s.procStart && !liveIds.has(r.sessionId) && isOpen(r.sessionId),
248 )
249 if (before !== undefined) {
250 closes.add(before.sessionId)
251 held = { ...before, sessionId: s.sessionId }
252 }
253 }
254 const jobId = s.jobId ?? job?.id
255 put({
256 sessionId: s.sessionId,
257 name: s.name,
258 cwd: s.cwd,
259 kind,
260 ...(isMeaningfulName(s) ? { isNamed: true } : {}),
261 openedAt: held?.openedAt ?? (s.startedAt || now),
262 pid: s.pid,
263 ...(s.procStart === undefined ? {} : { procStart: s.procStart }),
264 ...(jobId === undefined ? {} : { jobId }),
265 })
266 }
267
268 for (const job of jobs ?? []) {
269 if (closed.has(job.sessionId) || liveIds.has(job.sessionId)) continue
270 const held = current(job.sessionId)
271 put({
272 sessionId: job.sessionId,
273 name: job.name,
274 cwd: job.cwd || held?.cwd || '',
275 kind: 'background',
276 jobId: job.id,
277 // Claude Code names a background session from its task; until then its name is its id.
278 ...(job.name !== job.id ? { isNamed: true } : {}),
279 openedAt: held?.openedAt ?? (job.startedAt || now),
280 ...(held?.pid === undefined ? {} : { pid: held.pid }),
281 ...(held?.procStart === undefined ? {} : { procStart: held.procStart }),
282 })
283 }
284
285 for (const r of stored) {
286 if (!isOpen(r.sessionId) || present.has(r.sessionId) || jobById.has(r.sessionId)) continue
287 const held = current(r.sessionId)!
288 if (held.kind === 'interactive' && held.endedAt === undefined) put({ ...held, endedAt: now })
289 if (held.kind === 'background' && jobs !== null) {
290 if (held.missingSince === undefined) put({ ...held, missingSince: now })
291 else if (now - held.missingSince >= MISSING_GRACE_MS) closes.add(r.sessionId)
292 }
293 }
294
295 // Past the cap, the tabs whose sessions ended longest ago close.
296 const open = new Map<string, TabRecord>()
297 for (const r of [...stored, ...changed.values()]) if (isOpen(r.sessionId)) open.set(r.sessionId, current(r.sessionId)!)
298 const ended = [...open.values()].filter(r => r.endedAt !== undefined).sort((a, b) => a.endedAt! - b.endedAt!)
299 for (let excess = open.size - MAX_TABS; excess > 0 && ended.length > 0; excess--) closes.add(ended.shift()!.sessionId)
300
301 return { upserts: [...changed.values()].filter(r => !closes.has(r.sessionId)), closes: [...closes] }
302}
303
304function sameRecord(a: TabRecord, b: TabRecord): boolean {
305 const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof TabRecord>
306 for (const key of keys) if (a[key] !== b[key]) return false
307 return true
308}
309
310/** The open tabs, oldest first, each with what is running for it. */
311export function openTabs(
312 records: readonly TabRecord[],
313 closed: ReadonlySet<string>,
314 live: readonly Session[],
315 jobs: readonly Job[],
316): Tab[] {
317 const liveById = new Map(live.map(s => [s.sessionId, s]))
318 const jobById = new Map(jobs.map(j => [j.sessionId, j]))
319 return records
320 .filter(r => !closed.has(r.sessionId))
321 .sort((a, b) => a.openedAt - b.openedAt || a.sessionId.localeCompare(b.sessionId))
322 .map(record => ({ record, live: liveById.get(record.sessionId), job: jobById.get(record.sessionId) }))
323}
324
325/** The tmux client showing `pane` that was used last, from `list-clients -F '#{client_activity} #{client_name} #{pane_id}'`. */
326export function clientShowing(stdout: string, pane: string): string | undefined {
327 let best: { activity: number; name: string } | undefined
328 for (const line of stdout.split('\n')) {
329 const [activity, name, shown] = line.trim().split(' ')
330 if (name === undefined || shown !== pane) continue
331 const at = Number(activity)
332 if (best === undefined || at > best.activity) best = { activity: at, name }
333 }
334 return best?.name
335}
336
337export function badge(s: Session): Badge {
338 if (s.waitingFor !== undefined || s.status === 'waiting' || s.status === 'blocked') {
339 return { glyph: '●', color: 'warning', label: s.waitingFor ?? 'needs input', tone: 'needs' }
340 }
341 if (s.status === 'busy') return { glyph: '●', color: 'claude', label: 'working', tone: 'working' }
342 if (s.status === 'idle') return { glyph: '○', color: 'inactive', label: 'idle', tone: 'idle' }
343 return { glyph: '·', color: 'subtle', label: s.status ?? s.kind, tone: 'other' }
344}
345
346/**
347 * How a tab's status is drawn: a background job's settled state (waiting, done, failed, stopped)
348 * first, as agent view shows it; then a running process's own status; then the job's.
349 */
350export function tabBadge(tab: Tab): Badge {
351 const state = tab.job?.state
352 if (state === 'blocked') return { glyph: '●', color: 'warning', label: tab.live?.waitingFor ?? 'needs input', tone: 'needs' }
353 if (tab.live !== undefined && (state === undefined || state === 'running')) return badge(tab.live)
354 if (state === 'running') return { glyph: '●', color: 'claude', label: 'working', tone: 'working' }
355 if (state === 'done') return { glyph: '✓', color: 'success', label: 'done', tone: 'other' }
356 if (state === 'failed') return { glyph: '✗', color: 'error', label: 'failed', tone: 'other' }
357 if (state === 'stopped') return { glyph: '■', color: 'inactive', label: 'stopped', tone: 'other' }
358 if (tab.record.kind === 'background') return { glyph: '○', color: 'inactive', label: 'paused', tone: 'idle' }
359 return { glyph: '–', color: 'inactive', label: 'exited', tone: 'other' }
360}
361
362export function basename(path: string): string {
363 const parts = path.split('/').filter(Boolean)
364 return parts.at(-1) ?? path
365}
366
367/** The `%pane` part of a registry `tmux` field, a pane id that is unique on its tmux server. */
368export function tmuxPane(field: string | undefined): string | null {
369 const [, pane] = field?.match(/\.(%\d+)$/) ?? []
370 return pane ?? null
371}
372
373/** Finds an item by its 1-based tab number or by (a prefix of) its name. */
374export function pick<T>(items: readonly T[], query: string, nameOf: (item: T) => string): T | undefined {
375 const q = query.trim()
376 if (/^\d+$/.test(q)) return items[Number(q) - 1]
377 const lower = q.toLowerCase()
378 const name = (item: T) => nameOf(item).toLowerCase()
379 return items.find(item => name(item) === lower) ?? items.find(item => name(item).startsWith(lower))
380}
381
382/**
383 * What a tab is called: a name that says something (set with /rename, or by Claude Code), else the
384 * title the session gave itself from its first prompt, else its made-up name.
385 */
386export const tabTitle = (r: TabRecord, title: string | undefined): string =>
387 plainText(r.isNamed === true ? r.name : (title ?? r.name)) || plainText(r.name) || 'session'
388
389/** Text safe to draw on one line: control and format characters (a pasted escape sequence) become spaces. */
390export const plainText = (text: string): string =>
391 text.replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim()
392
393/** The braille spinner a working tab shows, one frame per SPIN_MS. */
394export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
395export const SPIN_MS = 120
396export const spinnerFrame = (now: number): string => SPINNER[Math.floor(now / SPIN_MS) % SPINNER.length]!
397
398const TITLE_MAX = 60
399
400/** The text of the conversation's first prompt, skipping what Claude Code wraps in tags (commands, notices). */
401export function firstPrompt(messages: readonly { role: string; text: string }[]): string | undefined {
402 for (const m of messages) {
403 const text = m.text.trim()
404 if (m.role === 'user' && text !== '' && !text.startsWith('<')) return text
405 }
406 return undefined
407}
408
409/** A title made from a prompt with no model: its first line, cut short. */
410export function titleFromPrompt(prompt: string): string {
411 const line = prompt.split('\n').map(plainText).find(l => l !== '') ?? plainText(prompt)
412 return line.length <= TITLE_MAX ? line : `${line.slice(0, TITLE_MAX - 1)}…`
413}
414
415/** A model's title, tidied: one line, no quotes or trailing period; undefined when nothing usable is left. */
416export function cleanTitle(text: string): string | undefined {
417 const line = text.split('\n').map(plainText).find(l => l !== '')
418 if (line === undefined) return undefined
419 const title = line.replace(/^(title:\s*)/i, '').replace(/^["'`*]+|["'`*.]+$/g, '').trim()
420 return title === '' ? undefined : title.slice(0, TITLE_MAX)
421}
422
423/** The folder under `~/.claude/projects` that holds a directory's transcripts: every non-alphanumeric becomes `-`. */
424export const projectDirName = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-')
425
426/** A window name tmux shows as typed: no format characters, no control characters, not too long. */
427export const windowName = (name: string): string => name.replace(/[#\p{Cc}]/gu, '').slice(0, 40) || 'claude'
428
429/** tmux expands formats in a start directory, so a `#` in a path is written `##`. */
430export const tmuxLiteral = (text: string): string => text.replace(/#/g, '##')
431