SLOPSHOPPER

session-tabs

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

newpanecommandtoastmodelprocess
v0.3.0MITupdated 2026-10-08NguyenPhan2810/claude-session-tabs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-tabs
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ 0 sessions │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ No sessions yet. ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ──────────── ⏺ Bash(bun test) │ ┃ + New session ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tabs │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
0 sessions ──────────────────────────────────────────────────────── No sessions yet. ──────────────────────────────────────────────────────── + New session
README

session-tabs

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.

Install

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).

Use

  • Click a tab, or type /tabs 2 / /tabs docs (a title prefix), to go to that session:
  • running in a tmux pane: your terminal jumps to that pane
  • exited: it's reopened (claude --resume) in a new tmux window, and you jump there
  • a background session: it's opened (claude attach) in a new tmux window, or the window already showing it
  • + New session or /tabs new starts claude in a new tmux window, in this session's folder.
  • ✕ or /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.
  • Focus the sidebar (Ctrl+X then Tab), then Tab and Enter to open tabs from the keyboard.

Titles

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.

Sessions that keep running without a terminal

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.

Fullscreen gives you the sidebar

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.

Switching needs tmux

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.

How it works

  • While the sidebar is open, every 2 seconds it reads Claude Code's session registry, ~/.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.
  • Background sessions, finished ones included, come from 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.
  • A crashed session can leave its registry file behind, and its pid can later belong to another program. On Linux the mod compares each session's recorded start time with /proc/<pid>/stat; elsewhere it uses the claude agents answer.
  • Tabs live in the mod's own store (~/.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.
  • The spinner redraws the sidebar about 8 times a second, only while a tab is working and the sidebar is open.
  • Before jumping to a running session's pane, it asks tmux which process runs there and checks the session runs under it, so it never jumps to a same-numbered pane on another tmux server. Windows it opens are tagged with the tmux pane option @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.

Develop

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.

License

MIT

Source 2 files
hooks/register.tsx 685 lines
1import 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}
685
hooks/model.ts 431 lines
1// 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