SLOPSHOPPER

ghostty

Ghostty integration: a 4-word summary as the tab title (/title for a new one now), a tab progress bar, ❓ and a notification when Claude waits on you, /goto…

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-09whilestevego/claude-mods/ghostty
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ghostty
│ ┃ claude-goto ✕ › fix the failing auth test and add an audit log call │ ┃ ❓ waiting on you · ⏳ working · number or │ ┃ Enter to jump · Esc to close ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /title │ ⎿ ghostty: Tab title: OK │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · claude-goto
❓ waiting on you · ⏳ working · number or Enter to jump · Esc to close
Pane · claude-workspaces
No saved workspaces. /workspace save remembers this window.
README

claude-mods

Mods for Claude Code: small plugins that add to its screen and behavior. Each folder is one mod. Turn on the ones you want (see Setup) and change their settings in /config, under the mod's name.

The mods

context-bar

Shows how full Claude's context window is, as a colored bar above the prompt.

  • Point at the bar to see what's using the space (system prompt, messages, tools…).
  • /context-bar hides or shows it for this session.

rate-limits

Shows how much of your 5-hour and weekly usage limits you've used, as two small meters under the context bar. They turn yellow at 70% and red at 90%, and you get a warning once a limit passes 90%.

  • Nothing to do: it appears on its own when you're on a subscription plan.

session-id

Shows this session's ID in the bottom-right corner of the screen.

  • Click it to copy the ID.
  • Ctrl-click it to copy the full claude --resume <id> command.

turn-receipt

Adds a receipt to the line that closes each turn ("✻ Baked for 42s"): how many tools ran, which files were edited, the tokens used and what the turn cost.

  • Nothing to do: it appears after every turn.

tldr

Adds a one-sentence summary under Claude's longer replies, written by Claude Haiku.

  • Nothing to do. TL;DR: long reply in /config sets how long a reply must be (600 characters by default).

previews

Lets you see images Claude reads, right in the conversation.

  • Under each image Claude opens, a line reads 🖼 name.png · hover to preview. Point at it to show the picture, and move away to hide it.

voice

Gives Claude a voice, through murmur. It speaks only when you're away from the session (another app or tab in front), so it never talks over you while you're watching.

  • It says what a long turn did, when Claude needs your permission or has a question, when a background task finishes, how a long task is going every few minutes, and when you're close to a usage limit.
  • /read reads Claude's last reply aloud.
  • /tldr-aloud gives a spoken 2–3 sentence summary of the session so far.
  • A soft chime plays before it speaks, so a voice never starts out of nowhere. Turn it off with Chime in /config.
  • /hush mutes this session; run it again to unmute.
  • Each open session gets its own voice. Pick one fixed voice, or the list sessions choose from, in /config under voice.

ghostty

Ties Claude into the Ghostty terminal. All its settings start with "Ghostty" in /config.

  • Tab title: a 4-word summary of what the session is about, from your last 10 messages and Claude's latest reply, updated every few minutes as it goes. /title gets a new one right away. A resumed session gets its old title back.
  • Tab progress bar: shows while Claude works, fills in as its to-do list gets done, and turns red when a command fails.
  • ❓ waiting on you: a ❓ in the tab title and a desktop notification when Claude needs a permission or an answer. If voice is on, it speaks instead of notifying.
  • /workspace saves and reopens whole windows of sessions: see Workspaces.
  • /goto (or ⌘⌃G) lists your open Claude sessions, the ones waiting on you first, and jumps to the one you pick. /goto next jumps straight to the one waiting longest; /goto <words> jumps to the one whose title matches.
  • /keybind add <keys> <text> makes a Ghostty key type something into Claude, like /keybind add super+ctrl+h /hush for ⌘⌃H. /keybind lists them, and /keybind remove <keys> removes one.
Workspaces

A workspace is a Ghostty window of Claude sessions you can put away and bring back later. It remembers every tab and split: each Claude session with its conversation, and each shell with its folder. Reopening it brings back the whole window, with every conversation picking up where it left off.

CommandWhat it does
/workspace save [name]Saves this window. With no name, it keeps the name the window already had, or Claude Haiku picks one from the tabs.
/workspace close [name]Saves this window, then exits every Claude session in it and closes their tabs. Your current tab stays open at the shell, and shell tabs stay open.
/workspace listShows your saved workspaces, newest first. Press a number or Enter to reopen one, or Tab to ✕ delete to remove it.
/workspace open <name>Reopens a saved workspace in a new window. Part of the name is enough.
  • Reopening puts each tab back in its folder. Claude tabs run claude --resume, so they continue their own conversation, with the tab title they had when you saved. A tab saved without a title gets a new one from its conversation. Shell tabs open at the prompt.
  • Put away a project for the day: /workspace close. Pick it up tomorrow: /workspace list, then press its number.
  • What doesn't come back: programs that were running (a dev server, a test watcher), and the exact split layout. Splits reopen side by side.
  • Where they're kept: ~/.claude/workspaces/, one JSON file per workspace.

Setup

  1. Clone this repo.
  2. List the mods you want in CLAUDE_CODE_PLUGIN_DIRS, separated by :, in the env block of ~/.claude/settings.json:
   "env": {
     "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/context-bar:/path/to/claude-mods/voice"
   }
  1. Start a new Claude session.

To try one mod for a single session: claude --plugin-dir /path/to/claude-mods/tldr.

Requirements

  • ghostty needs Ghostty 1.3 or newer on macOS. Set CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 in the same env block, so Claude Code doesn't overwrite the tab title.
  • voice needs murmur installed, which runs on Apple Silicon Macs.
  • previews needs a terminal that can draw images, like Ghostty or Kitty.
  • session-id's pointing-hand cursor works in Ghostty, Kitty and foot.

The first time a mod controls Ghostty, macOS asks for permission. Allow it.

Development

Editing a mod's files reloads it in running sessions.

claude plugin validate context-bar   # check a mod the way Claude Code will load it
claude plugin test context-bar       # run its tests

IDEAS.md has ideas for more mods.

