Pinned projects as horizontal tabs in Claude Desktop's Code tab (macOS): project icons, live session status, one click to a project's last session. Reads…

English · Русский

A mod for the Code tab of Claude Desktop (macOS) that turns your projects into horizontal tabs above the prompt, like browser tabs:
⋯ button or /tabs): keyboard shortcuts, pin and unpin projects, reorder them, pick icons (file picker, emoji, or automatic), refresh icons.It is built on Claude Code's mods (plugins of function hooks), so it does not patch the Claude app: updates of Claude don't break it, and removing the plugin removes it.
<img src="docs/screenshots/hover.png" alt="Hovering a tab shows its recent sessions with status dots" width="64%"> <img src="docs/screenshots/pane.png" alt="The settings pane: shortcuts, pinned projects, icons" width="34%">
<sub>Demo projects. The tabs, icons, counts and dots are drawn by the mod's own code; docs/demo/render.ts renders these pictures.</sub>
xcode-select --install), to build the small helper that listens for the keys.claude, where it draws a simpler text version of the tabs.claude plugin marketplace add tripolskiydigital/claude-tabs
claude plugin install project-tabs@claude-tabs
Then open a new session in Claude Desktop (or quit Claude with ⌘Q and open it again for sessions that were already open). To update later:
claude plugin update project-tabs@claude-tabs
git clone https://github.com/tripolskiydigital/claude-tabs.git ~/claude-tabs
Then add the folder to env in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-tabs"
}
}
Use one way or the other, not both: with both, the mod loads twice.
⋯ and pin projects from the list. The list shows every folder that has had a Code session in Claude Desktop.The icon is found automatically when the project has one: favicon.svg/png/ico, icon.svg/png, apple-touch-icon.png, in the project root, public/, app/, static/ or assets/, up to four folders deep (monorepos included). To set your own without the pane, put .claude/icon.svg or .claude/icon.png in the project.
Shortcuts are off until you switch them on: in the pane, Keyboard shortcuts → ⌃1–9 or ⌘1–9. The tabs then show their number, and ⌃1 opens the first pinned project's last session, ⌃2 the second, up to 9.
A mod can't bind keys in Claude Desktop itself, so switching shortcuts on builds a tiny helper from helper/tabs-hotkeys.swift (about 100 lines, in this repository) with swiftc, and has launchd run it with launchctl submit as com.github.tripolskiydigital.claude-tabs.hotkeys. No launchd file is written: the job lasts until you log out, and the mod submits it again at the next session start while shortcuts are on. The helper holds the keys only while Claude Desktop is the frontmost app; in any other app ⌃1…⌃9 do what they always did. It needs no Accessibility or other permissions and makes no network requests. Off stops it with launchctl remove.
⌘1–⌘3 are Claude Desktop's own shortcuts (new chat, task, code session); the ⌘ variant takes them over while it is on. If ⌃1…⌃9 switch desktops on your Mac (System Settings → Keyboard → Keyboard Shortcuts → Mission Control), use ⌘ or turn those off.
Everything stays on your Mac; the mod makes no network requests.
| What | Where it comes from |
|---|---|
| Projects and their sessions | ~/Library/Application Support/Claude/claude-code-sessions/**/local_*.json, the desktop's own session records (folder, title, last focus) |
| Live status (running, waiting, finished) | ~/.claude/sessions/*.json, written by every running Claude Code process |
| Interface language | the locale field of ~/Library/Application Support/Claude/config.json, picked out by grep: the mod never reads the file itself, which also holds account data |
| Switching sessions | the desktop's deep link claude://code/continue?session=…, opened with open |
| Pins and chosen icons | the plugin's own store (Claude Code keeps it); a chosen picture is kept there, scaled to 64px |
| Keyboard shortcuts | ~/.claude/project-tabs/hotkeys.json (which tab leads where), read by the helper ~/.claude/project-tabs/tabs-hotkeys |
"Unread" means the session finished after you last looked at it. The mod polls these files every 4 seconds, reading only the ones that changed.
What the mod sends, and where: nothing leaves your Mac. The mod makes no network requests and runs no network tool. What it reads stays inside the mod, apart from three things handed to programs on the same Mac: a claude://code/… link with a session id or a project folder, opened by Claude Desktop through open; the path of a picture you chose, given to sips to scale it; and the tab links in hotkeys.json, read by the shortcut helper. None of them reach another service.
Reads
~/Library/Application Support/Claude/claude-code-sessions/**/local_*.json: each session's folder, title, archive flag and times.~/.claude/sessions/*.json: each running session's status.favicon.*, icon.*, apple-touch-icon.* and a project's own .claude/icon.*), skipping node_modules, .git, build output and the like.HOME and TMPDIR.Writes one file of its own: ~/.claude/project-tabs/hotkeys.json, the modifier and where each tab 1…9 leads, written only while keyboard shortcuts are on. The shortcut helper reads it and acts on it (it opens those links when you press the keys); nothing else reads it. Pins and chosen icons go to the plugin store that Claude Code keeps for every plugin. Two programs write files when you ask for what they do: swiftc writes the helper to ~/.claude/project-tabs/tabs-hotkeys when you switch shortcuts on, and sips writes a scaled picture to $TMPDIR/claude-tabs-icon.png when you choose one.
Runs, each by name; the arguments shown in angle brackets are paths or text worked out at the call:
| Program | When | Why |
|---|---|---|
open claude://code/… | you click a tab or a recent session, or press a shortcut | switches Claude Desktop to that session |
ps -A -o pid= | every 4 seconds | tells running sessions from files left by sessions that crashed |
grep -o -m 1 "locale"… <home>/Library/Application Support/Claude/config.json | at session start, and when Claude's settings change | picks out the interface language without the mod reading the file, which also holds account data |
osascript <plugin>/helper/choose-icon.applescript "<prompt>" | you press Choose file… | shows the system's file dialog; the script is in this repository |
sips -s format png -Z 64 <picture> --out $TMPDIR/claude-tabs-icon.png | after you choose a picture | scales it to 64px |
swiftc -O -o <home>/.claude/project-tabs/tabs-hotkeys <plugin>/helper/tabs-hotkeys.swift | you switch shortcuts on, or the plugin updates while they are on | builds the shortcut helper; on Command Line Tools that ship a Swift module map twice (a known packaging bug that stops every Swift build) it adds -vfsoverlay <plugin>/helper/build/overlay.yaml, shipped in this repository, which maps the duplicate onto an empty file |
launchctl list / submit / remove com.github.tripolskiydigital.claude-tabs.hotkeys | shortcuts on, at session start, shortcuts off | starts, checks and stops the shortcut helper, with no launchd file |
Hooks and commands
session.start: registers the /tabs command, starts the 4-second refresh, and restarts the shortcut helper if shortcuts are on.command.run for /tabs only: opens the settings pane. No other command is touched.ui.render for the band above the prompt and for the mod's own pane: draws the tabs and the pane.The mod does not hook prompts, tool calls or the conversation, and adds no tools or agents for Claude.
Switch keyboard shortcuts Off in the pane first (it stops the helper), then:
claude plugin uninstall project-tabs@claude-tabs
The shortcut helper and hotkeys.json are in ~/.claude/project-tabs/; delete the folder to remove them. Pins and chosen icons go with the plugin's store.
claude plugin validate .
claude plugin test .
The module is hooks/register.tsx, helpers are in hooks/lib.ts and hooks/hotkeys.ts, translations in hooks/i18n.ts, the state contract in types/index.d.ts, the shortcut helper in helper/tabs-hotkeys.swift. The types of the mod API are written by Claude Code into .claude-plugin/types/ when it loads the plugin. To iterate with hot reload, ask Claude in a Code session to work on the mod with the plugin-authoring skill, pointing the session's mods folder at your clone.
Issues and pull requests are welcome.
hooks/register.tsx 920 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, FsStat, Register, RenderElement } from 'claude-code'
3
4import type { HotkeyModifier, Lang, Project, SessionState } from '../types'
5import { AGENT_LABEL, hotkeyPaths, lastLine } from './hotkeys'
6import type { AgentResult, HotkeyPaths } from './hotkeys'
7import { langOf, stateLabel, t } from './i18n'
8import {
9 DIGIT_WIDTH,
10 digitSvg,
11 OVERRIDE_CANDIDATES,
12 STATES,
13 DOT_ROW_HEIGHT,
14 EMOJI_CHOICES,
15 STATE_TEXT_COLORS,
16 SKIP_DIRS,
17 groupProjects,
18 iconRank,
19 dataUri,
20 countPillSvg,
21 countPillWidth,
22 dotSvg,
23 TAB_HEIGHT,
24 tabIconSvg,
25 isImagePath,
26 letterColor,
27 letterOf,
28 mimeOf,
29 move,
30 parseLive,
31 parseSession,
32 sessionUrl,
33 tabUrl,
34 topState,
35} from './lib'
36import type { LiveSession, SessionMeta } from './lib'
37
38const PANE = 'project-tabs'
39/** Statuses change by the second: a poll reads only the files whose mtime moved. */
40const REFRESH_MS = 4_000
41/** Image files past this are skipped: an Svg's source holds 131072 characters. */
42const MAX_ICON_BYTES = 90_000
43/** How deep, and through how many folders, an icon is looked for in a project. */
44const ICON_DEPTH = 4
45const ICON_DIRS = 300
46
47const projects = atom({ plugin: 'project-tabs', key: 'projects' } as const, [])
48const pinned = atom({ plugin: 'project-tabs', key: 'pinned' } as const, [])
49const icons = atom({ plugin: 'project-tabs', key: 'icons' } as const, {})
50const favicons = atom({ plugin: 'project-tabs', key: 'favicons' } as const, {})
51const current = atom({ plugin: 'project-tabs', key: 'current' } as const, '')
52const editing = atom({ plugin: 'project-tabs', key: 'editing' } as const, null)
53const lang = atom({ plugin: 'project-tabs', key: 'lang' } as const, 'en')
54const hotkeys = atom({ plugin: 'project-tabs', key: 'hotkeys' } as const, 'off')
55
56/** The tabs a digit reaches: ⌃1 is the first pinned project, ⌃9 the ninth. */
57const HOTKEY_TABS = 9
58
59const KEYS_LABEL: Record<Exclude<HotkeyModifier, 'off'>, string> = { control: '⌃1–9', command: '⌘1–9' }
60
61function asModifier(value: unknown): HotkeyModifier {
62 return value === 'control' || value === 'command' ? value : 'off'
63}
64
65type Engine = EngineInterface
66
67/** Parsed session files by path, kept while their mtime stands. */
68const sessionCache = new Map<string, { mtimeMs: number; meta: SessionMeta | null }>()
69
70async function sessionsRoot($: Engine): Promise<string> {
71 const home = await $.env.get('HOME')
72 return `${home ?? ''}/Library/Application Support/Claude/claude-code-sessions`
73}
74
75async function listDirs($: Engine, path: string): Promise<string[]> {
76 const entries = await $.fs.list(path).catch(() => [])
77 return entries.filter(e => e.kind === 'dir').map(e => `${path}/${e.name}`)
78}
79
80async function scanSessions($: Engine): Promise<SessionMeta[]> {
81 const root = await sessionsRoot($)
82 const leaves = (await Promise.all((await listDirs($, root)).map(d => listDirs($, d)))).flat()
83 const metas = await Promise.all(
84 leaves.map(async dir => {
85 const files = (await $.fs.list(dir).catch(() => [])).filter(
86 f => f.kind === 'file' && /^local_.*\.json$/.test(f.name),
87 )
88 return Promise.all(
89 files.map(async f => {
90 const path = `${dir}/${f.name}`
91 const cached = sessionCache.get(path)
92 if (cached?.mtimeMs === f.mtimeMs) return cached.meta
93 const meta = parseSession(await $.fs.read(path).catch(() => ''))
94 sessionCache.set(path, { mtimeMs: f.mtimeMs, meta })
95 return meta
96 }),
97 )
98 }),
99 )
100 return metas.flat().filter((m): m is SessionMeta => m !== null)
101}
102
103/** Parsed live-process files by path, kept while their mtime stands. */
104const liveCache = new Map<string, { mtimeMs: number; live: LiveSession | null }>()
105
106/** The running Claude Code processes by the desktop session each serves. */
107async function scanLive($: Engine): Promise<Map<string, LiveSession>> {
108 const dir = `${(await $.env.get('HOME')) ?? ''}/.claude/sessions`
109 const files = (await $.fs.list(dir).catch(() => [])).filter(
110 f => f.kind === 'file' && /^\d+\.json$/.test(f.name),
111 )
112 const found = (
113 await Promise.all(
114 files.map(async f => {
115 const path = `${dir}/${f.name}`
116 const cached = liveCache.get(path)
117 if (cached?.mtimeMs === f.mtimeMs) return cached.live
118 const live = parseLive(await $.fs.read(path).catch(() => ''))
119 liveCache.set(path, { mtimeMs: f.mtimeMs, live })
120 return live
121 }),
122 )
123 ).filter((l): l is LiveSession => l !== null)
124 if (found.length === 0) return new Map()
125
126 // A process that died without cleaning up leaves its file: keep the living.
127 // `ps` lists this machine's process ids; nothing else is read from it.
128 let pids = ''
129 try {
130 const ps = await $.process.run(['ps', '-A', '-o', 'pid='])
131 pids = ps.stdout
132 } catch {
133 pids = ''
134 }
135 const alive = new Set(pids.split(/\s+/).filter(Boolean).map(Number))
136 const byHost = new Map<string, LiveSession>()
137 for (const l of found) {
138 const prev = byHost.get(l.hostSessionId)
139 if (alive.has(l.pid) && (prev === undefined || l.statusUpdatedAt > prev.statusUpdatedAt)) {
140 byHost.set(l.hostSessionId, l)
141 }
142 }
143 return byHost
144}
145
146function asStrings(value: unknown): string[] {
147 return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
148}
149
150function asStringRecord(value: unknown): Record<string, string> {
151 if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
152 return Object.fromEntries(
153 Object.entries(value).filter((kv): kv is [string, string] => typeof kv[1] === 'string'),
154 )
155}
156
157/** Whether a value differs from the one held, so a quiet refresh writes and redraws nothing. */
158function changed(prev: unknown, next: unknown): boolean {
159 return JSON.stringify(prev) !== JSON.stringify(next)
160}
161
162async function expandHome($: Engine, path: string): Promise<string> {
163 return path.startsWith('~') ? `${(await $.env.get('HOME')) ?? ''}${path.slice(1)}` : path
164}
165
166async function readIcon($: Engine, path: string): Promise<string | null> {
167 const mime = mimeOf(path)
168 if (mime === undefined) return null
169 try {
170 const stat = await $.fs.stat(path)
171 if (stat.kind !== 'file' || stat.size === 0 || stat.size > MAX_ICON_BYTES) return null
172 const { base64 } = await $.fs.read(path, { as: 'bytes' })
173 return dataUri(base64, mime)
174 } catch {
175 return null
176 }
177}
178
179/** Walks the project breadth first, best-ranked icon files first. */
180async function iconFiles($: Engine, root: string): Promise<string[]> {
181 const found: { path: string; rank: number }[] = []
182 let level = ['']
183 let walked = 0
184 for (let depth = 0; depth <= ICON_DEPTH && level.length > 0; depth++) {
185 const nextLevel: string[] = []
186 for (const rel of level) {
187 if (walked++ >= ICON_DIRS) break
188 const entries = await $.fs.list(rel === '' ? root : `${root}/${rel}`).catch(() => [])
189 for (const entry of entries) {
190 const child = rel === '' ? entry.name : `${rel}/${entry.name}`
191 if (entry.kind === 'dir') {
192 if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) nextLevel.push(child)
193 continue
194 }
195 const rank = entry.kind === 'file' ? iconRank(child) : undefined
196 if (rank !== undefined) found.push({ path: `${root}/${child}`, rank })
197 }
198 }
199 level = nextLevel
200 }
201 return found.sort((a, b) => a.rank - b.rank).map(f => f.path)
202}
203
204async function findIcon(
205 $: Engine,
206 projectPath: string,
207 override: string | undefined,
208): Promise<string | null> {
209 // A picture chosen in the pane is kept in the plugin's store as a data URI.
210 if (override?.startsWith('data:image/')) return override
211 if (override !== undefined && isImagePath(override)) {
212 const svg = await readIcon($, await expandHome($, override))
213 if (svg !== null) return svg
214 }
215 for (const candidate of OVERRIDE_CANDIDATES) {
216 const svg = await readIcon($, `${projectPath}/${candidate}`)
217 if (svg !== null) return svg
218 }
219 for (const path of await iconFiles($, projectPath)) {
220 const svg = await readIcon($, path)
221 if (svg !== null) return svg
222 }
223 return null
224}
225
226/** Looks up the icon of every pinned project not looked up yet (or all, when forced). */
227async function loadFavicons($: Engine, force = false): Promise<Record<string, string | null>> {
228 const list = await read($, pinned)
229 const overrides = await read($, icons)
230 const known = force ? {} : await read($, favicons)
231 const next: Record<string, string | null> = {}
232 for (const path of list) {
233 const had = known[path]
234 next[path] = had === null || had?.startsWith('data:') ? had : await findIcon($, path, overrides[path])
235 }
236 if (changed(await read($, favicons), next)) await update($, favicons, () => next)
237 return next
238}
239
240async function reloadIcons($: Engine): Promise<void> {
241 const found = await loadFavicons($, true)
242 const all = Object.values(found)
243 const named = all.filter(svg => svg !== null).length
244 const vars = { found: named, total: all.length }
245 $.ui.toast(t(await read($, lang), named === all.length ? 'iconsAll' : 'iconsSome', vars))
246}
247
248/** The desktop's settings file: its `locale` is the interface language. */
249let localeCache: { mtimeMs: number; lang: Lang } | undefined
250
251/**
252 * The language the desktop is set to. The file holds other things (encrypted
253 * tokens among them), so the mod never reads it: grep hands back the one
254 * `"locale": "…"` pair and nothing else reaches the mod.
255 */
256async function desktopLang($: Engine): Promise<Lang> {
257 try {
258 const path = `${(await $.env.get('HOME')) ?? ''}/Library/Application Support/Claude/config.json`
259 const { mtimeMs } = await $.fs.stat(path)
260 if (localeCache?.mtimeMs === mtimeMs) return localeCache.lang
261 const found = await $.process.run([
262 'grep',
263 '-o',
264 '-m',
265 '1',
266 '"locale"[[:space:]]*:[[:space:]]*"[^"]*"',
267 path,
268 ])
269 const locale = /"locale"\s*:\s*"([^"]*)"/.exec(found.stdout)?.[1]
270 localeCache = { mtimeMs, lang: langOf(locale) }
271 return localeCache.lang
272 } catch {
273 return 'en'
274 }
275}
276
277async function loadLang($: Engine): Promise<Lang> {
278 const found = await desktopLang($)
279 if (found !== (await read($, lang))) await update($, lang, () => found)
280 return found
281}
282
283/**
284 * Asks for an image with the system's own file dialog, then keeps it in the
285 * plugin's store as a data URI: a raster scaled to 64px as PNG, an SVG as it is.
286 * No file of the mod's is written.
287 */
288async function chooseIconFile($: Engine, path: string, name: string): Promise<void> {
289 const l = await read($, lang)
290 const prompt = t(l, 'choosePrompt', { name })
291 // helper/choose-icon.applescript shows the system's file dialog and prints the path.
292 const picked = await $.process.run(
293 ['osascript', `${$.plugin.root}/helper/choose-icon.applescript`, prompt],
294 {
295 timeoutMs: 600_000,
296 },
297 )
298 // A cancelled dialog exits non-zero (-128): nothing to do.
299 const source = picked.stdout.trim()
300 if (picked.exitCode !== 0 || source === '') return
301
302 let picture: string | null
303 if (/\.svg$/i.test(source)) {
304 picture = await readIcon($, source)
305 if (picture === null) {
306 $.ui.toast(t(l, 'fileTooBig'))
307 return
308 }
309 } else {
310 // sips (part of macOS) scales the picture to 64px as PNG, into the temp folder.
311 const scaledPath = `${(await $.env.get('TMPDIR')) ?? '/tmp/'}`.replace(/\/?$/, '/claude-tabs-icon.png')
312 const scaled = await $.process.run([
313 'sips',
314 '-s',
315 'format',
316 'png',
317 '-Z',
318 '64',
319 source,
320 '--out',
321 scaledPath,
322 ])
323 picture = scaled.exitCode === 0 ? await readIcon($, scaledPath) : null
324 if (picture === null) {
325 $.ui.toast(t(l, 'iconFailed', { error: scaled.stderr.trim() || source }))
326 return
327 }
328 }
329 await saveIcon($, path, picture)
330 await update($, editing, () => null)
331}
332
333async function refresh($: Engine): Promise<SessionMeta[]> {
334 const [sessions, live, id] = await Promise.all([scanSessions($), scanLive($), $.session.id()])
335 const mine = sessions.find(s => s.cliSessionId === id)
336 const grouped = groupProjects(sessions, live, mine?.sessionId ?? null)
337 if (changed(await read($, projects), grouped)) await update($, projects, () => grouped)
338
339 await loadLang($)
340
341 const here = mine?.path ?? (await $.session.cwd())
342 if (changed(await read($, current), here)) await update($, current, () => here)
343
344 // Another session may have pinned or re-iconed since: the store is the truth.
345 const storedPins = asStrings(await $.store.get('pinned'))
346 if (changed(await read($, pinned), storedPins)) await update($, pinned, () => storedPins)
347 const storedIcons = asStringRecord(await $.store.get('icons'))
348 if (changed(await read($, icons), storedIcons)) await update($, icons, () => storedIcons)
349 const modifier = asModifier(await $.store.get('hotkeys'))
350 if (modifier !== (await read($, hotkeys))) await update($, hotkeys, () => modifier)
351 if (modifier !== 'off') await writeHotkeyConfig($, modifier, hotkeyTargets(grouped, storedPins))
352 await loadFavicons($)
353 return sessions
354}
355
356async function shortcutPaths($: Engine): Promise<HotkeyPaths> {
357 return hotkeyPaths((await $.env.get('HOME')) ?? '', $.plugin.root)
358}
359
360let lastHotkeyConfig = ''
361
362/** Tells the agent which modifier to hold and where each of the tabs 1…9 leads. */
363async function writeHotkeyConfig(
364 $: Engine,
365 modifier: HotkeyModifier,
366 targets: readonly (string | null)[],
367): Promise<void> {
368 const text = `${JSON.stringify({ modifier, targets }, null, 2)}\n`
369 if (text === lastHotkeyConfig) return
370 const home = (await $.env.get('HOME')) ?? ''
371 // Read by the shortcut helper (helper/tabs-hotkeys.swift), nothing else.
372 await $.fs.write(`${home}/.claude/project-tabs/hotkeys.json`, text)
373 lastHotkeyConfig = text
374}
375
376/**
377 * Builds the agent from its Swift source with swiftc when there is no binary
378 * or the source is newer (a plugin update).
379 */
380async function buildAgent($: Engine, p: HotkeyPaths): Promise<AgentResult> {
381 let source: FsStat | undefined
382 let binary: FsStat | undefined
383 try {
384 source = await $.fs.stat(p.source)
385 } catch {
386 return { isOk: false, error: `${p.source} is missing` }
387 }
388 try {
389 binary = await $.fs.stat(p.binary)
390 } catch {
391 binary = undefined
392 }
393 if (binary !== undefined && binary.mtimeMs >= source.mtimeMs) return { isOk: true }
394
395 // The binary goes beside hotkeys.json, whose write (always first) made the folder.
396 try {
397 let built = await $.process.run(['swiftc', '-O', '-o', p.binary, p.source], { timeoutMs: 300_000 })
398 if (built.exitCode !== 0 && built.stderr.includes("redefinition of module 'SwiftBridging'")) {
399 // Some Command Line Tools ship one module map twice; the overlay shipped
400 // in helper/build/ maps the duplicate onto an empty file for this build.
401 built = await $.process.run(
402 [
403 'swiftc',
404 '-O',
405 '-vfsoverlay',
406 p.overlay,
407 '-Xcc',
408 '-ivfsoverlay',
409 '-Xcc',
410 p.overlay,
411 '-o',
412 p.binary,
413 p.source,
414 ],
415 { timeoutMs: 300_000 },
416 )
417 }
418 return built.exitCode === 0
419 ? { isOk: true, isRebuilt: true }
420 : { isOk: false, error: lastLine(built.stderr) }
421 } catch {
422 return { isOk: false, error: 'swiftc not found (xcode-select --install)' }
423 }
424}
425
426async function isAgentRunning($: Engine): Promise<boolean> {
427 try {
428 const listed = await $.process.run(['launchctl', 'list', AGENT_LABEL])
429 return listed.exitCode === 0
430 } catch {
431 return false
432 }
433}
434
435/**
436 * Builds the agent if needed and has launchd run it, kept alive, with no file
437 * of its own: `launchctl submit` lasts until logout, and every session start
438 * submits it again while the shortcuts are on.
439 */
440async function ensureAgent($: Engine): Promise<AgentResult> {
441 const p = await shortcutPaths($)
442 const built = await buildAgent($, p)
443 if (!built.isOk) return built
444 if (await isAgentRunning($)) {
445 if (built.isRebuilt !== true) return { isOk: true }
446 await $.process.run(['launchctl', 'remove', AGENT_LABEL])
447 }
448 const submitted = await $.process.run(['launchctl', 'submit', '-l', AGENT_LABEL, '--', p.binary])
449 // Another session may have submitted it a moment before: running is what counts.
450 if (submitted.exitCode !== 0 && !(await isAgentRunning($))) {
451 return { isOk: false, error: lastLine(submitted.stderr) }
452 }
453 return { isOk: true }
454}
455
456/** Stops the agent; the built binary stays for a later switch-on. */
457async function removeAgent($: Engine): Promise<void> {
458 if (await isAgentRunning($)) await $.process.run(['launchctl', 'remove', AGENT_LABEL])
459}
460
461/** Where each digit leads: the pinned project's last session, as a click on its tab. */
462function hotkeyTargets(all: readonly Project[], list: readonly string[]): (string | null)[] {
463 return Array.from({ length: HOTKEY_TABS }, (_, i) => {
464 const path = list[i]
465 if (path === undefined) return null
466 return tabUrl(all.find(p => p.path === path) ?? { path, lastSessionId: null })
467 })
468}
469
470/**
471 * Switches the tab shortcuts: off takes the agent out of launchd; a modifier
472 * builds the agent (the first time, some seconds) and has launchd run it.
473 */
474async function setHotkeys($: Engine, modifier: HotkeyModifier): Promise<void> {
475 const l = await read($, lang)
476 await $.store.set('hotkeys', modifier)
477 await update($, hotkeys, () => modifier)
478 await writeHotkeyConfig($, modifier, hotkeyTargets(await read($, projects), await read($, pinned)))
479 if (modifier === 'off') {
480 await removeAgent($)
481 $.ui.toast(t(l, 'shortcutsDisabled'))
482 return
483 }
484 $.ui.toast(t(l, 'shortcutsBuilding'))
485 const agent = await ensureAgent($)
486 $.ui.toast(
487 agent.isOk
488 ? t(l, 'shortcutsOn', { keys: KEYS_LABEL[modifier] })
489 : t(l, 'shortcutsFailed', { error: agent.error }),
490 )
491}
492
493async function savePinned($: Engine, list: string[]): Promise<void> {
494 await $.store.set('pinned', list)
495 await update($, pinned, () => list)
496 await loadFavicons($)
497}
498
499async function saveIcon($: Engine, path: string, value: string): Promise<void> {
500 const next = { ...asStringRecord(await $.store.get('icons')) }
501 if (value.trim() === '') delete next[path]
502 else next[path] = value.trim()
503 await $.store.set('icons', next)
504 await update($, icons, () => next)
505 const known = { ...(await read($, favicons)) }
506 delete known[path]
507 await update($, favicons, () => known)
508 await loadFavicons($)
509}
510
511async function openProject($: Engine, path: string): Promise<void> {
512 if (path === (await read($, current))) return
513 // Rescan first: the last session of a project changes as the person works.
514 const sessions = await refresh($)
515 const project = groupProjects(sessions.filter(s => s.path === path))[0] ?? {
516 path,
517 lastSessionId: null,
518 }
519 await openUrl($, tabUrl(project))
520}
521
522async function openUrl($: Engine, url: string): Promise<void> {
523 const { exitCode, stderr } = await $.process.run(['open', url])
524 if (exitCode !== 0) $.ui.toast(t(await read($, lang), 'openFailed', { error: stderr.trim() }))
525}
526
527async function openPane($: Engine): Promise<unknown> {
528 const title = t(await read($, lang), 'title')
529 return $.ui.open({ id: PANE, title, focus: true, closeOnEscape: true, rows: 20 })
530}
531
532/** The ⋯ button: opens the pane, or closes it when it is open. */
533async function togglePane($: Engine): Promise<void> {
534 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
535 if (isOpen) await $.ui.close({ id: PANE })
536 else await openPane($)
537}
538
539function nameOf(list: readonly Project[], path: string): string {
540 return list.find(p => p.path === path)?.name ?? path.split('/').filter(Boolean).at(-1) ?? path
541}
542
543/** Whether the plugins beneath drew nothing in the band. */
544function isEmptyTree(tree: RenderElement | null | undefined): boolean {
545 if (tree == null) return true
546 return tree.type === 'Box' && (tree.children === undefined || tree.children.length === 0)
547}
548
549type IconElements = { Text: Elements['desktop']['Text']; Svg?: Elements['desktop']['Svg'] }
550
551/**
552 * A project's icon, 20px with rounded corners in a 28px box: its image, its
553 * emoji, or its first letter on a square of its own color. Where the surface
554 * draws no SVG (the terminal), the emoji or the letter as text.
555 */
556function projectIcon(
557 { Text, Svg }: IconElements,
558 name: string,
559 override: string | undefined,
560 uri: string | null | undefined,
561) {
562 const emoji = override !== undefined && !isImagePath(override) ? override : undefined
563 if (Svg === undefined) {
564 return emoji !== undefined ? (
565 <Text>{emoji}</Text>
566 ) : (
567 <Text backgroundColor={letterColor(name)} color="#ffffff" bold>{` ${letterOf(name)} `}</Text>
568 )
569 }
570 const source = tabIconSvg(
571 emoji !== undefined
572 ? { kind: 'emoji', text: emoji }
573 : uri != null
574 ? { kind: 'image', uri }
575 : { kind: 'letter', name },
576 )
577 return <Svg source={source} alt={name} width={TAB_HEIGHT} height={TAB_HEIGHT} />
578}
579
580/** A session's dot as the sidebar draws it, at the height of a list row. */
581function stateDot({ Text, Svg }: IconElements, state: SessionState, l: Lang) {
582 if (Svg !== undefined)
583 return <Svg source={dotSvg(state)} alt={stateLabel(l, state)} width={12} height={DOT_ROW_HEIGHT} />
584 return <Text color={STATE_TEXT_COLORS[state]}>{state === 'idle' ? '○' : '●'}</Text>
585}
586
587/** The count of a project's sessions, in the color of the most urgent; nothing for none. */
588function sessionCount({ Text, Svg }: IconElements, project: Project | undefined, l: Lang) {
589 const count = project?.sessionCount ?? 0
590 if (count === 0) return null
591 const state = topState(project!.counts)
592 if (Svg !== undefined) {
593 return (
594 <Svg
595 source={countPillSvg(count, state)}
596 alt={`${count} ${stateLabel(l, state)}`}
597 width={countPillWidth(count)}
598 height={TAB_HEIGHT}
599 />
600 )
601 }
602 return state === 'idle' ? (
603 <Text dimColor>{count}</Text>
604 ) : (
605 <Text color={STATE_TEXT_COLORS[state]} bold>
606 {count}
607 </Text>
608 )
609}
610
611export const register: Register = on => {
612 on('session.start', async ($, e, next) => {
613 await $.command.register({ name: 'tabs', description: t(await loadLang($), 'cmdDesc') })
614 $.clock.every(REFRESH_MS, () => void refresh($))
615 // Shortcuts that are on stay on: after a reboot or a plugin update the
616 // agent is started, or rebuilt, here, once the first refresh has written
617 // hotkeys.json (and so made the agent's folder).
618 void (async () => {
619 await refresh($)
620 if (asModifier(await $.store.get('hotkeys')) === 'off') return
621 const agent = await ensureAgent($)
622 if (!agent.isOk) $.ui.toast(t(await read($, lang), 'shortcutsFailed', { error: agent.error }))
623 })()
624
625 return next(e)
626 })
627
628 on('command.run', { command: 'tabs' }, async $ => {
629 await refresh($)
630 await openPane($)
631
632 return { text: t(await read($, lang), 'cmdOpened') }
633 })
634
635 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
636 // The band is one site: what the plugins beneath draw (another mod's row)
637 // stays, under the tabs.
638 const below = await next(e)
639 if (e.props.hasSurvey) return below
640 const withBelow = (tree: RenderElement) =>
641 isEmptyTree(below) ? tree : (
642 <Box flexDirection="column" rowGap={1}>
643 {tree}
644 {below}
645 </Box>
646 )
647
648 const [all, list, overrides, svgs, here, l, modifier] = await Promise.all([
649 read($, projects),
650 read($, pinned),
651 read($, icons),
652 read($, favicons),
653 read($, current),
654 read($, lang),
655 read($, hotkeys),
656 ])
657 const els = $.ui.resolve(e)
658 const { Box, Text, Button } = els
659 const Svg = 'Svg' in els ? els.Svg : undefined
660
661 // A hover only paints over a color the Box already has: the desktop's
662 // tabs start transparent. The terminal has no such color.
663 const isTerminal = e.surface === 'terminal'
664 const base = isTerminal ? {} : { backgroundColor: 'transparent' }
665 // The active tab is filled; a hovered one gets a lighter wash of its own.
666 const ACTIVE = 'userMessageBackground'
667 const HOVER = isTerminal ? ACTIVE : 'rgba(128, 128, 128, 0.14)'
668 // The name's own button lights under the pointer: the tab's fill is enough.
669 const quietButton = isTerminal ? {} : { hover: { backgroundColor: 'transparent' } }
670 const manage = (
671 <Box key="manage-box" alignSelf="center" flexShrink={0}>
672 <Button key="manage" label="⋯" onPress={() => void togglePane($)} />
673 </Box>
674 )
675
676 if (list.length === 0) {
677 return withBelow(
678 <Box flexDirection="row" alignItems="center" gap={1}>
679 <Text dimColor>{t(l, 'noPinned')}</Text>
680 {here !== '' && (
681 <Button
682 key="pin-current"
683 label={t(l, 'pinThis')}
684 onPress={async () => savePinned($, [...(await read($, pinned)), here])}
685 />
686 )}
687 {manage}
688 </Box>,
689 )
690 }
691
692 const iconEls = { Text, ...(Svg !== undefined ? { Svg } : {}) }
693
694 return withBelow(
695 <Box flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={1}>
696 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} flexShrink={1}>
697 {list.map((path, i) => {
698 const project = all.find(p => p.path === path)
699 const name = nameOf(all, path)
700 const isHere = path === here
701 const recent = project?.recent ?? []
702 const count = project?.sessionCount ?? 0
703
704 return (
705 <Box
706 key={`tab-${i}`}
707 {...(isTerminal || count === 0 ? { paddingRight: 1 } : {})}
708 position="relative"
709 flexDirection="row"
710 alignItems="center"
711 {...(isHere ? { backgroundColor: ACTIVE } : base)}
712 hover={{ backgroundColor: isHere ? ACTIVE : HOVER }}
713 >
714 {modifier !== 'off' &&
715 i < HOTKEY_TABS &&
716 (Svg !== undefined ? (
717 <Svg
718 source={digitSvg(i + 1)}
719 alt={`${KEYS_LABEL[modifier].slice(0, 1)}${i + 1}`}
720 width={DIGIT_WIDTH}
721 height={TAB_HEIGHT}
722 />
723 ) : (
724 <Text dimColor>{`${i + 1} `}</Text>
725 ))}
726 {projectIcon(iconEls, name, overrides[path], svgs[path])}
727 <Button
728 key={`open-${i}`}
729 label={name}
730 plain
731 {...(isHere ? {} : { dimColor: true })}
732 {...quietButton}
733 onPress={() => void openProject($, path)}
734 />
735 {sessionCount(iconEls, project, l)}
736 {recent.length > 0 && (
737 <Box
738 position="absolute"
739 bottom={2}
740 left={0}
741 display="none"
742 hover={{ display: 'flex' }}
743 flexDirection="column"
744 minWidth={32}
745 paddingX={1}
746 borderStyle="round"
747 borderColor="inactive"
748 backgroundColor="userMessageBackground"
749 >
750 <Text dimColor>{t(l, 'recentSessions', { name })}</Text>
751 {recent.map((session, j) => (
752 <Box flexDirection="row" alignItems="center" gap={1}>
753 {stateDot(iconEls, session.state, l)}
754 <Button
755 key={`session-${i}-${j}`}
756 label={session.title}
757 plain
758 onPress={() => void openUrl($, sessionUrl(session.id))}
759 />
760 </Box>
761 ))}
762 </Box>
763 )}
764 </Box>
765 )
766 })}
767 </Box>
768 {manage}
769 </Box>,
770 )
771 })
772
773 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
774 const [all, list, overrides, svgs, here, edited, l, modifier] = await Promise.all([
775 read($, projects),
776 read($, pinned),
777 read($, icons),
778 read($, favicons),
779 read($, current),
780 read($, editing),
781 read($, lang),
782 read($, hotkeys),
783 ])
784 const els = $.ui.resolve(e)
785 const { Box, Text, Button } = els
786 const Svg = 'Svg' in els ? els.Svg : undefined
787 const Input = 'Input' in els ? els.Input : undefined
788 const iconEls = { Text, ...(Svg !== undefined ? { Svg } : {}) }
789 // Every project with a desktop session, newest first: the pinned ones are above.
790 const others = all.filter(p => !list.includes(p.path))
791 const projectOf = (path: string) => all.find(p => p.path === path)
792
793 const pickIcon = async (path: string, value: string) => {
794 await saveIcon($, path, value)
795 await update($, editing, () => null)
796 }
797
798 return (
799 <Box flexDirection="column" gap={1}>
800 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
801 <Text bold>{t(l, 'shortcuts')}</Text>
802 <Box flexDirection="row" flexShrink={0} gap={1}>
803 {(['off', 'control', 'command'] as const).map(choice => (
804 <Button
805 key={`hotkeys-${choice}`}
806 label={choice === 'off' ? t(l, 'shortcutsOff') : KEYS_LABEL[choice]}
807 {...(choice === modifier ? { variant: 'primary' as const } : {})}
808 onPress={() => void (choice !== modifier && setHotkeys($, choice))}
809 />
810 ))}
811 </Box>
812 </Box>
813 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
814 <Text bold>{t(l, 'pinned')}</Text>
815 <Button key="reload-icons" label={t(l, 'refreshIcons')} onPress={() => void reloadIcons($)} />
816 </Box>
817 {list.length === 0 && <Text dimColor>{t(l, 'emptyPinned')}</Text>}
818 {list.map((path, i) => {
819 const name = nameOf(all, path)
820 const override = overrides[path]
821 const emoji = override !== undefined && !isImagePath(override) ? override : ''
822 return (
823 <Box key={`pinned-${i}`} flexDirection="column">
824 <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
825 <Box flexDirection="row" alignItems="center" flexShrink={1}>
826 {projectIcon(iconEls, name, override, svgs[path])}
827 {sessionCount(iconEls, projectOf(path), l)}
828 <Text wrap="truncate" bold={path === here}>
829 {name}
830 </Text>
831 </Box>
832 <Box flexDirection="row" flexShrink={0} gap={1}>
833 <Button
834 key={`up-${i}`}
835 label="↑"
836 plain
837 onPress={() => void savePinned($, move(list, i, -1))}
838 />
839 <Button
840 key={`down-${i}`}
841 label="↓"
842 plain
843 onPress={() => void savePinned($, move(list, i, 1))}
844 />
845 <Button
846 key={`edit-${i}`}
847 label="✎"
848 plain
849 onPress={() => void update($, editing, now => (now === path ? null : path))}
850 />
851 <Button
852 key={`unpin-${i}`}
853 label="✕"
854 plain
855 onPress={() =>
856 void savePinned(
857 $,
858 list.filter(p => p !== path),
859 )
860 }
861 />
862 </Box>
863 </Box>
864 {edited === path && (
865 <Box flexDirection="column" gap={1} paddingLeft={2} paddingTop={1}>
866 <Box flexDirection="row" flexWrap="wrap" gap={1}>
867 <Button
868 key={`file-${i}`}
869 label={t(l, 'chooseFile')}
870 variant="primary"
871 onPress={() => void chooseIconFile($, path, name)}
872 />
873 <Button
874 key={`auto-${i}`}
875 label={t(l, 'autoIcon')}
876 onPress={() => void pickIcon(path, '')}
877 />
878 </Box>
879 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
880 {EMOJI_CHOICES.map((choice, k) => (
881 <Button
882 key={`emoji-${i}-${k}`}
883 label={choice}
884 plain
885 onPress={() => void pickIcon(path, choice)}
886 />
887 ))}
888 </Box>
889 {Input !== undefined && (
890 <Input
891 key={`icon-${i}`}
892 placeholder={t(l, 'otherEmoji')}
893 value={emoji}
894 submitLabel={t(l, 'save')}
895 onSubmit={value => void pickIcon(path, value)}
896 />
897 )}
898 </Box>
899 )}
900 </Box>
901 )
902 })}
903 <Text bold>{t(l, 'others')}</Text>
904 {others.map((p, i) => (
905 <Box key={`other-${i}`} flexDirection="row" alignItems="center">
906 <Button
907 key={`pin-${i}`}
908 label="📌"
909 plain
910 onPress={async () => savePinned($, [...(await read($, pinned)), p.path])}
911 />
912 {sessionCount(iconEls, p, l)}
913 <Text wrap="truncate">{p.name}</Text>
914 </Box>
915 ))}
916 </Box>
917 )
918 })
919}
920hooks/hotkeys.ts 40 lines1/**
2 * The plain parts of the tab shortcuts: where the agent's files live. The
3 * calls that build and run it are in register.tsx, since `$` never crosses an
4 * import.
5 */
6
7/** The launchd job (`launchctl submit`, no file of its own) that runs helper/tabs-hotkeys.swift. */
8export const AGENT_LABEL = 'com.github.tripolskiydigital.claude-tabs.hotkeys'
9
10export type AgentResult = { isOk: true; isRebuilt?: boolean } | { isOk: false; error: string }
11
12export type HotkeyPaths = {
13 /** Which tab leads where: written by the mod, read by the agent. */
14 config: string
15 /** The agent, as swiftc builds it. */
16 binary: string
17 /** The agent's Swift source, shipped with the plugin. */
18 source: string
19 /**
20 * A VFS overlay shipped with the plugin, for Command Line Tools that ship one
21 * Swift module map twice (every Swift build fails there): it maps the
22 * duplicate onto the empty file beside it, for that one build.
23 */
24 overlay: string
25}
26
27export function hotkeyPaths(home: string, pluginRoot: string): HotkeyPaths {
28 const base = `${home}/.claude/project-tabs`
29 return {
30 config: `${base}/hotkeys.json`,
31 binary: `${base}/tabs-hotkeys`,
32 source: `${pluginRoot}/helper/tabs-hotkeys.swift`,
33 overlay: `${pluginRoot}/helper/build/overlay.yaml`,
34 }
35}
36
37export function lastLine(text: string): string {
38 return text.trim().split('\n').at(-1) ?? text
39}
40hooks/i18n.ts 271 lines1import type { Lang, SessionState } from '../types'
2
3type Strings = {
4 title: string
5 cmdDesc: string
6 cmdOpened: string
7 noPinned: string
8 pinThis: string
9 recentSessions: string
10 openFailed: string
11 iconsAll: string
12 iconsSome: string
13 pinned: string
14 emptyPinned: string
15 others: string
16 refreshIcons: string
17 chooseFile: string
18 autoIcon: string
19 otherEmoji: string
20 save: string
21 choosePrompt: string
22 fileTooBig: string
23 iconFailed: string
24 shortcuts: string
25 shortcutsOff: string
26 shortcutsBuilding: string
27 shortcutsOn: string
28 shortcutsDisabled: string
29 shortcutsFailed: string
30 states: Record<SessionState, string>
31}
32
33const STRINGS: Record<Lang, Strings> = {
34 en: {
35 title: 'Project tabs',
36 cmdDesc: 'Project tabs: pin, unpin, order, icons',
37 cmdOpened: 'Opened the project tabs pane.',
38 noPinned: 'No pinned projects',
39 pinThis: '📌 Pin this project',
40 recentSessions: 'Recent sessions · {name}',
41 openFailed: "Couldn't open: {error}",
42 iconsAll: 'Icons refreshed: {found} of {total}',
43 iconsSome:
44 'Icons refreshed: found {found} of {total}, the rest show a letter. Pick your own with ✎ or put it in the project as .claude/icon.svg or .claude/icon.png.',
45 pinned: 'Pinned',
46 emptyPinned: 'Nothing yet: pin a project from the list below.',
47 others: 'Other projects',
48 refreshIcons: 'Refresh icons',
49 chooseFile: 'Choose file…',
50 autoIcon: 'Automatic',
51 otherEmoji: 'or type an emoji',
52 save: 'save',
53 choosePrompt: 'Icon for “{name}”',
54 fileTooBig: 'That SVG is over 90 KB: pick a smaller one.',
55 iconFailed: "Couldn't use that image: {error}",
56 shortcuts: 'Keyboard shortcuts',
57 shortcutsOff: 'Off',
58 shortcutsBuilding: 'Setting up keyboard shortcuts…',
59 shortcutsOn: '{keys} switch tabs while Claude is in front',
60 shortcutsDisabled: 'Keyboard shortcuts are off',
61 shortcutsFailed: "Couldn't set up keyboard shortcuts: {error}",
62 states: { waiting: 'waiting for you', unread: 'done, unread', running: 'running', idle: 'quiet' },
63 },
64 ru: {
65 title: 'Вкладки проектов',
66 cmdDesc: 'Вкладки проектов: закрепить, открепить, порядок, иконки',
67 cmdOpened: 'Открыта панель вкладок проектов.',
68 noPinned: 'Нет закреплённых проектов',
69 pinThis: '📌 Закрепить этот проект',
70 recentSessions: 'Последние сессии · {name}',
71 openFailed: 'Не удалось открыть: {error}',
72 iconsAll: 'Иконки обновлены: {found} из {total}',
73 iconsSome:
74 'Иконки обновлены: найдено {found} из {total}, остальным — буква. Свою иконку можно выбрать через ✎ или положить в проект как .claude/icon.svg или .claude/icon.png.',
75 pinned: 'Закреплённые',
76 emptyPinned: 'Пока пусто: закрепите проект из списка ниже.',
77 others: 'Остальные проекты',
78 refreshIcons: 'Обновить иконки',
79 chooseFile: 'Выбрать файл…',
80 autoIcon: 'Авто',
81 otherEmoji: 'или введите эмодзи',
82 save: 'сохранить',
83 choosePrompt: 'Иконка для «{name}»',
84 fileTooBig: 'SVG больше 90 КБ — выберите файл поменьше.',
85 iconFailed: 'Не удалось использовать картинку: {error}',
86 shortcuts: 'Горячие клавиши',
87 shortcutsOff: 'Выкл',
88 shortcutsBuilding: 'Настраиваю горячие клавиши…',
89 shortcutsOn: '{keys} переключают вкладки, пока Claude на переднем плане',
90 shortcutsDisabled: 'Горячие клавиши выключены',
91 shortcutsFailed: 'Не удалось настроить горячие клавиши: {error}',
92 states: { waiting: 'ждёт ответа', unread: 'готово, не прочитано', running: 'работает', idle: 'спокойна' },
93 },
94 uk: {
95 title: 'Вкладки проєктів',
96 cmdDesc: 'Вкладки проєктів: закріпити, відкріпити, порядок, іконки',
97 cmdOpened: 'Відкрито панель вкладок проєктів.',
98 noPinned: 'Немає закріплених проєктів',
99 pinThis: '📌 Закріпити цей проєкт',
100 recentSessions: 'Останні сесії · {name}',
101 openFailed: 'Не вдалося відкрити: {error}',
102 iconsAll: 'Іконки оновлено: {found} з {total}',
103 iconsSome:
104 'Іконки оновлено: знайдено {found} з {total}, решті — літера. Свою іконку можна вибрати через ✎ або покласти в проєкт як .claude/icon.svg чи .claude/icon.png.',
105 pinned: 'Закріплені',
106 emptyPinned: 'Поки порожньо: закріпіть проєкт зі списку нижче.',
107 others: 'Інші проєкти',
108 refreshIcons: 'Оновити іконки',
109 chooseFile: 'Вибрати файл…',
110 autoIcon: 'Авто',
111 otherEmoji: 'або введіть емодзі',
112 save: 'зберегти',
113 choosePrompt: 'Іконка для «{name}»',
114 fileTooBig: 'SVG більший за 90 КБ — виберіть менший файл.',
115 iconFailed: 'Не вдалося використати зображення: {error}',
116 shortcuts: 'Гарячі клавіші',
117 shortcutsOff: 'Вимк',
118 shortcutsBuilding: 'Налаштовую гарячі клавіші…',
119 shortcutsOn: '{keys} перемикають вкладки, поки Claude на передньому плані',
120 shortcutsDisabled: 'Гарячі клавіші вимкнено',
121 shortcutsFailed: 'Не вдалося налаштувати гарячі клавіші: {error}',
122 states: {
123 waiting: 'чекає на відповідь',
124 unread: 'готово, не прочитано',
125 running: 'працює',
126 idle: 'спокійна',
127 },
128 },
129 de: {
130 title: 'Projekt-Tabs',
131 cmdDesc: 'Projekt-Tabs: anheften, lösen, sortieren, Symbole',
132 cmdOpened: 'Bereich „Projekt-Tabs“ geöffnet.',
133 noPinned: 'Keine angehefteten Projekte',
134 pinThis: '📌 Dieses Projekt anheften',
135 recentSessions: 'Letzte Sitzungen · {name}',
136 openFailed: 'Öffnen fehlgeschlagen: {error}',
137 iconsAll: 'Symbole aktualisiert: {found} von {total}',
138 iconsSome:
139 'Symbole aktualisiert: {found} von {total} gefunden, die übrigen zeigen einen Buchstaben. Ein eigenes Symbol wählst du mit ✎ oder legst es als .claude/icon.svg bzw. .claude/icon.png ins Projekt.',
140 pinned: 'Angeheftet',
141 emptyPinned: 'Noch leer: Hefte ein Projekt aus der Liste unten an.',
142 others: 'Weitere Projekte',
143 refreshIcons: 'Symbole aktualisieren',
144 chooseFile: 'Datei wählen…',
145 autoIcon: 'Automatisch',
146 otherEmoji: 'oder Emoji eingeben',
147 save: 'speichern',
148 choosePrompt: 'Symbol für „{name}“',
149 fileTooBig: 'Die SVG-Datei ist größer als 90 KB – bitte eine kleinere wählen.',
150 iconFailed: 'Bild konnte nicht verwendet werden: {error}',
151 shortcuts: 'Tastenkürzel',
152 shortcutsOff: 'Aus',
153 shortcutsBuilding: 'Tastenkürzel werden eingerichtet…',
154 shortcutsOn: '{keys} wechseln die Tabs, solange Claude im Vordergrund ist',
155 shortcutsDisabled: 'Tastenkürzel sind aus',
156 shortcutsFailed: 'Tastenkürzel konnten nicht eingerichtet werden: {error}',
157 states: { waiting: 'wartet auf dich', unread: 'fertig, ungelesen', running: 'läuft', idle: 'ruhig' },
158 },
159 fr: {
160 title: 'Onglets de projets',
161 cmdDesc: 'Onglets de projets : épingler, désépingler, ordre, icônes',
162 cmdOpened: 'Panneau des onglets de projets ouvert.',
163 noPinned: 'Aucun projet épinglé',
164 pinThis: '📌 Épingler ce projet',
165 recentSessions: 'Sessions récentes · {name}',
166 openFailed: 'Impossible d’ouvrir : {error}',
167 iconsAll: 'Icônes actualisées : {found} sur {total}',
168 iconsSome:
169 'Icônes actualisées : {found} sur {total} trouvées, les autres affichent une lettre. Choisissez la vôtre avec ✎ ou placez-la dans le projet sous .claude/icon.svg ou .claude/icon.png.',
170 pinned: 'Épinglés',
171 emptyPinned: 'Rien pour l’instant : épinglez un projet de la liste ci-dessous.',
172 others: 'Autres projets',
173 refreshIcons: 'Actualiser les icônes',
174 chooseFile: 'Choisir un fichier…',
175 autoIcon: 'Automatique',
176 otherEmoji: 'ou saisissez un emoji',
177 save: 'enregistrer',
178 choosePrompt: 'Icône pour « {name} »',
179 fileTooBig: 'Ce SVG dépasse 90 Ko : choisissez-en un plus petit.',
180 iconFailed: 'Impossible d’utiliser cette image : {error}',
181 shortcuts: 'Raccourcis clavier',
182 shortcutsOff: 'Désactivés',
183 shortcutsBuilding: 'Configuration des raccourcis clavier…',
184 shortcutsOn: '{keys} changent d’onglet tant que Claude est au premier plan',
185 shortcutsDisabled: 'Raccourcis clavier désactivés',
186 shortcutsFailed: 'Impossible de configurer les raccourcis clavier : {error}',
187 states: { waiting: 'vous attend', unread: 'terminée, non lue', running: 'en cours', idle: 'au repos' },
188 },
189 it: {
190 title: 'Schede progetto',
191 cmdDesc: 'Schede progetto: fissa, sblocca, ordina, icone',
192 cmdOpened: 'Pannello delle schede progetto aperto.',
193 noPinned: 'Nessun progetto fissato',
194 pinThis: '📌 Fissa questo progetto',
195 recentSessions: 'Sessioni recenti · {name}',
196 openFailed: 'Impossibile aprire: {error}',
197 iconsAll: 'Icone aggiornate: {found} di {total}',
198 iconsSome:
199 'Icone aggiornate: trovate {found} di {total}, le altre mostrano una lettera. Scegli la tua con ✎ o mettila nel progetto come .claude/icon.svg o .claude/icon.png.',
200 pinned: 'Fissati',
201 emptyPinned: 'Ancora vuoto: fissa un progetto dall’elenco qui sotto.',
202 others: 'Altri progetti',
203 refreshIcons: 'Aggiorna icone',
204 chooseFile: 'Scegli file…',
205 autoIcon: 'Automatica',
206 otherEmoji: 'o digita un’emoji',
207 save: 'salva',
208 choosePrompt: 'Icona per «{name}»',
209 fileTooBig: 'L’SVG supera i 90 KB: scegline uno più piccolo.',
210 iconFailed: 'Impossibile usare l’immagine: {error}',
211 shortcuts: 'Scorciatoie da tastiera',
212 shortcutsOff: 'Disattive',
213 shortcutsBuilding: 'Configurazione delle scorciatoie…',
214 shortcutsOn: '{keys} cambiano scheda mentre Claude è in primo piano',
215 shortcutsDisabled: 'Scorciatoie da tastiera disattivate',
216 shortcutsFailed: 'Impossibile configurare le scorciatoie: {error}',
217 states: { waiting: 'ti aspetta', unread: 'finita, non letta', running: 'in corso', idle: 'inattiva' },
218 },
219 es: {
220 title: 'Pestañas de proyectos',
221 cmdDesc: 'Pestañas de proyectos: fijar, desfijar, ordenar, iconos',
222 cmdOpened: 'Panel de pestañas de proyectos abierto.',
223 noPinned: 'No hay proyectos fijados',
224 pinThis: '📌 Fijar este proyecto',
225 recentSessions: 'Sesiones recientes · {name}',
226 openFailed: 'No se pudo abrir: {error}',
227 iconsAll: 'Iconos actualizados: {found} de {total}',
228 iconsSome:
229 'Iconos actualizados: encontrados {found} de {total}; el resto muestra una letra. Elige el tuyo con ✎ o ponlo en el proyecto como .claude/icon.svg o .claude/icon.png.',
230 pinned: 'Fijados',
231 emptyPinned: 'Aún vacío: fija un proyecto de la lista de abajo.',
232 others: 'Otros proyectos',
233 refreshIcons: 'Actualizar iconos',
234 chooseFile: 'Elegir archivo…',
235 autoIcon: 'Automático',
236 otherEmoji: 'o escribe un emoji',
237 save: 'guardar',
238 choosePrompt: 'Icono para «{name}»',
239 fileTooBig: 'Ese SVG supera los 90 KB: elige uno más pequeño.',
240 iconFailed: 'No se pudo usar la imagen: {error}',
241 shortcuts: 'Atajos de teclado',
242 shortcutsOff: 'Desactivados',
243 shortcutsBuilding: 'Configurando los atajos de teclado…',
244 shortcutsOn: '{keys} cambian de pestaña mientras Claude está al frente',
245 shortcutsDisabled: 'Atajos de teclado desactivados',
246 shortcutsFailed: 'No se pudieron configurar los atajos: {error}',
247 states: { waiting: 'te espera', unread: 'lista, sin leer', running: 'en curso', idle: 'en reposo' },
248 },
249}
250
251export const LANGS = Object.keys(STRINGS) as Lang[]
252
253/** The interface language for a desktop locale (`ru`, `de-DE`, `es-419`); English otherwise. */
254export function langOf(locale: string | undefined): Lang {
255 const prefix = locale?.toLowerCase().split(/[-_]/)[0]
256 return LANGS.find(lang => lang === prefix) ?? 'en'
257}
258
259type Key = Exclude<keyof Strings, 'states'>
260
261/** A string of the interface in `lang`, its `{name}` slots filled. */
262export function t(lang: Lang, key: Key, vars: Record<string, string | number> = {}): string {
263 return STRINGS[lang][key].replace(/\{(\w+)\}/g, (slot, name: string) =>
264 name in vars ? String(vars[name]) : slot,
265 )
266}
267
268export function stateLabel(lang: Lang, state: SessionState): string {
269 return STRINGS[lang].states[state]
270}
271hooks/lib.ts 411 lines1import type { Project, SessionState, SessionSummary } from '../types'
2
3/** One desktop session, as its metadata file under claude-code-sessions holds it. */
4export type SessionMeta = {
5 sessionId: string
6 cliSessionId: string | null
7 path: string
8 title: string
9 lastFocusedAt: number
10 lastActivityAt: number
11 isArchived: boolean
12}
13
14/** A running Claude Code process, as its file under ~/.claude/sessions says. */
15export type LiveSession = {
16 pid: number
17 /** The desktop session it serves (`local_...`). */
18 hostSessionId: string
19 status: 'busy' | 'idle' | 'waiting'
20 statusUpdatedAt: number
21}
22
23export function parseSession(text: string): SessionMeta | null {
24 let raw: unknown
25 try {
26 raw = JSON.parse(text)
27 } catch {
28 return null
29 }
30 if (typeof raw !== 'object' || raw === null) return null
31 const o = raw as Record<string, unknown>
32 const sessionId = typeof o.sessionId === 'string' ? o.sessionId : null
33 const path = typeof o.originCwd === 'string' ? o.originCwd : typeof o.cwd === 'string' ? o.cwd : null
34 if (sessionId === null || path === null || !/^local_[A-Za-z0-9-]{1,64}$/.test(sessionId)) {
35 return null
36 }
37 const num = (...values: unknown[]) => values.find((t): t is number => typeof t === 'number') ?? 0
38 return {
39 sessionId,
40 cliSessionId: typeof o.cliSessionId === 'string' ? o.cliSessionId : null,
41 path: path.replace(/\/+$/, ''),
42 title: typeof o.title === 'string' && o.title.trim() !== '' ? o.title.trim() : baseName(path),
43 lastFocusedAt: num(o.lastFocusedAt, o.lastActivityAt, o.createdAt),
44 lastActivityAt: num(o.lastActivityAt, o.lastFocusedAt, o.createdAt),
45 isArchived: o.isArchived === true,
46 }
47}
48
49export function parseLive(text: string): LiveSession | null {
50 let raw: unknown
51 try {
52 raw = JSON.parse(text)
53 } catch {
54 return null
55 }
56 if (typeof raw !== 'object' || raw === null) return null
57 const o = raw as Record<string, unknown>
58 const status = o.status
59 if (
60 typeof o.pid !== 'number' ||
61 typeof o.hostSessionId !== 'string' ||
62 (status !== 'busy' && status !== 'idle' && status !== 'waiting')
63 ) {
64 return null
65 }
66 return {
67 pid: o.pid,
68 hostSessionId: o.hostSessionId,
69 status,
70 statusUpdatedAt: typeof o.statusUpdatedAt === 'number' ? o.statusUpdatedAt : 0,
71 }
72}
73
74/**
75 * What a session wants, as the desktop's sidebar dot says it: waiting on the
76 * person (a permission or a question), finished since it was last looked at,
77 * or running. The session this band is drawn in is never unread: it is in view.
78 */
79export function stateOf(
80 meta: SessionMeta,
81 live: LiveSession | undefined,
82 ownSessionId: string | null,
83): SessionState {
84 if (live === undefined) return 'idle'
85 if (live.status === 'waiting') return 'waiting'
86 if (live.status === 'busy') return 'running'
87 const isUnseen = live.statusUpdatedAt > meta.lastFocusedAt + UNREAD_SLACK_MS
88 return isUnseen && meta.sessionId !== ownSessionId ? 'unread' : 'idle'
89}
90
91/** Focus written a moment before the turn's end still counts as seen. */
92const UNREAD_SLACK_MS = 2_000
93
94/** The most urgent first. */
95export const STATES = ['waiting', 'unread', 'running'] as const
96
97export function baseName(path: string): string {
98 return path.split('/').filter(Boolean).at(-1) ?? path
99}
100
101export const RECENT_COUNT = 5
102
103/**
104 * Groups sessions by project folder, most recently focused project first: each
105 * with its last focused session, its most recently active ones, and how many
106 * of them wait, are unread or run.
107 */
108export function groupProjects(
109 sessions: readonly SessionMeta[],
110 live: ReadonlyMap<string, LiveSession> = new Map(),
111 ownSessionId: string | null = null,
112): Project[] {
113 const byPath = new Map<string, { project: Project; active: (SessionSummary & { at: number })[] }>()
114 for (const s of sessions) {
115 const entry = byPath.get(s.path) ?? {
116 project: {
117 path: s.path,
118 name: baseName(s.path),
119 lastSessionId: null,
120 lastFocusedAt: 0,
121 sessionCount: 0,
122 recent: [],
123 counts: { waiting: 0, unread: 0, running: 0 },
124 },
125 active: [],
126 }
127 byPath.set(s.path, entry)
128 if (s.isArchived) continue
129 const p = entry.project
130 p.sessionCount += 1
131 if (p.lastSessionId === null || s.lastFocusedAt > p.lastFocusedAt) {
132 p.lastSessionId = s.sessionId
133 p.lastFocusedAt = s.lastFocusedAt
134 }
135 const l = live.get(s.sessionId)
136 const state = stateOf(s, l, ownSessionId)
137 if (state !== 'idle') p.counts[state] += 1
138 const at = Math.max(s.lastActivityAt, s.lastFocusedAt, l?.statusUpdatedAt ?? 0)
139 entry.active.push({ id: s.sessionId, title: s.title, state, at })
140 }
141 return [...byPath.values()]
142 .map(({ project, active }) => ({
143 ...project,
144 recent: active
145 .sort((a, b) => b.at - a.at)
146 .slice(0, RECENT_COUNT)
147 .map(({ id, title, state }) => ({ id, title, state })),
148 }))
149 .sort((a, b) => b.lastFocusedAt - a.lastFocusedAt)
150}
151
152export function sessionUrl(sessionId: string): string {
153 return `claude://code/continue?session=${sessionId}&source=project-tabs`
154}
155
156/** The deep link a tab opens: the project's last session, or a new one in its folder. */
157export function tabUrl(project: Pick<Project, 'path' | 'lastSessionId'>): string {
158 return project.lastSessionId !== null
159 ? sessionUrl(project.lastSessionId)
160 : `claude://code/new?folder=${encodeURIComponent(project.path)}&source=project-tabs`
161}
162
163/** An icon the person put in the project for this mod, before any favicon. */
164export const OVERRIDE_CANDIDATES = ['.claude/icon.svg', '.claude/icon.png'] as const
165
166/** Folders a favicon never lives in, or that are too big to walk. */
167export const SKIP_DIRS = new Set([
168 'node_modules',
169 '.git',
170 '.next',
171 '.nuxt',
172 '.svelte-kit',
173 '.turbo',
174 '.cache',
175 '.vercel',
176 'dist',
177 'build',
178 'out',
179 'coverage',
180 'vendor',
181 'target',
182 'Pods',
183 '.venv',
184 'venv',
185 '__pycache__',
186 'legacy',
187 'tmp',
188])
189
190const ICON_NAME = /^(?:favicon(?:-\d+x\d+)?|icon|apple-touch-icon|apple-icon)\.(svg|png|ico)$/i
191
192/**
193 * How good a file is as the project's icon, lower is better; undefined when it
194 * is none. SVG beats PNG beats ICO, a shallower file beats a deeper one, and a
195 * file in a web root (public, app, static) beats one elsewhere.
196 */
197export function iconRank(relPath: string): number | undefined {
198 const parts = relPath.split('/')
199 const file = parts.at(-1) ?? ''
200 const ext = ICON_NAME.exec(file)?.[1]?.toLowerCase()
201 if (ext === undefined) return undefined
202 const byExt = ext === 'svg' ? 0 : ext === 'png' ? 1 : 2
203 const byName = file.toLowerCase().startsWith('favicon') ? 0 : 1
204 const dir = parts.at(-2)
205 const inWebRoot = dir === undefined || ['public', 'app', 'static', 'assets'].includes(dir)
206 return (parts.length - 1) * 10 + (inWebRoot ? 0 : 5) + byExt * 2 + byName
207}
208
209const MIME: Record<string, string> = {
210 svg: 'image/svg+xml',
211 png: 'image/png',
212 ico: 'image/x-icon',
213 jpg: 'image/jpeg',
214 jpeg: 'image/jpeg',
215 webp: 'image/webp',
216 gif: 'image/gif',
217}
218
219export function mimeOf(path: string): string | undefined {
220 return MIME[path.split('.').at(-1)?.toLowerCase() ?? '']
221}
222
223/** A data URI of an image file's bytes, what an icon draws. */
224export function dataUri(base64: string, mime: string): string {
225 return `data:${mime};base64,${base64}`
226}
227
228/** The tab's icon box: 20px inside 4px of padding, 28px in all. */
229export const TAB_HEIGHT = 28
230const PAD = 4
231const ICON = 20
232/**
233 * The desktop rounds a Box's background about 6px; strictly concentric, 4px in,
234 * the icon would take 2px, which reads square. 6px sits better beside it.
235 */
236const RADIUS = 6
237
238const svgOpen = (width: number, height: number) =>
239 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
240
241export type IconSource =
242 { kind: 'image'; uri: string } | { kind: 'emoji'; text: string } | { kind: 'letter'; name: string }
243
244/** The tab's icon, padded to the tab's height: an image, an emoji, or the name's letter on a square. */
245export function tabIconSvg(icon: IconSource): string {
246 const box = `x="${PAD}" y="${PAD}" width="${ICON}" height="${ICON}"`
247 const mid = PAD + ICON / 2
248 let body: string
249 if (icon.kind === 'image') {
250 body =
251 `<defs><clipPath id="r"><rect ${box} rx="${RADIUS}"/></clipPath></defs>` +
252 `<image href="${icon.uri}" ${box} preserveAspectRatio="xMidYMid meet" clip-path="url(#r)"/>`
253 } else if (icon.kind === 'emoji') {
254 body = `<text x="${mid}" y="${mid}" font-size="14" text-anchor="middle" dominant-baseline="central">${escapeXml(icon.text)}</text>`
255 } else {
256 body =
257 `<rect ${box} rx="${RADIUS}" fill="${letterColor(icon.name)}"/>` +
258 `<text x="${mid}" y="${mid}" font-size="12" font-weight="600" font-family="-apple-system, Helvetica, Arial, sans-serif" text-anchor="middle" dominant-baseline="central" fill="#fff">${escapeXml(letterOf(icon.name))}</text>`
259 }
260 return `${svgOpen(PAD + ICON + PAD, TAB_HEIGHT)}${body}</svg>`
261}
262
263/** How wide the tab's shortcut digit is drawn, with 6px before it. */
264export const DIGIT_WIDTH = 16
265
266/**
267 * The tab's shortcut digit, dim, at the tab's height: 6px from the tab's edge,
268 * then the icon's own 4px of padding after it.
269 */
270export function digitSvg(digit: number): string {
271 const style = '<style>.d{fill:#8a8983}@media (prefers-color-scheme: dark){.d{fill:#8b8a84}}</style>'
272 return (
273 `${svgOpen(DIGIT_WIDTH, TAB_HEIGHT)}${style}` +
274 `<text class="d" x="${DIGIT_WIDTH - 2}" y="${TAB_HEIGHT / 2}" font-size="12" font-weight="500" font-family="-apple-system, Helvetica, Arial, sans-serif" text-anchor="end" dominant-baseline="central">${digit}</text></svg>`
275 )
276}
277
278/** The desktop's own dot colors (its CSS): light theme, then dark. */
279const DOT_COLORS: Record<SessionState, [string, string]> = {
280 waiting: ['#a66a00', '#c98500'],
281 unread: ['#2a78d6', '#5598e7'],
282 running: ['#8a8a86', '#77766f'],
283 idle: ['#b5b4ad', '#5c5b56'],
284}
285
286export const DOT_ROW_HEIGHT = 26
287
288/** The count pill's fill and ring per state, from the desktop's dot colors: [fill, ring]. */
289const PILL_COLORS: Record<SessionState, { light: [string, string]; dark: [string, string] }> = {
290 waiting: { light: ['#c98500', '#a66a00'], dark: ['#a66a00', '#c98500'] },
291 unread: { light: ['#3987e5', '#2a78d6'], dark: ['#2a78d6', '#5598e7'] },
292 running: { light: ['#8a8a86', '#73726c'], dark: ['#5c5b56', '#8a8a86'] },
293 idle: { light: ['#a3a29c', '#8f8e88'], dark: ['#3a3936', '#4f4e4a'] },
294}
295
296/**
297 * The session count as a pill the height of the icon and of the name's own
298 * highlight, filled and ringed in the state's color, its number white: 4px
299 * from the name and 4px from the tab's edge, as the icon sits. A running
300 * session's pill pulses, as the sidebar's dot does.
301 */
302export function countPillSvg(count: number, state: SessionState): string {
303 const text = count > 999 ? '999+' : String(count)
304 const pill = Math.max(ICON, 12 + Math.ceil(text.length * 7))
305 const width = PAD + pill + PAD
306 const { light, dark } = PILL_COLORS[state]
307 const style =
308 `<style>.b{fill:${light[0]};stroke:${light[1]}}` +
309 `@media (prefers-color-scheme: dark){.b{fill:${dark[0]};stroke:${dark[1]}}}</style>`
310 const pulse =
311 state === 'running'
312 ? '<animate attributeName="opacity" values="1;0.55;1" dur="1.6s" repeatCount="indefinite"/>'
313 : ''
314 return (
315 `${svgOpen(width, TAB_HEIGHT)}${style}` +
316 `<rect class="b" x="${PAD + 0.5}" y="${PAD + 0.5}" width="${pill - 1}" height="${ICON - 1}" rx="${RADIUS - 0.5}" stroke-width="1">${pulse}</rect>` +
317 `<text x="${PAD + pill / 2}" y="${TAB_HEIGHT / 2}" font-size="12" font-weight="400" font-family="-apple-system, Helvetica, Arial, sans-serif" text-anchor="middle" dominant-baseline="central" fill="#ffffff">${text}</text></svg>`
318 )
319}
320
321/** How wide the count pill is drawn, with its padding. */
322export function countPillWidth(count: number): number {
323 const text = count > 999 ? '999+' : String(count)
324 return PAD + Math.max(ICON, 12 + Math.ceil(text.length * 7)) + PAD
325}
326
327/**
328 * A session's dot as the sidebar draws it, at the height of a row of the list:
329 * filled in its state's color (a running one pulses), a ring when idle.
330 */
331export function dotSvg(state: SessionState): string {
332 const [light, dark] = DOT_COLORS[state]
333 const paint = state === 'idle' ? 'fill:none;stroke:' : 'fill:'
334 const style = `<style>.d{${paint}${light}}@media (prefers-color-scheme: dark){.d{${paint}${dark}}}</style>`
335 const pulse =
336 state === 'running'
337 ? '<animate attributeName="opacity" values="1;0.35;1" dur="1.6s" repeatCount="indefinite"/>'
338 : ''
339 const cy = DOT_ROW_HEIGHT / 2
340 return `${svgOpen(12, DOT_ROW_HEIGHT)}${style}<circle class="d" cx="6" cy="${cy}" r="${state === 'idle' ? 3.5 : 4}" stroke-width="1.5">${pulse}</circle></svg>`
341}
342
343function escapeXml(text: string): string {
344 return text.replace(/[<>&"']/g, c => `&#${c.charCodeAt(0)};`)
345}
346
347const PALETTE = ['#d97757', '#6a9bcc', '#788c5d', '#b07cc6', '#c2a14a', '#5aa5a0', '#c76b8e', '#7d7fd1']
348
349/** The fallback icon's color, picked from the project's name. */
350export function letterColor(name: string): string {
351 let hash = 0
352 for (const ch of name) hash = (hash * 31 + ch.codePointAt(0)!) >>> 0
353 return PALETTE[hash % PALETTE.length]!
354}
355
356/** The fallback icon's letter: the name's first letter or digit. */
357export function letterOf(name: string): string {
358 return (name.match(/[\p{L}\p{N}]/u)?.[0] ?? '?').toUpperCase()
359}
360
361/** An icon override is an image when it is a data URI or names a file; anything else is drawn as text. */
362export function isImagePath(icon: string): boolean {
363 return icon.startsWith('data:image/') || (/^(~|\/)/.test(icon) && mimeOf(icon) !== undefined)
364}
365
366/** Moves the entry at `index` by `delta` places, clamped to the list. */
367export function move<T>(list: readonly T[], index: number, delta: number): T[] {
368 const to = Math.max(0, Math.min(list.length - 1, index + delta))
369 const out = [...list]
370 const [item] = out.splice(index, 1)
371 if (item !== undefined) out.splice(to, 0, item)
372 return out
373}
374
375/** The state colors as text draws them, where no SVG carries a scheme. */
376export const STATE_TEXT_COLORS: Record<SessionState, string> = {
377 waiting: '#c98500',
378 unread: '#3987e5',
379 running: '#8a8a86',
380 idle: '#8a8a86',
381}
382
383/** The most urgent state among a project's sessions. */
384export function topState(counts: Project['counts']): SessionState {
385 return STATES.find(st => counts[st] > 0) ?? 'idle'
386}
387
388/** Emoji offered as icons, one press each. */
389export const EMOJI_CHOICES = [
390 '🚀',
391 '⭐',
392 '🔥',
393 '💡',
394 '🛒',
395 '📦',
396 '🎨',
397 '🎮',
398 '🧪',
399 '🤖',
400 '📱',
401 '🌐',
402 '💰',
403 '📊',
404 '🍔',
405 '🏠',
406 '🎵',
407 '📚',
408 '⚙️',
409 '🧩',
410] as const
411types/index.d.ts 45 lines1/** The interface languages: the desktop's own, where the mod speaks it. */
2export type Lang = 'en' | 'ru' | 'uk' | 'de' | 'fr' | 'it' | 'es'
3
4/** Which key, with a digit, switches tabs while Claude Desktop is in front. */
5export type HotkeyModifier = 'off' | 'control' | 'command'
6
7/** What a session wants of the person, as the desktop's sidebar dot says it. */
8export type SessionState = 'waiting' | 'unread' | 'running' | 'idle'
9
10export type SessionSummary = { id: string; title: string; state: SessionState }
11
12export type Project = {
13 /** The project folder: the sessions' originCwd. */
14 path: string
15 name: string
16 /** The most recently focused session that is not archived. */
17 lastSessionId: string | null
18 lastFocusedAt: number
19 sessionCount: number
20 /** The most recently active sessions that are not archived, newest first. */
21 recent: SessionSummary[]
22 /** How many of the project's sessions wait, are unread, run. */
23 counts: { waiting: number; unread: number; running: number }
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'project-tabs': {
29 projects: Project[]
30 pinned: string[]
31 /** Per project path: an emoji, or a path to an image file. */
32 icons: Record<string, string>
33 /** Per project path: its icon image as a data URI, null when none was found. */
34 favicons: Record<string, string | null>
35 current: string
36 /** The pinned project whose icon field is open in the pane. */
37 editing: string | null
38 /** The interface language, as the desktop's settings set it. */
39 lang: Lang
40 /** The tab shortcuts' modifier; 'off' when they are not set up. */
41 hotkeys: HotkeyModifier
42 }
43 }
44}
45