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…

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.
Shows how full Claude's context window is, as a colored bar above the prompt.
/context-bar hides or shows it for this session.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%.
Shows this session's ID in the bottom-right corner of the screen.
claude --resume <id> command.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.
Adds a one-sentence summary under Claude's longer replies, written by Claude Haiku.
/config sets how long a reply must be (600 characters by default).Lets you see images Claude reads, right in the conversation.
🖼 name.png · hover to preview. Point at it to show the picture, and move away to hide it.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.
/read reads Claude's last reply aloud./tldr-aloud gives a spoken 2–3 sentence summary of the session so far./config./hush mutes this session; run it again to unmute./config under voice.Ties Claude into the Ghostty terminal. All its settings start with "Ghostty" in /config.
/title gets a new one right away. A resumed session gets its old title back./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.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.
| Command | What 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 list | Shows 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. |
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./workspace close. Pick it up tomorrow: /workspace list, then press its number.~/.claude/workspaces/, one JSON file per workspace.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"
}
To try one mod for a single session: claude --plugin-dir /path/to/claude-mods/tldr.
CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 in the same env block, so Claude Code doesn't overwrite the tab title.The first time a mod controls Ghostty, macOS asks for permission. Allow it.
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.
hooks/register.tsx 607 lines1import { 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}
607hooks/term.ts 83 lines1// 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}
83hooks/keybinds.ts 63 lines1// 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}
63hooks/registry.ts 64 lines1// 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}
64hooks/title.ts 38 lines1// 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}
38hooks/workspace.ts 159 lines1// 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}
159types/index.d.ts 9 lines1/** 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