Source 7 files
hooks/register.tsx 607 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { WorkspaceRow } from '../types'
5
6import {
7  claudeSessions, matchSessions, notify, progress, Progress, runningBar, shortFolder, shouldNotify, tabTitle, title,
8} from './term'
9import type { Session, Todo } from './term'
10import { DEFAULTS, parseAdd, parseBindings, prettyKeys, renderFile, upsert, withInclude } from './keybinds'
11import type { Binding } from './keybinds'
12import { asksPermission, badge, nextWaiting, parseEntry, rank } from './registry'
13import type { Entry, Ranked, Status } from './registry'
14import { ask, bare, clean, context } from './title'
15import {
16  claudePidsFrom, cleanName, closable, conversationOf, fromWindow, namePrompt, openScript, parseWorkspace, sameWorkspace, slug,
17  summary as describe,
18} from './workspace'
19import type { Workspace } from './workspace'
20
21type Config = {
22  tabTitle: boolean
23  titleMinutes: number
24  tabProgress: boolean
25  waitingBadge: boolean
26  waitingNotification: string
27  keybinds: boolean
28}
29
30function config(o: PluginOptions): Config {
31  return {
32    tabTitle: o.tabTitle !== false,
33    titleMinutes: typeof o.titleMinutes === 'number' && o.titleMinutes > 0 ? o.titleMinutes : 3,
34    tabProgress: o.tabProgress !== false,
35    waitingBadge: o.waitingBadge !== false,
36    waitingNotification: o.waitingNotification === 'always' || o.waitingNotification === 'never' ? o.waitingNotification : 'auto',
37    keybinds: o.keybinds !== false,
38  }
39}
40
41// The module's own session state; a reload starts it over.
42let cfg: Config
43let tty = ''
44/** Claude's own process: a session is alive while it is. */
45let pid = ''
46let summary = ''
47let titledAt = 0
48let todos: Todo[] = []
49let hasFailed = false
50let isRunning = false
51let isWaiting = false
52/** Calls the permission check put to you, until they run or are refused. */
53const asking = new Set<string>()
54let home = ''
55/** The sessions the /goto picker shows. */
56let picks: Ranked[] = []
57let status: Status = 'idle'
58let statusSince = 0
59
60const workspaceRows = atom({ plugin: 'ghostty', key: 'workspaces' } as const, [])
61const WORKSPACES = 'claude-workspaces'
62
63const GOTO = 'claude-goto'
64
65// "<tty> <pid>" of Claude itself: the nearest ancestor named claude (the mods' own host process, between it and
66// this shell, owns the tty too). Without one, the nearest ancestor owning a tty.
67const FIND_TTY = `p=$$
68first=
69while [ "$p" -gt 1 ]; do
70  t=$(ps -o tty= -p "$p" | tr -d ' ')
71  case "$t" in ''|'?'|'??') ;; *) [ -z "$first" ] && first="/dev/$t $p"
72    c=$(ps -o comm= -p "$p"); [ "\${c##*/}" = claude ] && { echo "/dev/$t $p"; exit; } ;;
73  esac
74  p=$(ps -o ppid= -p "$p" | tr -d ' ')
75done
76echo "$first"`
77
78async function locate($: EngineInterface) {
79  // HOME first: the tty search exits as soon as it finds one.
80  const found = (await $.process.run(['sh', '-c', `echo "$HOME"\n${FIND_TTY}`])).stdout.trim().split('\n')
81  home = found[0] ?? ''
82  ;[tty = '', pid = ''] = (found[1] ?? '').split(' ')
83}
84
85// Every Ghostty terminal as "id<TAB>tty<TAB>title<TAB>folder"; plus the ttys running Claude.
86const LIST_TERMINALS = `tell application "Ghostty"
87  set out to ""
88  repeat with t in terminals
89    set out to out & (id of t) & (character id 9) & (tty of t) & (character id 9) & (name of t) & (character id 9) & (working directory of t) & linefeed
90  end repeat
91  return out
92end tell`
93// A string pattern with [/]: no backslash for the template literal to eat.
94const CLAUDE_TTYS = `ps -ax -o tty=,comm= | awk 'match($2, "(^|[/])claude$") { print "/dev/" $1 }'`
95const FOCUS = `on run argv
96  tell application "Ghostty" to focus (first terminal whose id is (item 1 of argv))
97end run`
98
99async function listSessions($: EngineInterface): Promise<Session[]> {
100  const [terms, ttys] = await Promise.all([
101    $.process.run(['osascript', '-e', LIST_TERMINALS]),
102    $.process.run(['sh', '-c', CLAUDE_TTYS]),
103  ])
104  // A failing command must say so: an empty list would read as "no other sessions".
105  for (const [what, r] of [['Ghostty', terms], ['ps', ttys]] as const) {
106    if (r.exitCode !== 0) throw new Error(`${what} query failed: ${r.stderr.trim().slice(0, 160) || `exit ${r.exitCode}`}`)
107  }
108  return claudeSessions(terms.stdout, new Set(ttys.stdout.split('\n').filter(Boolean)), tty)
109}
110
111async function goTo($: EngineInterface, s: Session) {
112  await $.process.run(['osascript', '-e', FOCUS, s.id])
113}
114
115// A mod can't print to the screen, so escape sequences go straight to Claude's tty.
116async function send($: EngineInterface, bytes: string) {
117  if (!tty || !bytes) return
118  await $.process.run(['sh', '-c', 'printf "%s" "$1" > "$2"', 'sh', bytes, tty]).catch(() => null)
119}
120
121async function drawTitle($: EngineInterface) {
122  if (cfg.tabTitle || (cfg.waitingBadge && isWaiting) || summary) {
123    await send($, title(tabTitle(summary, cfg.waitingBadge && isWaiting)))
124  }
125}
126
127async function drawBar($: EngineInterface) {
128  if (!cfg.tabProgress) return
129  if (isWaiting) return send($, progress(Progress.paused))
130  await send($, isRunning ? runningBar(todos, hasFailed) : progress(Progress.hide))
131}
132
133/** The voice mod speaks "needs you" itself when it's loaded with that setting on. */
134async function voiceSpeaksIt($: EngineInterface): Promise<boolean> {
135  const rows = await $.config.list().catch(() => [])
136  return rows.some(r => r.key === 'voice.needsYou' && r.value === true)
137}
138
139async function waitOnYou($: EngineInterface, why: string) {
140  isWaiting = true
141  void setStatus($, 'waiting').catch(() => {})
142  await Promise.all([drawTitle($), drawBar($)])
143  if (shouldNotify(cfg.waitingNotification, await voiceSpeaksIt($))) await send($, notify('Claude Code', why))
144}
145
146/**
147 * A call that asks waits in Claude Code's dialog, or in auto mode its checker. Only the dialog marks the session
148 * record waiting, so watch it while the call is pending. (Mods never hear the classic Notification event.)
149 */
150async function watchForDialog($: EngineInterface, id: string, why: string) {
151  asking.add(id)
152  // ponytail: polls every half second, 10 minutes at most; an event for the dialog would replace it.
153  for (let i = 0; i < 1200; i++) {
154    await $.clock.sleep(500)
155    if (!asking.has(id)) return
156    if (asksPermission(await $.fs.read(`${home}/.claude/sessions/${pid}.json`).catch(() => ''))) return waitOnYou($, why)
157  }
158}
159
160async function stopWaiting($: EngineInterface) {
161  if (!isWaiting) return
162  isWaiting = false
163  void setStatus($, isRunning ? 'working' : 'idle').catch(() => {})
164  await Promise.all([drawTitle($), drawBar($)])
165}
166
167/**
168 * A new title from the transcript (so a resumed session has its history), remembered for its conversation.
169 * `answer`: the reply that just ended, which the transcript may not hold yet. '' when there's nothing to summarize.
170 */
171async function retitle($: EngineInterface, answer?: string): Promise<string> {
172  const c = context(await $.session.messages())
173  if (c.prompts.length === 0) return ''
174  const r = await $.model.complete({ model: 'haiku', prompt: ask(c.prompts, answer ?? c.answer), maxTokens: 24 })
175  const words = r.isAnswered ? clean(r.text) : ''
176  if (!words) return ''
177  summary = words
178  await drawTitle($)
179  const file = titleFile(await $.session.id())
180  if (file) await $.process.run(['mkdir', '-p', titleDir()]).then(() => $.fs.write(file, words)).catch(() => {})
181  return words
182}
183
184// Each conversation's title, so resuming it (or reopening its workspace) puts the title back.
185const titleDir = () => `${home}/Library/Caches/claude-ghostty/titles`
186const titleFile = (sessionId?: string) => (home && sessionId && /^[0-9a-f-]{8,64}$/i.test(sessionId) ? `${titleDir()}/${sessionId}` : undefined)
187
188/** The title this conversation had, or a new one when it has history but no title yet. */
189async function restoreTitle($: EngineInterface) {
190  const file = titleFile(await $.session.id())
191  const saved = file ? clean(await $.fs.read(file).catch(() => '')) : ''
192  titledAt = Date.now()
193  if (!saved) return void (await retitle($))
194  summary = saved
195  await drawTitle($)
196}
197
198// ─── Session status (for /goto and /workspace) ─────────────────────────────
199
200const statusDir = () => `${home}/Library/Caches/claude-ghostty/sessions`
201
202/** This session's status file: only this session writes it, so sessions never race. */
203async function setStatus($: EngineInterface, next: Status) {
204  if (!home || !tty) return
205  if (next !== status || !statusSince) statusSince = Date.now()
206  status = next
207  const entry: Entry = { tty, pid, sessionId: await $.session.id(), cwd: await $.session.cwd(), status, since: statusSince }
208  await $.process.run(['mkdir', '-p', statusDir()])
209  await $.fs.write(`${statusDir()}/${tty.replace(/\W/g, '_')}.json`, JSON.stringify(entry))
210}
211
212async function readEntries($: EngineInterface): Promise<Map<string, Entry>> {
213  const files = await $.fs.list(statusDir()).catch(() => [])
214  const entries = await Promise.all(
215    files.filter(f => f.name.endsWith('.json')).map(f => $.fs.read(`${statusDir()}/${f.name}`).then(parseEntry, () => undefined)),
216  )
217  return new Map(entries.filter(e => e !== undefined).map(e => [e.tty, e]))
218}
219
220async function rankedSessions($: EngineInterface): Promise<Ranked[]> {
221  const [sessions, entries] = await Promise.all([listSessions($), readEntries($)])
222  return rank(sessions, entries)
223}
224
225// ─── Workspaces ──────────────────────────────────────────────────────────────
226
227const workspaceDir = () => `${home}/.claude/workspaces`
228
229// The window holding this session, as "tab index<TAB>tty<TAB>folder<TAB>title<TAB>terminal id" lines in order.
230const LIST_WINDOW = `on run argv
231  set myTty to item 1 of argv
232  set sep to character id 9
233  tell application "Ghostty"
234    repeat with w in windows
235      set hit to false
236      repeat with t in terminals of w
237        if tty of t is myTty then set hit to true
238      end repeat
239      if hit then
240        set out to ""
241        repeat with tb in tabs of w
242          repeat with t in terminals of tb
243            set out to out & (index of tb) & sep & (tty of t) & sep & (working directory of t) & sep & (name of t) & sep & (id of t) & linefeed
244          end repeat
245        end repeat
246        return out
247      end if
248    end repeat
249  end tell
250  return ""
251end run`
252
253// Every Claude process with its tty: "/dev/ttys004 36583" lines.
254const CLAUDE_PIDS = `ps -ax -o tty=,pid=,comm= | awk 'match($3, "(^|[/])claude$") { print "/dev/" $1, $2 }'`
255
256/** Claude Code's records of running sessions: process ID → conversation ID. */
257async function conversations($: EngineInterface, pids: Iterable<string>): Promise<Map<string, string>> {
258  const pairs = await Promise.all(
259    [...pids].map(async pid => [pid, conversationOf(await $.fs.read(`${home}/.claude/sessions/${pid}.json`).catch(() => ''))] as const),
260  )
261  return new Map(pairs.filter((p): p is readonly [string, string] => p[1] !== undefined))
262}
263
264type Saved = { text: string; window?: string; claudePids?: Map<string, string> }
265
266/** Every saved workspace, read in full. */
267async function savedWorkspaces($: EngineInterface): Promise<Array<Workspace & { file: string }>> {
268  const files = await $.fs.list(workspaceDir()).catch(() => [])
269  const all = await Promise.all(
270    files.filter(f => f.name.endsWith('.json')).map(async f => {
271      const file = `${workspaceDir()}/${f.name}`
272      const ws = parseWorkspace(await $.fs.read(file).catch(() => ''))
273      return ws ? { ...ws, file } : undefined
274    }),
275  )
276  return all.filter(w => w !== undefined)
277}
278
279async function saveWorkspace($: EngineInterface, given: string): Promise<Saved> {
280  const [window, ps] = await Promise.all([$.process.run(['osascript', '-e', LIST_WINDOW, tty]), $.process.run(['sh', '-c', CLAUDE_PIDS])])
281  if (window.exitCode !== 0) return { text: `Couldn't read this Ghostty window: ${window.stderr.trim().slice(0, 160)}` }
282  const claudePids = claudePidsFrom(ps.stdout)
283  const tabs = fromWindow(window.stdout, claudePids, await conversations($, claudePids.values()))
284  if (tabs.length === 0) return { text: "Couldn't find this session's Ghostty window." }
285  // No name: the saved workspace this window was opened from keeps its name, else Haiku names it.
286  let name = given.trim() || sameWorkspace(tabs, await savedWorkspaces($))?.name || ''
287  if (!name) {
288    const r = await $.model.complete({ model: 'haiku', prompt: namePrompt(tabs), maxTokens: 20, timeoutMs: 8000 })
289    name = (r.isAnswered && cleanName(r.text)) || `Workspace ${new Date().toISOString().slice(0, 10)}`
290  }
291  const ws: Workspace = { name, savedAt: Date.now(), tabs }
292  await $.process.run(['mkdir', '-p', workspaceDir()])
293  const file = `${workspaceDir()}/${slug(name)}.json`
294  const existed = await $.fs.stat(file).then(() => true, () => false)
295  await $.fs.write(file, JSON.stringify(ws, null, 2))
296  const unknown = tabs.flat().filter(p => p.kind === 'claude' && !p.sessionId)
297  const text = `${existed ? 'Updated' : 'Saved'} workspace "${name}": ${describe(ws)}.` +
298    (unknown.length ? ` Couldn't find the conversation for ${unknown.map(p => `"${p.title}"`).join(', ')}: ${unknown.length === 1 ? 'it' : 'they'} will reopen as a new session.` : '')
299  return { text, window: window.stdout, claudePids }
300}
301
302/**
303 * Saves the window, exits every other Claude session in it and closes their panes, then exits this
304 * session too, leaving its tab open at the shell. Shell panes stay open.
305 */
306async function closeWorkspace($: EngineInterface, given: string): Promise<string> {
307  const saved = await saveWorkspace($, given)
308  if (!saved.window || !saved.claudePids) return saved.text
309  const targets = closable(saved.window, saved.claudePids, tty)
310  if (targets.length === 0) return exitThisSession($, `${saved.text} Exiting this session.`)
311  // A polite exit, as quitting from outside does; Claude has written each conversation to disk as it went.
312  await $.process.run(['kill', '-TERM', ...targets.map(t => t.pid)])
313  let alive = targets
314  for (let waited = 0; alive.length && waited < 5000; waited += 250) {
315    await $.clock.sleep(250)
316    const r = await $.process.run(['sh', '-c', 'for p; do kill -0 "$p" 2>/dev/null && echo "$p"; done', 'sh', ...alive.map(t => t.pid)])
317    const running = new Set(r.stdout.split('\n').filter(Boolean))
318    alive = alive.filter(t => running.has(t.pid))
319  }
320  const exited = targets.filter(t => !alive.includes(t))
321  for (const t of exited) await $.process.run(['osascript', '-e', CLOSE_TERMINAL, t.id])
322  return exitThisSession(
323    $,
324    `${saved.text} Closed ${exited.length} other Claude session${exited.length === 1 ? '' : 's'}.` +
325      (alive.length ? ` ${alive.length} didn't exit within 5 seconds and ${alive.length === 1 ? 'is' : 'are'} still open.` : '') +
326      ' Exiting this session.',
327  )
328}
329
330/** Quits this session the way typing /exit does, just after `message` has printed: the tab stays, at the shell. */
331function exitThisSession($: EngineInterface, message: string): string {
332  $.clock.after(300, () => void $.command.run({ command: 'exit', args: '' }).catch(() => {}))
333  return message
334}
335
336const CLOSE_TERMINAL = `on run argv
337  tell application "Ghostty" to close (first terminal whose id is (item 1 of argv))
338end run`
339
340async function loadWorkspaces($: EngineInterface): Promise<WorkspaceRow[]> {
341  const all = await savedWorkspaces($)
342  return all.map(ws => ({ file: ws.file, name: ws.name, summary: describe(ws), savedAt: ws.savedAt })).sort((a, b) => b.savedAt - a.savedAt)
343}
344
345async function openWorkspace($: EngineInterface, file: string): Promise<string> {
346  const ws = parseWorkspace(await $.fs.read(file).catch(() => ''))
347  if (!ws) return "That workspace couldn't be read."
348  // Each Claude tab's saved title, for its session to pick up when it resumes; a title the session saved itself wins.
349  await $.process.run(['mkdir', '-p', titleDir()])
350  for (const p of ws.tabs.flat()) {
351    const file = p.kind === 'claude' ? titleFile(p.sessionId) : undefined
352    if (file && bare(p.title) && !(await $.fs.stat(file).then(() => true, () => false))) await $.fs.write(file, clean(bare(p.title)))
353  }
354  const r = await $.process.run(['osascript', '-e', openScript(ws)])
355  return r.exitCode === 0 ? `Opened "${ws.name}": ${describe(ws)}.` : `Ghostty couldn't open it: ${r.stderr.trim().slice(0, 160)}`
356}
357
358async function deleteWorkspace($: EngineInterface, file: string) {
359  await $.process.run(['rm', '-f', file])
360  await update($, workspaceRows, rows => rows.filter(r => r.file !== file))
361}
362
363// ─── Keybinds ────────────────────────────────────────────────────────────────
364
365// Where Ghostty reads its config: ~/.config/ghostty when that's in use, else the macOS app folder.
366async function configDir($: EngineInterface): Promise<string> {
367  const xdg = `${home}/.config/ghostty`
368  const app = `${home}/Library/Application Support/com.mitchellh.ghostty`
369  const r = await $.process.run(['sh', '-c', '[ -f "$1/config" ] && echo "$1" || echo "$2"', 'sh', xdg, app])
370  return r.stdout.trim() || app
371}
372
373async function readBindings($: EngineInterface): Promise<{ dir: string; file: string; text: string | null }> {
374  const dir = await configDir($)
375  const file = `${dir}/claude-keybinds`
376  const text = await $.fs.read(file).catch(() => null)
377  return { dir, file, text }
378}
379
380/** Writes the bindings, makes the main config load them, and lets Ghostty check and reload. Undoes a rejected change. */
381async function saveBindings($: EngineInterface, bindings: readonly Binding[]): Promise<string | undefined> {
382  const { dir, file, text } = await readBindings($)
383  const previous = text ?? ''
384  await $.process.run(['mkdir', '-p', dir])
385  await $.fs.write(file, renderFile(previous, bindings))
386  const main = `${dir}/config`
387  const config = await $.fs.read(main).catch(() => '')
388  if (withInclude(config) !== config) await $.fs.write(main, withInclude(config))
389  const check = await $.process.run(['sh', '-c', '"$GHOSTTY_BIN_DIR/ghostty" +validate-config 2>&1'])
390  if (check.exitCode !== 0) {
391    await $.fs.write(file, previous)
392    return check.stdout.trim().split('\n')[0] || 'Ghostty rejected the change'
393  }
394  await $.process.run(['osascript', '-e', 'tell application "Ghostty" to perform action "reload_config" on (first terminal)'])
395  return undefined
396}
397
398function listing(bindings: readonly Binding[]): string {
399  if (bindings.length === 0) return 'No Claude keybinds yet. /keybind add <keys> <text>, e.g. /keybind add super+ctrl+h /hush'
400  return bindings.map(b => `${prettyKeys(b.keys).padEnd(6)} ${b.text}`).join('\n')
401}
402
403export const register: Register = (on, options) => {
404  cfg = config(options)
405  // Hooks that gate a prompt or a tool call pass it on untouched if they fail: the tab must never block work.
406
407  on('session.start', async ($, e, next) => {
408    await locate($)
409    void setStatus($, 'idle').catch(() => {})
410    if (cfg.tabTitle) void restoreTitle($).catch(() => {})
411    await $.command.register({ name: 'title', description: 'A new 4-word tab title for this session, now' })
412    await $.command.register({ name: 'keybind', description: 'Ghostty keys that type into Claude: /keybind, /keybind add <keys> <text>, /keybind remove <keys>' })
413    // First start: install the default keys once (a later /keybind remove sticks, since the file then exists).
414    if (cfg.keybinds && (await readBindings($).catch(() => null))?.text === null) void saveBindings($, DEFAULTS).catch(() => {})
415    await $.command.register({ name: 'goto', description: 'Jump to another Claude session in Ghostty (waiting ones first): /goto, /goto next, or /goto <number or words>' })
416    await $.command.register({ name: 'workspace', description: 'Save this Ghostty window of sessions, or reopen one: /workspace save [name], /workspace close [name] (save, then exit the other sessions), /workspace list, /workspace open <name>' })
417    return next(e)
418  })
419
420  // Claude exits: clear the bar.
421  on('session.end', async ($, e, next) => {
422    if (cfg.tabProgress) await send($, progress(Progress.hide))
423    return next(e)
424  })
425
426  on('command.run', { command: 'title' }, async $ => {
427    titledAt = Date.now()
428    const words = await retitle($).catch(() => '')
429    return { text: words ? `Tab title: ${words}` : 'Nothing to summarize yet: send a message first.' }
430  })
431
432  on('command.run', { command: 'goto' }, async ($, e) => {
433    const ranked = await rankedSessions($).catch((err: Error) => err)
434    if (ranked instanceof Error) return { text: `/goto couldn't list sessions: ${ranked.message}` }
435    const others = ranked.filter(s => !s.isMe)
436    if (others.length === 0) return { text: 'No other Claude sessions are running in Ghostty.' }
437    const query = e.args.trim()
438    if (query === 'next') {
439      const waiting = nextWaiting(ranked)
440      if (!waiting) return { text: 'No session is waiting on you.' }
441      await goTo($, waiting)
442      return { text: `→ ${waiting.title} (${badge(waiting, Date.now())})` }
443    }
444    if (query) {
445      const hits = matchSessions(ranked, query).filter(s => !s.isMe)
446      if (hits.length === 1) {
447        await goTo($, hits[0]!)
448        return { text: `→ ${hits[0]!.title}` }
449      }
450      if (hits.length === 0) return { text: `No Claude session matches "${query}". /goto lists them.` }
451    }
452    picks = ranked
453    await $.ui.open({ id: GOTO, title: 'Claude sessions', focus: true, closeOnEscape: true, rows: ranked.length + 1 })
454    const waiting = others.filter(s => s.status === 'waiting').length
455    return { text: `${others.length} other Claude session${others.length === 1 ? '' : 's'}${waiting ? `, ${waiting} waiting on you` : ''}: pick one above.` }
456  })
457
458  on('command.run', { command: 'workspace' }, async ($, e) => {
459    const [verb = 'list', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
460    const arg = rest.join(' ')
461    if (verb === 'save') return { text: (await saveWorkspace($, arg)).text }
462    if (verb === 'close') return { text: await closeWorkspace($, arg) }
463    const rows = await loadWorkspaces($)
464    if (verb === 'open' && arg) {
465      const hit = rows.find(r => r.name.toLowerCase() === arg.toLowerCase()) ?? rows.find(r => r.name.toLowerCase().includes(arg.toLowerCase()))
466      return { text: hit ? await openWorkspace($, hit.file) : `No workspace called "${arg}". /workspace list shows them.` }
467    }
468    if (verb !== 'list' && verb !== 'open') return { text: 'Usage: /workspace save [name], /workspace close [name], /workspace list, /workspace open <name>' }
469    if (rows.length === 0) return { text: 'No saved workspaces yet. /workspace save remembers this window.' }
470    await update($, workspaceRows, () => rows)
471    await $.ui.open({ id: WORKSPACES, title: 'Workspaces', focus: true, closeOnEscape: true, rows: rows.length + 1 })
472    return { text: `${rows.length} saved workspace${rows.length === 1 ? '' : 's'}: pick one above.` }
473  })
474
475  on('command.run', { command: 'keybind' }, async ($, e) => {
476    const [verb, ...rest] = e.args.trim().split(/\s+/)
477    const current = parseBindings((await readBindings($)).text ?? '')
478    if (!verb || verb === 'list') return { text: listing(current) }
479    if (verb === 'add') {
480      const b = parseAdd(rest.join(' '))
481      if (typeof b === 'string') return { text: b }
482      const error = await saveBindings($, upsert(current, b))
483      return { text: error ? `Ghostty didn't accept that: ${error}` : `${prettyKeys(b.keys)} now types ${b.text} ⏎` }
484    }
485    if (verb === 'remove') {
486      const keys = (rest[0] ?? '').toLowerCase()
487      if (!current.some(b => b.keys === keys)) return { text: `No Claude keybind on ${keys || '(no keys given)'}.\n${listing(current)}` }
488      const error = await saveBindings($, current.filter(b => b.keys !== keys))
489      return { text: error ? `Ghostty didn't accept that: ${error}` : `Removed ${prettyKeys(keys)}.` }
490    }
491    return { text: 'Usage: /keybind, /keybind add <keys> <text>, /keybind remove <keys>' }
492  })
493
494  on('ui.render', { component: 'Pane', requestId: GOTO }, async ($, e) => {
495    const { Box, Button, Text } = $.ui.resolve(e)
496    const now = Date.now()
497    const label = (s: Ranked) => `${badge(s, now).padEnd(6)} ${s.title} · ${shortFolder(s.folder, home)}`
498    const first = picks.findIndex(p => !p.isMe)
499    return (
500      <Box flexDirection="column">
501        {picks.map((s, i) =>
502          s.isMe ? (
503            <Text dimColor>
504              {'   '}
505              {label(s)} (this session)
506            </Text>
507          ) : (
508            <Button
509              key={`goto-${i}`}
510              plain
511              hotkey={i < 9 ? String(i + 1) : undefined}
512              label={label(s)}
513              autoFocus={first === i ? true : undefined}
514              onPress={async () => {
515                await $.ui.close({ id: GOTO })
516                await goTo($, s)
517              }}
518            />
519          ),
520        )}
521        <Text dimColor>❓ waiting on you · ⏳ working · number or Enter to jump · Esc to close</Text>
522      </Box>
523    )
524  })
525
526  on('ui.render', { component: 'Pane', requestId: WORKSPACES }, async ($, e) => {
527    const { Box, Button, Text } = $.ui.resolve(e)
528    const rows = await read($, workspaceRows)
529    if (rows.length === 0) return <Text dimColor>No saved workspaces. /workspace save remembers this window.</Text>
530    return (
531      <Box flexDirection="column">
532        {rows.map((r, i) => (
533          <Box flexDirection="row">
534            <Button
535              key={`ws-open-${i}`}
536              plain
537              hotkey={i < 9 ? String(i + 1) : undefined}
538              label={`${r.name} · ${r.summary} · ${new Date(r.savedAt).toLocaleDateString()}`}
539              autoFocus={i === 0 ? true : undefined}
540              onPress={async () => {
541                await $.ui.close({ id: WORKSPACES })
542                const text = await openWorkspace($, r.file)
543                $.ui.toast(text)
544              }}
545            />
546            <Text> </Text>
547            <Button key={`ws-del-${i}`} plain dimColor label="✕ delete" onPress={() => deleteWorkspace($, r.file)} />
548          </Box>
549        ))}
550        <Text dimColor>number or Enter to open · Tab to ✕ delete · Esc to close</Text>
551      </Box>
552    )
553  })
554
555  on('prompt.submit', async ($, e, next) => {
556    todos = []
557    hasFailed = false
558    isRunning = true
559    isWaiting = false
560    void Promise.all([drawTitle($), drawBar($), setStatus($, 'working').catch(() => {})])
561    return next(e)
562  }).catch(($, e, next) => next(e))
563
564  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
565    todos = e.todos.map(t => ({ status: t.status }))
566    void drawBar($)
567    return next(e)
568  }).catch(($, e, next) => next(e))
569
570  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
571    void waitOnYou($, 'Claude has a question for you')
572    return next(e)
573  }).catch(($, e, next) => next(e))
574
575  // Every tool call: a permission prompt or a question waits inside next(); its return means you answered.
576  on('tool.call', async ($, e, next) => {
577    const ran = await next(e)
578    asking.delete((e as { tool_use_id?: string }).tool_use_id ?? '')
579    const failed = ran.deny === undefined && ran.isError === true
580    if (failed !== hasFailed) {
581      hasFailed = failed
582      void drawBar($)
583    }
584    void stopWaiting($)
585    return ran
586  }).catch(($, e, next) => next(e))
587
588  on('tool.check', async ($, e, next) => {
589    const r = await next(e)
590    if (r.decision === 'ask' && e.tool_use_id) void watchForDialog($, e.tool_use_id, `Claude needs your permission to use ${e.tool}`).catch(() => {})
591    return r
592  }).catch(($, e, next) => next(e))
593
594  on('turn.complete', async ($, e, next) => {
595    if (e.agentId) return next(e)
596    isRunning = false
597    isWaiting = false
598    void Promise.all([drawTitle($), drawBar($), setStatus($, 'idle').catch(() => {})])
599    const now = Date.now()
600    if (cfg.tabTitle && now - titledAt >= cfg.titleMinutes * 60_000) {
601      titledAt = now
602      void retitle($, e.answer).catch(() => {})
603    }
604    return next(e)
605  })
606}
607
hooks/term.ts 83 lines
1// Ghostty's escape sequences, and the small decisions around them. Pure: register.ts writes the bytes.
2
3const ESC = '\x1b'
4const BEL = '\x07'
5
6/** Strips control characters, so text from the model or a tool can't smuggle in escape sequences. */
7export function safe(text: string): string {
8  return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim()
9}
10
11/** OSC 2: the tab title. */
12export const title = (text: string) => `${ESC}]2;${safe(text)}${BEL}`
13
14/** OSC 9;4 states, as Ghostty draws them on the tab. */
15export const Progress = { hide: 0, value: 1, error: 2, busy: 3, paused: 4 } as const
16
17/** OSC 9;4: the tab's progress bar. `percent` matters for `value` (and colors `error`/`paused` when given). */
18export function progress(state: number, percent?: number): string {
19  const p = percent === undefined ? '' : `;${Math.max(0, Math.min(100, Math.round(percent)))}`
20  return `${ESC}]9;4;${state}${p}${BEL}`
21}
22
23/** OSC 777: a desktop notification. `;` separates fields there, so it can't appear inside one. */
24export function notify(heading: string, body: string): string {
25  const field = (s: string) => safe(s).replace(/;/g, ',')
26  return `${ESC}]777;notify;${field(heading)};${field(body)}${BEL}`
27}
28
29/** The tab title: the session's summary, with ❓ in front while Claude waits on you. */
30export function tabTitle(summary: string, isWaiting: boolean): string {
31  return `${isWaiting ? '❓' : '✳'} ${summary || 'Claude Code'}`
32}
33
34export type Todo = { status: 'pending' | 'in_progress' | 'completed' }
35
36/** How far along Claude's todo list is, or undefined without one (an indeterminate bar then). */
37export function percentDone(todos: readonly Todo[]): number | undefined {
38  if (todos.length === 0) return undefined
39  return (todos.filter(t => t.status === 'completed').length / todos.length) * 100
40}
41
42/** The bar for a running turn: red after a failed tool call, else the todo list's progress, else busy. */
43export function runningBar(todos: readonly Todo[], hasFailed: boolean): string {
44  const pct = percentDone(todos)
45  if (hasFailed) return progress(Progress.error, pct ?? 100)
46  return pct === undefined ? progress(Progress.busy) : progress(Progress.value, pct)
47}
48
49/** Whether to post a notification when Claude waits on you: never twice, and not when the voice mod will say it. */
50export function shouldNotify(setting: string, voiceSpeaksIt: boolean): boolean {
51  return setting === 'always' || (setting === 'auto' && !voiceSpeaksIt)
52}
53
54// ─── /goto ───────────────────────────────────────────────────────────────────
55
56export type Session = { id: string; tty: string; title: string; folder: string; isMe: boolean }
57
58/**
59 * Ghostty's terminals ("id\ttty\ttitle\tfolder" lines) that run Claude (by tty, from `ps`).
60 * Splits of one tab running Claude are separate sessions; a tab without Claude isn't one.
61 */
62export function claudeSessions(terminals: string, claudeTtys: ReadonlySet<string>, myTty: string): Session[] {
63  return terminals
64    .split('\n')
65    .map(line => line.split('\t'))
66    .filter(f => f.length >= 4 && claudeTtys.has(f[1]!))
67    .map(([id, tty, title, folder]) => ({ id: id!, tty: tty!, title: safe(title!), folder: folder!, isMe: tty === myTty }))
68}
69
70/** The sessions `/goto <query>` means: a number from the list, or text in the title or folder. */
71export function matchSessions(sessions: readonly Session[], query: string): Session[] {
72  const q = query.trim().toLowerCase()
73  if (!q) return []
74  const n = Number(q)
75  if (Number.isInteger(n) && n >= 1 && n <= sessions.length) return [sessions[n - 1]!]
76  return sessions.filter(s => s.title.toLowerCase().includes(q) || s.folder.toLowerCase().includes(q))
77}
78
79/** "~/work/brilliant" for display. */
80export function shortFolder(folder: string, home: string): string {
81  return home && folder.startsWith(home) ? `~${folder.slice(home.length)}` : folder
82}
83
hooks/keybinds.ts 63 lines
1// Ghostty keybinds that type into Claude, kept in their own file the main Ghostty config includes.
2
3export type Binding = { keys: string; text: string }
4
5export const HEADER = [
6  '# Ghostty keys that type into Claude Code, managed with /keybind (the ghostty Claude mod).',
7  '# Loaded by `config-file = ?claude-keybinds` in your Ghostty config. Edits by hand are kept.',
8]
9
10/** The line that makes the main config load this file; `?` means a missing file is fine. */
11export const INCLUDE = 'config-file = ?claude-keybinds'
12
13/** What every new install starts with. */
14export const DEFAULTS: Binding[] = [{ keys: 'super+ctrl+g', text: '/goto' }]
15
16/** Ghostty's `text:` action takes a Zig string literal: backslashes doubled, then Enter as \r. */
17export function bindingLine(b: Binding): string {
18  return `keybind = ${b.keys}=text:${b.text.replace(/\\/g, '\\\\')}\\r`
19}
20
21/** The bindings a keybinds file holds (only `text:` ones ending in Enter are this mod's shape). */
22export function parseBindings(file: string): Binding[] {
23  const out: Binding[] = []
24  for (const line of file.split('\n')) {
25    const m = /^\s*keybind\s*=\s*([^=\s]+)=text:(.*)\\r\s*$/.exec(line)
26    if (m) out.push({ keys: m[1]!, text: m[2]!.replace(/\\\\/g, '\\') })
27  }
28  return out
29}
30
31/** The whole file for `bindings`, keeping any other lines you wrote by hand. */
32export function renderFile(previous: string, bindings: readonly Binding[]): string {
33  const mine = previous.split('\n').filter(l => l.trim() && !HEADER.includes(l) && !/^\s*keybind\s*=\s*[^=\s]+=text:.*\\r\s*$/.test(l))
34  return [...HEADER, ...mine, ...bindings.map(bindingLine)].join('\n') + '\n'
35}
36
37/** Adds or replaces the binding for `keys`. */
38export function upsert(bindings: readonly Binding[], b: Binding): Binding[] {
39  return [...bindings.filter(x => x.keys !== b.keys), b]
40}
41
42/** `super+ctrl+g` → `⌘⌃G`, for display. */
43export function prettyKeys(keys: string): string {
44  const sym: Record<string, string> = { super: '⌘', cmd: '⌘', ctrl: '⌃', alt: '⌥', opt: '⌥', shift: '⇧' }
45  return keys.split('+').map(k => sym[k] ?? k.toUpperCase()).join('')
46}
47
48/** `/keybind add <keys> <text…>` → a binding, or why not. */
49export function parseAdd(args: string): Binding | string {
50  const [keys, ...rest] = args.trim().split(/\s+/)
51  const text = rest.join(' ')
52  if (!keys || !text) return 'Usage: /keybind add <keys> <text>, e.g. /keybind add super+ctrl+h /hush'
53  if (!/^[a-z0-9_+.,;'\[\]\/`-]+$/i.test(keys)) return `"${keys}" isn't a Ghostty key combination (like super+ctrl+g)`
54  if (/[\u0000-\u001f\u007f]/.test(text)) return 'The text can\'t contain control characters'
55  return { keys: keys.toLowerCase(), text }
56}
57
58/** The main Ghostty config with the include line added once. */
59export function withInclude(config: string): string {
60  if (config.split('\n').some(l => l.trim() === INCLUDE)) return config
61  return `${config.replace(/\n*$/, '\n')}\n# Claude Code keybinds (/keybind)\n${INCLUDE}\n`
62}
63
hooks/registry.ts 64 lines
1// Each Claude session's status in its own small file, so /goto can rank sessions and /workspace can resume them.
2
3import type { Session } from './term'
4
5export type Status = 'idle' | 'working' | 'waiting'
6
7/** One session's file: `<dir>/<tty name>.json`, written only by that session. */
8export type Entry = { tty: string; pid: string; sessionId: string; cwd: string; status: Status; since: number }
9
10export function parseEntry(text: string): Entry | undefined {
11  try {
12    const e = JSON.parse(text) as Partial<Entry>
13    return typeof e.tty === 'string' && typeof e.status === 'string' ? (e as Entry) : undefined
14  } catch {
15    return undefined
16  }
17}
18
19/**
20 * Claude Code's own record of a running session (~/.claude/sessions/<pid>.json) says a permission dialog is up.
21 * Only the dialog sets it: auto mode's checker approves a call that asks without one.
22 */
23export function asksPermission(record: string): boolean {
24  try {
25    const r = JSON.parse(record) as { status?: unknown; waitingFor?: unknown }
26    return r.status === 'waiting' && typeof r.waitingFor === 'string' && /permission/i.test(r.waitingFor)
27  } catch {
28    return false
29  }
30}
31
32export type Ranked = Session & { status: Status; since?: number }
33
34const ORDER: Record<Status, number> = { waiting: 0, working: 1, idle: 2 }
35
36/** Waiting on you first (longest wait first), then working, then idle; this session last. */
37export function rank(sessions: readonly Session[], entries: ReadonlyMap<string, Entry>): Ranked[] {
38  return sessions
39    .map(s => {
40      const e = entries.get(s.tty)
41      return { ...s, status: e?.status ?? 'idle', since: e?.since }
42    })
43    .sort((a, b) => Number(a.isMe) - Number(b.isMe) || ORDER[a.status] - ORDER[b.status] || (a.since ?? 0) - (b.since ?? 0))
44}
45
46/** "45s", "4m", "1h 5m". */
47export function ago(ms: number): string {
48  const s = Math.max(0, Math.round(ms / 1000))
49  if (s < 60) return `${s}s`
50  const m = Math.floor(s / 60)
51  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
52}
53
54/** The picker row's lead: ❓ and the wait, ⏳ while working, nothing when idle. */
55export function badge(r: Ranked, now: number): string {
56  if (r.status === 'waiting') return `❓ ${r.since ? ago(now - r.since) : ''}`.trim()
57  return r.status === 'working' ? '⏳' : ''
58}
59
60/** The other session waiting on you longest, for `/goto next`. */
61export function nextWaiting(ranked: readonly Ranked[]): Ranked | undefined {
62  return ranked.find(r => !r.isMe && r.status === 'waiting')
63}
64
hooks/title.ts 38 lines
1// The model's reply as a safe tab title: no control characters (no escape-sequence injection), 4 words, 48 chars.
2export function clean(reply: string): string {
3  return reply
4    .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
5    .replace(/["'`*_#.]/g, '')
6    .split(/\s+/)
7    .filter(Boolean)
8    .slice(0, 4)
9    .join(' ')
10    .slice(0, 48)
11}
12
13/** Your last 10 requests and Claude's latest reply, from the transcript; markup-only messages (commands, notices) are skipped. */
14export function context(messages: ReadonlyArray<{ role: string; text: string }>): { prompts: string[]; answer: string } {
15  const said = messages.filter(m => m.text.trim() && !m.text.trimStart().startsWith('<'))
16  return {
17    prompts: said.filter(m => m.role === 'user').map(m => m.text).slice(-10),
18    answer: [...said].reverse().find(m => m.role === 'assistant')?.text ?? '',
19  }
20}
21
22export function ask(prompts: readonly string[], answer: string): string {
23  return [
24    'Recent requests in a coding session, oldest first:',
25    ...prompts.map(p => `- ${p.slice(0, 300)}`),
26    '',
27    `Latest reply (start): ${answer.slice(0, 600)}`,
28    '',
29    'Summarize what this session is about in exactly 4 words, Title Case. Reply with the 4 words only.',
30  ].join('\n')
31}
32
33/** A tab's title without the ✳ or ❓ this mod puts in front; '' for the default "Claude Code". */
34export function bare(tabName: string): string {
35  const words = tabName.replace(/^[✳❓]️?\s*/u, '').trim()
36  return words === 'Claude Code' ? '' : words
37}
38
hooks/workspace.ts 159 lines
1// Workspaces: a Ghostty window's tabs of Claude sessions and shells, saved and reopened.
2
3import { safe } from './term'
4import { bare } from './title'
5
6/** A Claude tab resumes its conversation (or the folder's latest, without a known ID); a shell just opens there. */
7export type Pane = { kind: 'claude'; cwd: string; title: string; sessionId?: string } | { kind: 'shell'; cwd: string; title: string }
8
9/** Each tab's panes in order: the first is the tab, the rest its splits. */
10export type Workspace = { name: string; savedAt: number; tabs: Pane[][] }
11
12/**
13 * The window's terminals ("tab index<TAB>tty<TAB>folder<TAB>title" lines, in order) as tabs of panes.
14 * A pane is Claude when Claude runs on its tty (`claudePids`: tty → Claude's process ID); its conversation
15 * is the one Claude Code records for that process (`conversations`: pid → session ID).
16 */
17export function fromWindow(
18  lines: string,
19  claudePids: ReadonlyMap<string, string>,
20  conversations: ReadonlyMap<string, string>,
21): Pane[][] {
22  const tabs = new Map<string, Pane[]>()
23  for (const line of lines.split('\n')) {
24    const [tab, tty, cwd, title] = line.split('\t')
25    if (!tab || !tty || cwd === undefined) continue
26    const name = bare(safe(title ?? ''))
27    const pid = claudePids.get(tty)
28    const pane: Pane = pid
29      ? { kind: 'claude', cwd, title: name, sessionId: conversations.get(pid) }
30      : { kind: 'shell', cwd, title: name }
31    tabs.set(tab, [...(tabs.get(tab) ?? []), pane])
32  }
33  return [...tabs.values()]
34}
35
36/** `ps` lines ("/dev/ttys004 36583") → tty → Claude's process ID. */
37export function claudePidsFrom(ps: string): Map<string, string> {
38  return new Map(ps.split('\n').map(l => l.trim().split(/\s+/)).filter(f => f.length === 2).map(([tty, pid]) => [tty!, pid!]))
39}
40
41/** Claude Code's own record of a running session (~/.claude/sessions/<pid>.json) → its conversation ID. */
42export function conversationOf(record: string): string | undefined {
43  try {
44    const id = (JSON.parse(record) as { sessionId?: unknown }).sessionId
45    return typeof id === 'string' ? id : undefined
46  } catch {
47    return undefined
48  }
49}
50
51/** The conversations a workspace resumes. */
52export function conversationsIn(ws: Pick<Workspace, 'tabs'>): Set<string> {
53  return new Set(ws.tabs.flat().flatMap(p => (p.kind === 'claude' && p.sessionId ? [p.sessionId] : [])))
54}
55
56/**
57 * The saved workspace this window is (at least half the same conversations), so saving it again,
58 * or closing it, updates that one instead of making a near-duplicate under a new name.
59 */
60export function sameWorkspace<T extends Pick<Workspace, 'tabs'>>(tabs: Pane[][], saved: readonly T[]): T | undefined {
61  const mine = conversationsIn({ tabs })
62  if (mine.size === 0) return undefined
63  let best: T | undefined
64  let bestShare = 0.5
65  for (const ws of saved) {
66    const theirs = conversationsIn(ws)
67    const shared = [...mine].filter(id => theirs.has(id)).length
68    const share = shared / Math.max(mine.size, theirs.size)
69    if (share >= bestShare) [best, bestShare] = [ws, share]
70  }
71  return best
72}
73
74/** The terminals `/workspace close` exits and closes: every Claude pane but this session's own. */
75export function closable(lines: string, claudePids: ReadonlyMap<string, string>, myTty: string): Array<{ id: string; tty: string; pid: string }> {
76  return lines
77    .split('\n')
78    .map(l => l.split('\t'))
79    .filter(f => f.length >= 5 && f[1] !== myTty && claudePids.has(f[1]!))
80    .map(f => ({ id: f[4]!, tty: f[1]!, pid: claudePids.get(f[1]!)! }))
81}
82
83/** "Brilliant Admin Work" → "brilliant-admin-work": the file name. */
84export function slug(name: string): string {
85  return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'workspace'
86}
87
88export function namePrompt(tabs: readonly Pane[][]): string {
89  const rows = tabs.flat().map(p => `- ${p.title || '(untitled)'} in ${p.cwd}`)
90  return [
91    'These terminal tabs are being saved as a workspace. Name it in 2 to 4 words, Title Case,',
92    'after the work they share (a project or a task). Reply with the name only.',
93    '',
94    ...rows.slice(0, 30),
95  ].join('\n')
96}
97
98/** The model's reply as a name; empty when unusable. */
99export function cleanName(reply: string): string {
100  const name = safe(reply).replace(/["'`*_#.:]/g, '').trim()
101  return name.length >= 2 && name.length <= 48 ? name : ''
102}
103
104/** An AppleScript string literal. */
105const str = (s: string) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
106
107/** The command a pane starts with: resume its conversation, or a new session when it isn't known. */
108export function startCommand(p: Pane): string | undefined {
109  if (p.kind === 'shell') return undefined
110  return p.sessionId && /^[0-9a-f-]{8,64}$/i.test(p.sessionId) ? `claude --resume ${p.sessionId}` : 'claude'
111}
112
113/**
114 * AppleScript that recreates the workspace in a new window. Commands are typed into each pane's shell
115 * (initial input), so they run with your normal PATH and environment.
116 */
117export function openScript(ws: Workspace): string {
118  const out = ['tell application "Ghostty"']
119  let n = 0
120  const config = (p: Pane) => {
121    const c = `c${++n}`
122    out.push(`  set ${c} to new surface configuration`)
123    out.push(`  set initial working directory of ${c} to ${str(p.cwd)}`)
124    const cmd = startCommand(p)
125    if (cmd) out.push(`  set initial input of ${c} to ${str(cmd)} & linefeed`)
126    return c
127  }
128  ws.tabs.forEach((panes, i) => {
129    const [first, ...splits] = panes
130    if (!first) return
131    const c = config(first)
132    if (i === 0) {
133      out.push(`  set w to new window with configuration ${c}`)
134      out.push('  set t to focused terminal of selected tab of w')
135    } else {
136      out.push(`  set tb to new tab in w with configuration ${c}`)
137      out.push('  set t to focused terminal of tb')
138    }
139    for (const p of splits) out.push(`  set t to split t direction right with configuration ${config(p)}`)
140  })
141  out.push('  activate window w', 'end tell')
142  return out.join('\n')
143}
144
145/** "3 tabs · 2 Claude" for the picker. */
146export function summary(ws: Workspace): string {
147  const claude = ws.tabs.flat().filter(p => p.kind === 'claude').length
148  return `${ws.tabs.length} tab${ws.tabs.length === 1 ? '' : 's'} · ${claude} Claude`
149}
150
151export function parseWorkspace(text: string): Workspace | undefined {
152  try {
153    const w = JSON.parse(text) as Workspace
154    return typeof w.name === 'string' && Array.isArray(w.tabs) ? w : undefined
155  } catch {
156    return undefined
157  }
158}
159
types/index.d.ts 9 lines
1/** One saved workspace as the /workspace picker lists it. */
2export type WorkspaceRow = { file: string; name: string; summary: string; savedAt: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    ghostty: { workspaces: WorkspaceRow[] }
7  }
8}
9