SLOPSHOPPER

project-tabs

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…

newpanebandcommandtoastprocess
v0.6.2Apache-2.0updated 2026-10-07tripolskiydigital/claude-tabs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-tabs
│ ┃ Project tabs ✕ › fix the failing auth test and add an audit log call │ ┃ Keyboard shortcuts [ Off ] [ ⌃1–9 ] [ ⌘1–9 ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Pinned [ Refresh icons ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Nothing yet: pin a project from the list ⎿ Added 2 lines, removed 1 line │ ┃ below. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Other projects │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tabs │ ⎿ project-tabs: Opened the project tabs pane. │ │ No pinned projects [ 📌 Pin this project ] [ ⋯ ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
No pinned projects [ 📌 Pin this project ] [ ⋯ ] ⟨Claude Code's own drawing⟩
Pane · Project tabs
Keyboard shortcuts [ Off ] [ ⌃1–9 ] [ ⌘1–9 ] Pinned [ Refresh icons ] Nothing yet: pin a project from the list below. Other projects
README

Claude Tabs — project tabs for Claude Desktop

English · Русский

Project tabs above the prompt in Claude Desktop

A mod for the Code tab of Claude Desktop (macOS) that turns your projects into horizontal tabs above the prompt, like browser tabs:

  • Pinned projects as tabs, each with its icon: the project's own favicon, an emoji, a picture you choose, or a colored letter.
  • One click opens the project's last session, the one you were in most recently. A project with no session yet opens a new one in its folder.
  • ⌃1…⌃9 (or ⌘1…⌘9) switch tabs while Claude is in front.
  • Live session status, colored like the desktop's sidebar dots: the session count on each tab turns amber when a session waits for you (a permission or a question), blue when one finished and is unread, grey while one runs.
  • Hover a tab to see its 5 most recent sessions with their status dots, and jump straight to any of them.
  • A settings pane (the ⋯ button or /tabs): keyboard shortcuts, pin and unpin projects, reorder them, pick icons (file picker, emoji, or automatic), refresh icons.
  • Speaks your language: English, Deutsch, Français, Italiano, Español, Українська, Русский, following Claude Desktop's own language setting; English for every other language.

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>

Requirements

  • macOS and Claude Desktop with mods support (Claude Code engine 2.1.289 or later, shipped with Claude Desktop 2.26454 and later).
  • For keyboard shortcuts: Xcode Command Line Tools (xcode-select --install), to build the small helper that listens for the keys.
  • The mod draws in Claude Desktop's Code tab. It also loads in the terminal claude, where it draws a simpler text version of the tabs.

Install

From the marketplace (recommended)

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

From a clone

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.

Using it

  1. Click 📌 Pin this project in the band above the prompt, or open the pane with ⋯ and pin projects from the list. The list shows every folder that has had a Code session in Claude Desktop.
  2. Click a tab to open that project's last session. Hover it for its recent sessions.
  3. In the pane, ✎ opens a project's icon editor: Choose file… (PNG, JPG, SVG and the rest; pictures are scaled to 64px and copied, so moving the original is fine), Automatic, or an emoji. Refresh icons looks for favicons again.

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.

Keyboard shortcuts

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.

How it works

Everything stays on your Mac; the mod makes no network requests.

WhatWhere 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 languagethe 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 sessionsthe desktop's deep link claude://code/continue?session=…, opened with open
Pins and chosen iconsthe 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 reads, writes and runs

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

  • Claude Desktop's session records, ~/Library/Application Support/Claude/claude-code-sessions/**/local_*.json: each session's folder, title, archive flag and times.
  • Claude Code's running-session files, ~/.claude/sessions/*.json: each running session's status.
  • Pinned projects' folders, up to four levels deep, for a favicon (favicon.*, icon.*, apple-touch-icon.* and a project's own .claude/icon.*), skipping node_modules, .git, build output and the like.
  • A picture you choose as an icon.
  • The environment variables 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:

ProgramWhenWhy
open claude://code/…you click a tab or a recent session, or press a shortcutswitches Claude Desktop to that session
ps -A -o pid=every 4 secondstells running sessions from files left by sessions that crashed
grep -o -m 1 "locale"… <home>/Library/Application Support/Claude/config.jsonat session start, and when Claude's settings changepicks 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.pngafter you choose a picturescales it to 64px
swiftc -O -o <home>/.claude/project-tabs/tabs-hotkeys <plugin>/helper/tabs-hotkeys.swiftyou switch shortcuts on, or the plugin updates while they are onbuilds 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.hotkeysshortcuts on, at session start, shortcuts offstarts, 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.

Limits

  • Mods can't change the app's own window: the tabs live above the prompt of each session, and the sidebar stays.
  • Session status comes from processes running on this Mac; a session whose process has ended shows no status.
  • The look follows Claude Desktop's current UI; a large redesign of the app may need an update of the mod.

Uninstall

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.

Development

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.

License

Apache 2.0

Source 5 files
hooks/register.tsx 920 lines
1import { 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}
920
hooks/hotkeys.ts 40 lines
1/**
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}
40
hooks/i18n.ts 271 lines
1import 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}
271
hooks/lib.ts 411 lines
1import 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
411
types/index.d.ts 45 lines
1/** 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