SLOPSHOPPER

env-tabs

Pin projects in Claude Desktop's Code tab: a Projects pane with the pinned ones on top, and a tab above the prompt for each.

panebandguardcommandtoasttool
★ 1v0.3.7MITupdated 2026-10-18ahmtsahin/claude-env-tabs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · env-tabs
│ ┃ Projects ✕ › fix the failing auth test and add an audit log call │ ┃ Pinned │ ┃ Pin a project to keep it here. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Sessions could not be read: TypeError: ⏺ Update(src/auth.ts) │ ┃ undefined is not an object (evaluating ⎿ Added 2 lines, removed 1 line │ ┃ 'blocks.map') ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /env-tabs │ ⎿ env-tabs: No pinned folders yet. Run /env-tabs pin in a session │ │ + Pin this folder Projects ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
+ Pin this folder Projects
Pane · Projects
Pinned Pin a project to keep it here. Sessions could not be read: TypeError: undefined is not an object (evaluating 'blocks.map')
README

env-tabs

Your projects, pinned in Claude Desktop's Code tab

A Projects pane with your pinned projects on top, live session states like the sidebar's dots,<br> and a tab above the prompt for every project you pin.

Claude Desktop mod Platform No network requests License: MIT

<img src="docs/images/hero-light.svg" width="100%" alt="Claude Desktop's Code tab with the env-tabs Projects pane docked on the right, pinned projects on top with session dots and counts, and project tabs above the prompt">

<sub>Demo projects. The pictures are drawn by <a href="docs/demo/render.mjs">docs/demo/render.mjs</a> with the mod's own icons and colors.</sub>

Why

The Code tab's sidebar sorts its folder groups by itself, by recent activity or A to Z, and there is no way to keep the projects you use most at the top. Plenty of people have asked for one, on Windows and on macOS: #70104, #66196, #102088.

Neither a mod nor Claude can pin a folder group in the app's sidebar, so env-tabs draws a Projects pane of its own beside the conversation, and tabs above the prompt. Your sessions are not moved or filed anywhere: the app's sidebar stays exactly as it is.

What you get

A Projects pane, pinned projects on top

<img src="docs/images/pane-light.svg" width="760" alt="The Projects pane: pinned projects on top with icons, counts and session dots, the other recent projects below, a row showing + New and Pin under the pointer, and This computer at the bottom">

  • Pinned projects stay on top, in the order of their tabs. Every other recent project follows, the latest first.
  • Every project has an icon: its first letter on a color of its own, which a rename keeps. The project you are in is highlighted.
  • Click a project to show or hide its sessions, click a session to open it.
  • Point at a project's row for + New, which starts a session in that folder, and Pin or Unpin.
  • Projects are folders, like in the sidebar: a project inside a pinned folder keeps its own row, and a session in one of a repository's worktrees is listed under the repository.
  • This computer gathers the sessions you started without choosing a folder.

Session states, like the sidebar's dots

StateWhat it means
<img src="docs/images/dot-waiting.svg" width="14" alt="amber dot">Waiting for youThe session needs a permission or an answer.
<img src="docs/images/dot-unread.svg" width="14" alt="blue dot">UnreadIt finished after you last looked at it.
<img src="docs/images/dot-running.svg" width="14" alt="grey dot">RunningClaude is working; the dot pulses.
<img src="docs/images/dot-idle.svg" width="14" alt="grey ring">IdleNothing new.

A project's row and its tab count the sessions that need you: the ones waiting for you or unread, in amber while one waits. A project with a session running and nothing to count shows the running dot. The states refresh every few seconds, and the session you are in is never shown as unread.

A tab above the prompt for every pinned project

<img src="docs/images/tabs-light.svg" width="100%" alt="Project tabs above the prompt: orbit-api highlighted with an amber count of 2, pixel-forge with a blue 1, northwind-web running, atlas-cli, and a Projects button">

  • One click opens the project's latest session, or starts one in its folder when it has none yet.
  • The tab of the project you are in is filled, and every tab carries the project's icon and its count.
  • In a project you have not pinned, + Pin this folder pins it right there; Projects opens the pane.

Install

At the prompt of a terminal session:

/plugin install env-tabs --marketplace ahmtsahin/claude-env-tabs

Or from a shell:

claude plugin install env-tabs --marketplace ahmtsahin/claude-env-tabs

Answer y to add the marketplace and pick the user scope, so the pane and the tabs show in every session.

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ahmtsahin/claude-env-tabs, then Add plugin.

[!NOTE] Requires Claude Desktop with mod support. Built and tested on Windows 11 with Claude Desktop (Claude Code engine 2.1.299). macOS runs the same code, opening sessions with open instead of rundll32 and reading the session states from ~/Library/Application Support/Claude; the tests cover both, but it has not been tried on a Mac yet.

Use

  • The pane opens by itself when the window has room beside the conversation. Otherwise click Projects above the prompt, or run /env-tabs panel. Close it and it stays closed in later sessions until you open it again.
  • Pin a project with Pin on its row in the pane, or + Pin this folder above the prompt in one of its sessions.
  • Or just ask Claude: "pin this project", "move orbit-api to the first tab", "unpin lumen-docs".
  • Or use the command:
CommandWhat it does
/env-tabsLists the pinned projects
/env-tabs pin [folder]Pins this session's project, or the folder given
/env-tabs unpin <n>Unpins tab n (or the tab with that label)
/env-tabs move <n> <to>Moves tab n to position to, in the pane too
/env-tabs rename <n> <label>Renames tab n and its project in the pane
/env-tabs panelOpens the Projects pane
/env-tabs statusShows what env-tabs sees, and where it reads the states from

Good to know

  • Projects are folders. A session is listed under the folder it runs in, as in the sidebar, so pinning D:\Code does not gather D:\Code\api under it. Pin that one too.
  • This computer. A session started without a folder runs in a scratch folder the app makes for it. The pane lists all of them in one row at the bottom of Projects; there is no folder to start a session in or to pin, so that row has no + New or Pin.
  • Recent sessions only. env-tabs reads your 60 most recent sessions, since the app caps the session list it hands to a mod. A project with no session among them is listed once you pin it, and the pane shows up to 5 sessions per project.
  • New sessions. + New and a tab with no session open the app's documented link, claude://code/new?folder=.... The app asks you to confirm the folder each time (Trust workspace), since it does not trust a folder that comes from a link. Now and then it still starts the session in a new scratch folder; its Environment card then shows Choose folder…, so choose the folder there before you send the first message.
  • Per computer. Pins are kept on the computer where you made them.

What it reads, runs and keeps

Everything stays on your computer: env-tabs makes no network requests.

WhatWhy
ReadsThe app's session list, through the app's own local toolsThe projects and their sessions
ReadsEach running session's file in ~/.claude/sessionsBusy, idle, or waiting for you
ReadsThe desktop's record of each sessionWhen you last looked at it, when it last did something
Runsrundll32 url.dll,FileProtocolHandler claude://… on Windows, open claude://… on macOSOpening a session, or starting one in a folder
KeepsYour pins, and which projects you folded, in the plugin's own storeSo they are there in the next session

Develop

claude plugin validate .
claude plugin test .

To run a working copy, start a session with claude --plugin-dir <this folder>, or name the folder in CLAUDE_CODE_PLUGIN_DIRS. The pictures in this README are drawn by node docs/demo/render.mjs.

Issues and pull requests are welcome.

If env-tabs saves you a few clicks a day, a ⭐ helps other people find it.

MIT

Source 2 files
hooks/register.tsx 1340 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { Pin, SessionRow, SessionState } from '../types'
5
6const pins = atom({ plugin: 'env-tabs', key: 'pins' } as const, [])
7const sessions = atom({ plugin: 'env-tabs', key: 'sessions' } as const, [])
8const thisSession = atom({ plugin: 'env-tabs', key: 'thisSession' } as const, null)
9const here = atom({ plugin: 'env-tabs', key: 'here' } as const, '')
10const flipped = atom({ plugin: 'env-tabs', key: 'flipped' } as const, [])
11const states = atom({ plugin: 'env-tabs', key: 'states' } as const, {})
12const probe = atom({ plugin: 'env-tabs', key: 'probe' } as const, null)
13
14const STORE_KEY = 'pins'
15const FLIPPED_KEY = 'flipped'
16const CLOSED_KEY = 'paneClosed'
17const REFRESH_MS = 60_000
18// How often the sessions' states are read again: a session waiting on the
19// person should show soon.
20const STATES_MS = 4_000
21// The engine caps an MCP answer's size, so a long session list is asked for
22// in a smaller size when the larger one is refused.
23const LIST_LIMITS = [60, 30, 15]
24const TOO_LARGE = /exceeds maximum allowed tokens/i
25const TOOL = 'mcp__env-tabs__tabs'
26const PANE = 'projects'
27const PANE_TITLE = 'Projects'
28// The most sessions an open project lists in the pane.
29const SHOWN = 5
30
31const HELP = [
32  'Usage: /env-tabs [list | pin [folder] | unpin <n> | move <n> <to> | rename <n> <label> | panel | status]',
33  "  pin     pins this session's project (or the folder given): a tab, and a place at the top of the Projects pane",
34  '  unpin   removes tab <n> (or the tab with that label)',
35  '  move    moves tab <n> to position <to>',
36  '  rename  sets the label of tab <n>',
37  '  panel   opens the Projects pane: the pinned projects first, then the rest, each with its sessions',
38  '  status  shows what the tabs see',
39].join('\n')
40
41// Paths: Windows paths compare without case and with either slash.
42
43const isWindowsPath = (path: string) =>
44  /^[A-Za-z]:([\\/]|$)/.test(path) || path.startsWith('\\\\')
45
46const normalize = (path: string) => {
47  const trimmed = path.trim()
48
49  return isWindowsPath(trimmed)
50    ? trimmed.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
51    : trimmed.replace(/\/+$/, '')
52}
53
54const baseName = (path: string) =>
55  path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
56
57// Projects as the sidebar groups sessions: a session belongs to the folder it
58// runs in, so a folder inside a pinned one is a project of its own. A session
59// in one of a repository's worktrees belongs to the repository.
60const WORKTREE = /[\\/]\.claude[\\/]worktrees([\\/].*)?$/i
61
62// The app runs a session started without a folder in a scratch folder it makes
63// for that session; all of them are one project, This computer.
64const SCRATCH = /[\\/]Claude[\\/]scratch-workspaces[\\/]/i
65const SCRATCH_KEY = 'scratch'
66const SCRATCH_LABEL = 'This computer'
67
68const isScratch = (folder: string) => SCRATCH.test(folder)
69
70const projectFolder = (folder: string) => folder.replace(WORKTREE, '')
71
72/** The project a folder belongs to, by key. */
73const keyOf = (folder: string) =>
74  isScratch(folder) ? SCRATCH_KEY : normalize(projectFolder(folder))
75
76/** The pin of a folder's project; a scratch folder has none. */
77const pinOf = (folder: string, list: readonly Pin[]) =>
78  isScratch(folder) ? undefined : list.find(pin => keyOf(pin.folder) === keyOf(folder))
79
80// State and store. Each write skips a value that has not changed, so the band
81// and the pane redraw only on news.
82
83const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
84
85const setPins = async ($: EngineInterface, list: Pin[]) => {
86  if (!same(await read($, pins), list)) {
87    await update($, pins, () => list)
88  }
89}
90
91const setSessions = async ($: EngineInterface, rows: SessionRow[]) => {
92  if (!same(await read($, sessions), rows)) {
93    await update($, sessions, () => rows)
94  }
95}
96
97const setThisSession = async ($: EngineInterface, row: SessionRow | null) => {
98  if (!same(await read($, thisSession), row)) {
99    await update($, thisSession, () => row)
100  }
101}
102
103const setHere = async ($: EngineInterface, folder: string) => {
104  if ((await read($, here)) !== folder) {
105    await update($, here, () => folder)
106  }
107}
108
109const setFlipped = async ($: EngineInterface, keys: string[]) => {
110  if (!same(await read($, flipped), keys)) {
111    await update($, flipped, () => keys)
112  }
113}
114
115const setStates = async ($: EngineInterface, value: Record<string, SessionState>) => {
116  if (!same(await read($, states), value)) {
117    await update($, states, () => value)
118  }
119}
120
121const toPins = (value: unknown): Pin[] => {
122  if (!Array.isArray(value)) {
123    return []
124  }
125
126  return value.flatMap(item => {
127    const pin = item as Partial<Pin> | null
128
129    return typeof pin?.folder === 'string' && typeof pin.label === 'string'
130      ? [{ folder: pin.folder, label: pin.label }]
131      : []
132  })
133}
134
135const toKeys = (value: unknown): string[] =>
136  Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
137
138const loadPins = async ($: EngineInterface) => toPins(await $.store.get(STORE_KEY))
139
140const savePins = async ($: EngineInterface, list: Pin[]) => {
141  await $.store.set(STORE_KEY, list)
142  await setPins($, list)
143}
144
145/**
146 * Flips a project in the pane: open where it starts closed, closed where it
147 * starts open. A pinned project starts open, any other closed.
148 */
149const setFlip = async ($: EngineInterface, key: string, isFlipped?: boolean) => {
150  const keys = toKeys(await $.store.get(FLIPPED_KEY))
151  const wanted = isFlipped ?? !keys.includes(key)
152  const changed = keys.filter(one => one !== key).concat(wanted ? [key] : [])
153  await $.store.set(FLIPPED_KEY, changed)
154  await setFlipped($, changed)
155}
156
157// The desktop app's own tools, reached through its MCP servers.
158
159const textOf = (blocks: readonly { text?: string }[]) =>
160  blocks.map(block => block.text ?? '').join('')
161
162const parseJson = (text: string): unknown => {
163  try {
164    return JSON.parse(text)
165  } catch {
166    return undefined
167  }
168}
169
170/** Calls one of the app's tools: its text answer, or a throw with the app's reason. */
171const callApp = async (
172  $: EngineInterface,
173  server: string,
174  tool: string,
175  args: Record<string, unknown>,
176) => {
177  const answer = await $.mcp.call(server, tool, args)
178  const text = textOf(answer.content)
179  if (answer.isError) {
180    throw new Error(text)
181  }
182
183  return text
184}
185
186type ListedSession = {
187  sessionId?: string
188  title?: string
189  cwd?: string
190  link?: string
191  isRunning?: boolean
192  lastActivityAt?: string
193}
194
195const toRow = (item: unknown): SessionRow | undefined => {
196  const row = item as ListedSession | null
197
198  return typeof row?.sessionId === 'string' && typeof row.cwd === 'string'
199    ? {
200        id: row.sessionId,
201        title: row.title ?? '',
202        folder: row.cwd,
203        link: row.link ?? null,
204        isRunning: row.isRunning === true,
205        lastActivityAt: row.lastActivityAt ?? '',
206      }
207    : undefined
208}
209
210const toRows = (text: string): SessionRow[] => {
211  const parsed = parseJson(text)
212  if (!Array.isArray(parsed)) {
213    throw new Error(`list_sessions answered: ${text.slice(0, 200)}`)
214  }
215
216  return parsed.flatMap(item => toRow(item) ?? [])
217}
218
219const listSessions = async ($: EngineInterface) => {
220  let text = ''
221  for (const limit of LIST_LIMITS) {
222    try {
223      text = await callApp($, 'ccd_session_mgmt', 'list_sessions', { limit })
224    } catch (error) {
225      text = String(error)
226      if (!TOO_LARGE.test(text)) {
227        throw error
228      }
229      continue
230    }
231    if (!TOO_LARGE.test(text)) {
232      return toRows(text)
233    }
234  }
235
236  throw new Error(text.slice(0, 200))
237}
238
239/** This session, which list_sessions leaves out; null when the app does not say. */
240const readThisSession = async ($: EngineInterface) => {
241  try {
242    const text = await callApp($, 'ccd_session_mgmt', 'get_session', { session_id: 'self' })
243    return toRow(parseJson(text)) ?? null
244  } catch {
245    return null
246  }
247}
248
249// The sessions' states, as the desktop's sidebar dots show them. Each running
250// session writes a file of its own under the Claude configuration folder:
251// busy, idle, or waiting on the person. The desktop keeps a record of every
252// session with when the person last looked at it and when it last did
253// something: one that did something since is unread, running or not.
254
255/** Focus written a moment before a turn's end still counts as seen. */
256const UNREAD_SLACK_MS = 2_000
257
258type Live = { host: string; status: string; at: number; updatedAt: number }
259type Seen = { focusedAt: number; activityAt: number }
260type Cached = { stamp: string; value: unknown }
261
262const join = (...parts: string[]) => parts.join(isWindowsPath(parts[0] ?? '') ? '\\' : '/')
263
264const numberOf = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
265
266const fieldsOf = (text: string) => {
267  const value = parseJson(text)
268  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
269}
270
271const toLive = (text: string): Live | undefined => {
272  const file = fieldsOf(text)
273  return typeof file?.hostSessionId === 'string' && typeof file.status === 'string'
274    ? {
275        host: file.hostSessionId,
276        status: file.status,
277        at: numberOf(file.statusUpdatedAt),
278        updatedAt: numberOf(file.updatedAt),
279      }
280    : undefined
281}
282
283const toSeen = (text: string): Seen | undefined => {
284  const file = fieldsOf(text)
285  return file === undefined
286    ? undefined
287    : { focusedAt: numberOf(file.lastFocusedAt), activityAt: numberOf(file.lastActivityAt) }
288}
289
290const homeOf = async ($: EngineInterface) =>
291  (await $.env.get('OS')) === 'Windows_NT' ? $.env.get('USERPROFILE') : $.env.get('HOME')
292
293/** The folder each running session writes its own file into. */
294const liveDirOf = async ($: EngineInterface) => {
295  const config = await $.env.get('CLAUDE_CONFIG_DIR')
296  const home = await homeOf($)
297
298  return config ? join(config, 'sessions') : home ? join(home, '.claude', 'sessions') : null
299}
300
301const isDir = async ($: EngineInterface, path: string) => $.fs.exists(path).catch(() => false)
302
303/**
304 * The folder of the desktop's session records: in its own folder of the
305 * user's application data, or, for the Microsoft Store build, in its package.
306 */
307const recordsDirOf = async ($: EngineInterface) => {
308  const tail = ['Claude', 'claude-code-sessions']
309  if ((await $.env.get('OS')) === 'Windows_NT') {
310    const appData = await $.env.get('APPDATA')
311    if (appData && (await isDir($, join(appData, ...tail)))) {
312      return join(appData, ...tail)
313    }
314    const localAppData = await $.env.get('LOCALAPPDATA')
315    const packages = localAppData ? join(localAppData, 'Packages') : null
316    for (const entry of packages === null ? [] : await $.fs.list(packages).catch(() => [])) {
317      const dir = join(packages ?? '', entry.name, 'LocalCache', 'Roaming', ...tail)
318      if (entry.kind === 'dir' && entry.name.startsWith('Claude_') && (await isDir($, dir))) {
319        return dir
320      }
321    }
322    return null
323  }
324  const home = await homeOf($)
325  const config = (await $.env.get('XDG_CONFIG_HOME')) || (home ? join(home, '.config') : '')
326  const candidates = [home ? join(home, 'Library', 'Application Support', ...tail) : '', config ? join(config, ...tail) : '']
327  for (const dir of candidates) {
328    if (dir !== '' && (await isDir($, dir))) {
329      return dir
330    }
331  }
332
333  return null
334}
335
336// Where the states are read from, looked for again at every refresh so that a
337// folder made later is found; and the files read before, by path, each read
338// again only once its size or time moved.
339let sources: { records: string | null; live: string | null } | undefined
340let readCache = new Map<string, Cached>()
341
342const readParsed = async <T,>(
343  $: EngineInterface,
344  path: string,
345  stamp: string,
346  parse: (text: string) => T | undefined,
347  kept: Map<string, Cached>,
348) => {
349  const held = readCache.get(path)
350  const value =
351    held?.stamp === stamp
352      ? (held.value as T | undefined)
353      : parse(await $.fs.read(path).then(String, () => ''))
354  kept.set(path, { stamp, value })
355
356  return value
357}
358
359/** The running sessions' own files, the latest by session where one left another behind. */
360const readLive = async ($: EngineInterface, dir: string, kept: Map<string, Cached>) => {
361  const byHost = new Map<string, Live>()
362  for (const entry of await $.fs.list(dir).catch(() => [])) {
363    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) {
364      continue
365    }
366    const file = await readParsed($, join(dir, entry.name), `${entry.size}:${entry.mtimeMs}`, toLive, kept)
367    const held = file === undefined ? undefined : byHost.get(file.host)
368    if (file !== undefined && (held === undefined || file.updatedAt >= held.updatedAt)) {
369      byHost.set(file.host, file)
370    }
371  }
372
373  return byHost
374}
375
376/** The desktop's records of the sessions wanted, by session id: each file is named for its session. */
377const readSeen = async (
378  $: EngineInterface,
379  dir: string,
380  wanted: ReadonlySet<string>,
381  kept: Map<string, Cached>,
382  depth = 0,
383): Promise<Map<string, Seen>> => {
384  const seen = new Map<string, Seen>()
385  for (const entry of await $.fs.list(dir).catch(() => [])) {
386    const path = join(dir, entry.name)
387    const id = entry.name.replace(/\.json$/, '')
388    if (entry.kind === 'dir' && depth < 3) {
389      for (const [one, record] of await readSeen($, path, wanted, kept, depth + 1)) {
390        seen.set(one, record)
391      }
392    } else if (entry.kind === 'file' && id !== entry.name && wanted.has(id)) {
393      const record = await readParsed($, path, `${entry.size}:${entry.mtimeMs}`, toSeen, kept)
394      if (record !== undefined) {
395        seen.set(id, record)
396      }
397    }
398  }
399
400  return seen
401}
402
403/** A session's state from its own file and its record; this session is never unread, it is in view. */
404const stateOf = (row: SessionRow, own: Live | undefined, seen: Seen | undefined, isMine: boolean) => {
405  if (own?.status === 'waiting') {
406    return 'waiting'
407  }
408  if (own?.status === 'busy' || (own === undefined && row.isRunning)) {
409    return 'running'
410  }
411  const doneAt = Math.max(seen?.activityAt ?? 0, own?.at ?? 0)
412
413  return seen !== undefined && !isMine && doneAt > seen.focusedAt + UNREAD_SLACK_MS ? 'unread' : undefined
414}
415
416/** Reads the listed sessions' states again; the pane and the tabs redraw where one changed. */
417const pollStates = async ($: EngineInterface) => {
418  try {
419    sources ??= { records: await recordsDirOf($), live: await liveDirOf($) }
420    const mine = await read($, thisSession)
421    const others = await read($, sessions)
422    const rows = mine === null ? others : [mine, ...others.filter(row => row.id !== mine.id)]
423    const kept = new Map<string, Cached>()
424    const live = sources.live === null ? new Map<string, Live>() : await readLive($, sources.live, kept)
425    const wanted = new Set(rows.map(row => row.id))
426    const seen = sources.records === null ? new Map<string, Seen>() : await readSeen($, sources.records, wanted, kept)
427    readCache = kept
428
429    const value: Record<string, SessionState> = {}
430    for (const row of rows) {
431      const state = stateOf(row, live.get(row.id), seen.get(row.id), row.id === mine?.id)
432      if (state !== undefined) {
433        value[row.id] = state
434      }
435    }
436    await setStates($, value)
437  } catch {
438    // The states are a hint: the next read tries again.
439  }
440}
441
442/** What status says of the states: where they are read from. */
443const statesLine = () => {
444  if (sources === undefined) {
445    return 'Session states: not read yet.'
446  }
447  if (sources.records === null && sources.live === null) {
448    return 'Session states: unavailable (no session records or session files found).'
449  }
450
451  return `Session states: read from ${sources.records ?? '(no session records)'} and ${sources.live ?? '(no session files)'}.`
452}
453
454const refresh = async ($: EngineInterface) => {
455  await setPins($, await loadPins($))
456  await setFlipped($, toKeys(await $.store.get(FLIPPED_KEY)))
457  await setHere($, await $.session.root())
458  await setThisSession($, await readThisSession($))
459  const at = await $.clock.now()
460
461  try {
462    await setSessions($, await listSessions($))
463    await update($, probe, () => ({ ok: true, error: null, at }))
464  } catch (error) {
465    await update($, probe, () => ({ ok: false, error: String(error), at }))
466  }
467  sources = { records: await recordsDirOf($), live: await liveDirOf($) }
468  await pollStates($)
469}
470
471// Opening a session: its claude:// link, handed to the operating system.
472
473const openLink = async ($: EngineInterface, url: string) => {
474  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
475  const commands = isWindows
476    ? [['rundll32', 'url.dll,FileProtocolHandler', url]]
477    : [
478        ['open', url],
479        ['xdg-open', url],
480      ]
481
482  for (const argv of commands) {
483    try {
484      const ran = await $.process.run(argv, { timeoutMs: 10_000 })
485      if (ran.exitCode === 0) {
486        return true
487      }
488    } catch {
489      // Not on this system: try the next opener.
490    }
491  }
492
493  return false
494}
495
496/**
497 * The app's documented link for a new Code session in a folder. The app asks
498 * the person to confirm the folder each time: a link's folder is not trusted.
499 */
500const newSessionLink = (folder: string) =>
501  `claude://code/new?folder=${encodeURIComponent(folder)}`
502
503const startSession = async ($: EngineInterface, folder: string, label: string) => {
504  if (!(await openLink($, newSessionLink(folder)))) {
505    $.ui.toast(`Could not start a session in ${label}.`)
506  }
507}
508
509const openSession = async ($: EngineInterface, row: SessionRow) => {
510  if (row.id === (await read($, thisSession))?.id) {
511    $.ui.toast('You are in this session.')
512    return
513  }
514  if (row.link === null || !(await openLink($, row.link))) {
515    $.ui.toast(`Could not open "${row.title}".`)
516  }
517}
518
519const goTo = async ($: EngineInterface, target: Pin) => {
520  await refresh($)
521  const list = await read($, pins)
522  if (pinOf(await read($, here), list)?.folder === target.folder) {
523    $.ui.toast(`You are in ${target.label} already.`)
524    return
525  }
526
527  const latest = (await read($, sessions))
528    .filter(row => row.link !== null && pinOf(row.folder, list)?.folder === target.folder)
529    .sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt))[0]
530  if (latest === undefined) {
531    await startSession($, target.folder, target.label)
532    return
533  }
534  await openSession($, latest)
535}
536
537// The Projects pane: the pinned projects in the order of their tabs, then the
538// other projects with a session among the recent ones, the latest first, and
539// This computer last.
540
541type Project = {
542  key: string
543  /** Its folder; null for This computer, whose sessions each have their own. */
544  folder: string | null
545  label: string
546  isPinned: boolean
547  /** Its sessions, the latest first. */
548  rows: SessionRow[]
549}
550
551const projectsOf = (list: readonly Pin[], rows: readonly SessionRow[]) => {
552  const latestFirst = [...rows].sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt))
553  const pinned: Project[] = list.map(pin => ({
554    key: keyOf(pin.folder),
555    folder: pin.folder,
556    label: pin.label,
557    isPinned: true,
558    rows: latestFirst.filter(row => pinOf(row.folder, list)?.folder === pin.folder),
559  }))
560
561  const rest = new Map<string, Project>()
562  for (const row of latestFirst) {
563    if (pinOf(row.folder, list) !== undefined) {
564      continue
565    }
566    const key = keyOf(row.folder)
567    const folder = key === SCRATCH_KEY ? null : projectFolder(row.folder)
568    const project: Project = rest.get(key) ?? {
569      key,
570      folder,
571      label: folder === null ? SCRATCH_LABEL : baseName(folder),
572      isPinned: false,
573      rows: [],
574    }
575    project.rows.push(row)
576    rest.set(key, project)
577  }
578  const others = [...rest.values()]
579
580  return {
581    pinned,
582    rest: [
583      ...others.filter(project => project.key !== SCRATCH_KEY),
584      ...others.filter(project => project.key === SCRATCH_KEY),
585    ],
586  }
587}
588
589/** How long ago an ISO time was, in the unit that reads best: 5m, 3h, 2d, 6w. */
590const ago = (iso: string, now: number) => {
591  const then = Date.parse(iso)
592  if (Number.isNaN(then) || now === 0) {
593    return ''
594  }
595
596  const minutes = Math.max(0, Math.floor((now - then) / 60_000))
597  if (minutes < 1) {
598    return 'now'
599  }
600  if (minutes < 60) {
601    return `${minutes}m`
602  }
603  const hours = Math.floor(minutes / 60)
604  if (hours < 24) {
605    return `${hours}h`
606  }
607  const days = Math.floor(hours / 24)
608
609  return days < 7 ? `${days}d` : `${Math.floor(days / 7)}w`
610}
611
612/** Cuts a text to a number of characters, an ellipsis last where it was cut. */
613const clip = (text: string, room: number) => {
614  const chars = Array.from(text)
615
616  return chars.length <= room ? text : `${chars.slice(0, Math.max(1, room - 1)).join('')}…`
617}
618
619// Buttons kept across redraws. A Button built again gets a new handle and the
620// engine lets the old one go, so a click the surface sends from the drawing it
621// still shows runs nothing, and the surface only draws again. Redraws land
622// between a drawing and a click often enough to lose first clicks: the pane
623// taking the keyboard, the desktop asking for every drawing again, the session
624// list read again. A Button whose look and action have not changed is
625// therefore handed back as it was, its handle with it.
626
627type Kept = { inputs: string; element: unknown }
628
629/** Each site's Buttons by key, as its latest drawing handed them out. */
630const keptBySite = new Map<string, Map<string, Kept>>()
631
632/**
633 * What one drawing builds its Buttons with. Taken once the drawing's reads are
634 * done, so drawings of one site do not interleave; the Buttons a drawing leaves
635 * out are let go.
636 */
637const keeperOf = (e: { surface: string; component: string; requestId: string }) => {
638  const site = [e.surface, e.component, e.requestId].join('\u0000')
639  const before = keptBySite.get(site) ?? new Map<string, Kept>()
640  const after = new Map<string, Kept>()
641  keptBySite.set(site, after)
642
643  /** The Button under `key`, built again only when `inputs`, what it shows and does, change. */
644  return <T,>(key: string, inputs: unknown[], build: () => T): T => {
645    const said = JSON.stringify(inputs)
646    const held = before.get(key)
647    const element = held?.inputs === said ? (held.element as T) : build()
648    after.set(key, { inputs: said, element })
649
650    return element
651  }
652}
653
654// The look: each project's icon, the fill that marks the current row, and the
655// wash that lights a row under the pointer.
656
657/** Each pair is a light square and its dark letter; a dark theme swaps the two. */
658const SWATCHES = [
659  ['#F5C4B3', '#712B13'],
660  ['#9FE1CB', '#085041'],
661  ['#CECBF6', '#3C3489'],
662  ['#C0DD97', '#27500A'],
663  ['#B5D4F4', '#0C447C'],
664  ['#FAC775', '#633806'],
665  ['#F4C0D1', '#72243E'],
666] as const
667
668const ICON_SIZE = 20
669
670/** A project's colors, picked from its key so that a renamed tab keeps them. */
671const swatchOf = (key: string) => {
672  let hash = 0
673  for (const char of key) {
674    hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0
675  }
676
677  return SWATCHES[hash % SWATCHES.length] ?? SWATCHES[0]
678}
679
680/** The first letter or digit of a name: what its icon shows. */
681const letterOf = (name: string) => (/[\p{L}\p{N}]/u.exec(name)?.[0] ?? '?').toUpperCase()
682
683const escapeXml = (text: string) => text.replace(/[<>&"']/g, char => `&#${char.charCodeAt(0)};`)
684
685const SVG_OPEN = `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 20 20">`
686
687/** A project's letter on its square, in the theme's light or dark. */
688const letterSvg = (key: string, label: string) => {
689  const [fill, ink] = swatchOf(key)
690
691  return (
692    SVG_OPEN +
693    `<style>.s{fill:${fill}}.l{fill:${ink}}` +
694    `@media (prefers-color-scheme: dark){.s{fill:${ink}}.l{fill:${fill}}}</style>` +
695    '<rect class="s" x="2" y="2" width="16" height="16" rx="4"/>' +
696    '<text class="l" x="10" y="10.5" font-size="10" font-weight="600" ' +
697    'font-family="system-ui, -apple-system, Segoe UI, sans-serif" text-anchor="middle" ' +
698    `dominant-baseline="central">${escapeXml(letterOf(label))}</text></svg>`
699  )
700}
701
702/** This computer's icon: a screen on its stand. */
703const COMPUTER_SVG =
704  SVG_OPEN +
705  '<style>.m{stroke:#73726c}@media (prefers-color-scheme: dark){.m{stroke:#a3a29c}}</style>' +
706  '<g class="m" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
707  '<rect x="3" y="4" width="14" height="9.5" rx="1.5"/><path d="M7.5 16.5h5M10 13.5v3"/></g></svg>'
708
709/**
710 * A project's icon: an SVG where the surface draws one, else its letter in a
711 * colored cell. The terminal's table answers Svg too, with an empty stand-in.
712 */
713const iconOf = (els: ElementTable, surface: string, key: string, label: string) => {
714  const { Text } = els
715  if (surface !== 'terminal' && 'Svg' in els) {
716    const { Svg } = els
717    const source = key === SCRATCH_KEY ? COMPUTER_SVG : letterSvg(key, label)
718
719    return <Svg source={source} alt={label} width={ICON_SIZE} height={ICON_SIZE} />
720  }
721  if (key === SCRATCH_KEY) {
722    return <Text dimColor>{' ▪ '}</Text>
723  }
724  const [fill, ink] = swatchOf(key)
725
726  return (
727    <Text backgroundColor={fill} color={ink}>
728      {` ${letterOf(label)} `}
729    </Text>
730  )
731}
732
733// The desktop's own fill for what is selected, and a wash for what the pointer is over.
734const ACTIVE = 'userMessageBackground'
735const LIT = 'rgba(128, 128, 128, 0.14)'
736
737/** Whether a surface has a pointer to hover with: the terminal and the phone have none. */
738const canHover = (surface: string) => surface === 'desktop' || surface === 'vscode'
739
740/** A row's fill: the selected row filled, any other lit while the pointer is over it. */
741const fillOf = (surface: string, isSelected: boolean) =>
742  isSelected
743    ? { backgroundColor: ACTIVE }
744    : canHover(surface)
745      ? { backgroundColor: 'transparent', hover: { backgroundColor: LIT } }
746      : {}
747
748/** A Button in a lit row: its own highlight under the pointer off, the row's is enough. */
749const quietOf = (surface: string) =>
750  canHover(surface) ? { hover: { backgroundColor: 'transparent' } } : {}
751
752// A session's state as the sidebar's dot draws it, and the count of a
753// project's sessions that need the person, in the color of the most urgent.
754
755type Shown = SessionState | 'idle'
756
757/** Each state's dot: [light theme, dark theme]. */
758const DOT_COLORS: Record<Shown, readonly [string, string]> = {
759  waiting: ['#BA7517', '#EF9F27'],
760  unread: ['#378ADD', '#85B7EB'],
761  running: ['#888780', '#B4B2A9'],
762  idle: ['#B4B2A9', '#5F5E5A'],
763}
764
765/** A count's pill: [fill, number] in a light theme, then in a dark one. */
766const BADGE_COLORS = {
767  waiting: ['#FAEEDA', '#633806', '#633806', '#FAC775'],
768  unread: ['#E6F1FB', '#0C447C', '#0C447C', '#B5D4F4'],
769} as const
770
771const STATE_LABELS: Record<Shown, string> = {
772  waiting: 'Waiting for you',
773  unread: 'Unread',
774  running: 'Running',
775  idle: 'Idle',
776}
777
778/** The terminal's theme colors for the states. */
779const STATE_THEME: Record<SessionState, string> = {
780  waiting: 'warning',
781  unread: 'suggestion',
782  running: 'inactive',
783}
784
785const DOT_WIDTH = 12
786
787/** A state's dot: filled, pulsing while the session runs, a ring when it is idle. */
788const dotSvg = (state: Shown) => {
789  const [light, dark] = DOT_COLORS[state]
790  const paint = state === 'idle' ? 'fill:none;stroke:' : 'fill:'
791  const pulse =
792    state === 'running'
793      ? '<animate attributeName="opacity" values="1;0.35;1" dur="1.6s" repeatCount="indefinite"/>'
794      : ''
795
796  return (
797    `<svg xmlns="http://www.w3.org/2000/svg" width="${DOT_WIDTH}" height="${ICON_SIZE}" viewBox="0 0 ${DOT_WIDTH} ${ICON_SIZE}">` +
798    `<style>.d{${paint}${light}}@media (prefers-color-scheme: dark){.d{${paint}${dark}}}</style>` +
799    `<circle class="d" cx="6" cy="10" r="${state === 'idle' ? 3.25 : 4}" stroke-width="1.5">${pulse}</circle></svg>`
800  )
801}
802
803/** A count as a pill, and how wide it is drawn. */
804const badgeSvg = (count: number, state: keyof typeof BADGE_COLORS) => {
805  const text = count > 99 ? '99+' : String(count)
806  const width = Math.max(18, 10 + text.length * 6)
807  const [fill, ink, darkFill, darkInk] = BADGE_COLORS[state]
808  const source =
809    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ICON_SIZE}" viewBox="0 0 ${width} ${ICON_SIZE}">` +
810    `<style>.p{fill:${fill}}.n{fill:${ink}}` +
811    `@media (prefers-color-scheme: dark){.p{fill:${darkFill}}.n{fill:${darkInk}}}</style>` +
812    `<rect class="p" x="0" y="2" width="${width}" height="16" rx="8"/>` +
813    `<text class="n" x="${width / 2}" y="10.5" font-size="10.5" font-weight="600" ` +
814    'font-family="system-ui, -apple-system, Segoe UI, sans-serif" text-anchor="middle" ' +
815    `dominant-baseline="central">${text}</text></svg>`
816
817  return { source, width }
818}
819
820/** A session's dot: an SVG where the surface draws one, else a glyph in the theme's color. */
821const dotOf = (els: ElementTable, surface: string, state: SessionState | undefined) => {
822  const { Text } = els
823  if (surface !== 'terminal' && 'Svg' in els) {
824    const { Svg } = els
825    const shown = state ?? 'idle'
826
827    return <Svg source={dotSvg(shown)} alt={STATE_LABELS[shown]} width={DOT_WIDTH} height={ICON_SIZE} />
828  }
829
830  return state === undefined ? <Text>{' '}</Text> : <Text color={STATE_THEME[state]}>●</Text>
831}
832
833/**
834 * What a project's row and tab show of its sessions: how many wait for the
835 * person or are unread, else a running dot while one runs, else nothing.
836 */
837const markOf = (
838  els: ElementTable,
839  surface: string,
840  ids: readonly string[],
841  value: Readonly<Record<string, SessionState>>,
842) => {
843  const list = ids.map(id => value[id])
844  const waiting = list.filter(state => state === 'waiting').length
845  const need = waiting + list.filter(state => state === 'unread').length
846  if (need === 0) {
847    return list.includes('running') ? dotOf(els, surface, 'running') : null
848  }
849  const state = waiting > 0 ? 'waiting' : 'unread'
850  const { Text } = els
851  if (surface !== 'terminal' && 'Svg' in els) {
852    const { Svg } = els
853    const { source, width } = badgeSvg(need, state)
854
855    return <Svg source={source} alt={`${need} need you`} width={width} height={ICON_SIZE} />
856  }
857
858  return (
859    <Text color={STATE_THEME[state]} bold>
860      {String(need)}
861    </Text>
862  )
863}
864
865/** Opens the pane at the person's ask: what to tell them when it is not drawn, or nothing. */
866const showPane = async ($: EngineInterface) => {
867  await $.store.set(CLOSED_KEY, false)
868  const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
869
870  return opened.isPlaced ? '' : `The Projects pane is not drawn yet: ${opened.reason}`
871}
872
873// Changing the pins: the command, the model's tool, the band and the pane share these.
874
875const indexOf = (list: readonly Pin[], key: string) => {
876  const wanted = key.trim()
877  if (/^\d+$/.test(wanted)) {
878    const index = Number(wanted) - 1
879    return index >= 0 && index < list.length ? index : -1
880  }
881
882  return list.findIndex(
883    pin =>
884      pin.label.toLowerCase() === wanted.toLowerCase() ||
885      normalize(pin.folder) === normalize(wanted),
886  )
887}
888
889const pinFolder = async ($: EngineInterface, folder: string, label?: string) => {
890  if (isScratch(folder)) {
891    return `${folder} is a scratch folder the app made for a session started without one: there is no project to pin.`
892  }
893  const list = await loadPins($)
894  const existing = list.find(pin => keyOf(pin.folder) === keyOf(folder))
895  if (existing !== undefined) {
896    return `${existing.label} is pinned already.`
897  }
898
899  const added = { folder, label: label?.trim() || baseName(folder) }
900  await savePins($, [...list, added])
901  await setFlip($, keyOf(folder), false)
902  await refresh($)
903
904  return `Pinned ${added.label} (${folder}): it has a tab, and stays at the top of the Projects pane.`
905}
906
907const unpinTab = async ($: EngineInterface, key: string) => {
908  const list = await loadPins($)
909  const removed = list[indexOf(list, key)]
910  if (removed === undefined) {
911    return `No tab matches "${key}".`
912  }
913
914  await savePins($, list.filter(pin => pin !== removed))
915  await setFlip($, keyOf(removed.folder), false)
916
917  return `Unpinned ${removed.label}.`
918}
919
920const moveTab = async ($: EngineInterface, key: string, to: number) => {
921  const list = await loadPins($)
922  const moved = list[indexOf(list, key)]
923  if (moved === undefined) {
924    return `No tab matches "${key}".`
925  }
926
927  const others = list.filter(pin => pin !== moved)
928  const target = Math.min(Math.max(to, 1), others.length + 1) - 1
929  others.splice(target, 0, moved)
930  await savePins($, others)
931
932  return `Moved ${moved.label} to position ${target + 1}.`
933}
934
935const renameTab = async ($: EngineInterface, key: string, label: string) => {
936  const list = await loadPins($)
937  const old = list[indexOf(list, key)]
938  if (old === undefined) {
939    return `No tab matches "${key}".`
940  }
941
942  const name = label.trim()
943  await savePins($, list.map(pin => (pin === old ? { ...old, label: name } : pin)))
944
945  return `Renamed ${old.label} to ${name}.`
946}
947
948const describe = (list: readonly Pin[], rows: readonly SessionRow[]) => {
949  if (list.length === 0) {
950    return 'No pinned folders yet. Run /env-tabs pin in a session to pin its project.'
951  }
952
953  return list
954    .map((pin, index) => {
955      const count = rows.filter(row => pinOf(row.folder, list)?.folder === pin.folder).length
956      return `${index + 1}. ${pin.label}  (${pin.folder}; ${count} other session${count === 1 ? '' : 's'})`
957    })
958    .join('\n')
959}
960
961const status = async ($: EngineInterface) => {
962  await refresh($)
963  const last = await read($, probe)
964  const rows = await read($, sessions)
965
966  return [
967    `This session's folder: ${await read($, here)}`,
968    last?.ok
969      ? `Session list: ${rows.length} other sessions read.`
970      : `Session list: unavailable (${last?.error ?? 'not read yet'}).`,
971    statesLine(),
972    '',
973    describe(await read($, pins), rows),
974  ].join('\n')
975}
976
977const runCommand = async ($: EngineInterface, args: string) => {
978  const trimmed = args.trim()
979  const [verb = 'list'] = trimmed.split(/\s+/, 1).filter(Boolean)
980  const tail = trimmed.slice(verb.length).trim()
981
982  switch (verb.toLowerCase()) {
983    case 'list': {
984      await refresh($)
985      return describe(await read($, pins), await read($, sessions))
986    }
987    case 'pin':
988      return pinFolder($, tail || projectFolder(await $.session.root()))
989    case 'unpin':
990      return tail ? unpinTab($, tail) : HELP
991    case 'move': {
992      const match = /^(.*\S)\s+(\d+)$/.exec(tail)
993      return match?.[1] && match[2] ? moveTab($, match[1], Number(match[2])) : HELP
994    }
995    case 'rename': {
996      const match = /^(\d+)\s+(.*\S)$/.exec(tail)
997      return match?.[1] && match[2] ? renameTab($, match[1], match[2]) : HELP
998    }
999    case 'panel':
1000      return (await showPane($)) || 'The Projects pane is open.'
1001    case 'status':
1002      return status($)
1003    default:
1004      return HELP
1005  }
1006}
1007
1008type ToolInput = {
1009  action?: string
1010  folder?: string
1011  tab?: string
1012  label?: string
1013  position?: number
1014}
1015
1016const runTool = async ($: EngineInterface, input: ToolInput) => {
1017  switch (input.action) {
1018    case 'pin':
1019      return pinFolder(
1020        $,
1021        input.folder?.trim() || projectFolder(await $.session.root()),
1022        input.label,
1023      )
1024    case 'unpin':
1025      return input.tab ? unpinTab($, input.tab) : 'Name the tab to unpin.'
1026    case 'move':
1027      return input.tab && input.position
1028        ? moveTab($, input.tab, input.position)
1029        : 'Name the tab and its new position.'
1030    case 'rename':
1031      return input.tab && input.label?.trim()
1032        ? renameTab($, input.tab, input.label)
1033        : 'Name the tab and its new label.'
1034    case 'panel':
1035      return (await showPane($)) || 'The Projects pane is open.'
1036    default:
1037      return status($)
1038  }
1039}
1040
1041export const register: Register = on => {
1042  on('session.start', async ($, e, next) => {
1043    await $.command.register({
1044      name: 'env-tabs',
1045      description: 'Pinned projects as tabs above the prompt and at the top of a Projects pane',
1046      argumentHint: '[list|pin|unpin|move|rename|panel|status]',
1047    })
1048    await $.tool.register({
1049      name: 'tabs',
1050      description:
1051        "Reads and changes env-tabs: pinned projects (folders), each drawn as a tab above the prompt in Claude Desktop's Code tab " +
1052        'and kept at the top of its Projects pane, which lists every recent project with its sessions. ' +
1053        'action "status" lists the pins and what they see; "pin" pins a folder (this session\'s project when none is given); ' +
1054        '"unpin", "move" and "rename" take a tab by its 1-based number or its label; "panel" opens the Projects pane.',
1055      inputSchema: {
1056        type: 'object',
1057        properties: {
1058          action: {
1059            type: 'string',
1060            enum: ['status', 'pin', 'unpin', 'move', 'rename', 'panel'],
1061          },
1062          folder: { type: 'string', description: 'The absolute folder to pin (pin only).' },
1063          tab: { type: 'string', description: 'The tab: its 1-based number or its label.' },
1064          label: { type: 'string', description: 'The text on the tab (pin, rename).' },
1065          position: {
1066            type: 'integer',
1067            minimum: 1,
1068            description: 'The new 1-based position (move).',
1069          },
1070        },
1071        required: ['action'],
1072      },
1073      isDeferred: true,
1074    })
1075    const started = await next(e)
1076    sources = undefined
1077    readCache = new Map()
1078    void refresh($)
1079    $.clock.every(REFRESH_MS, () => refresh($))
1080    $.clock.every(STATES_MS, () => pollStates($))
1081    // The pane opens by itself where the surface has room for it beside the
1082    // transcript, unless the person closed it.
1083    if ((await $.store.get(CLOSED_KEY)) !== true) {
1084      void $.ui.open({ id: PANE, title: PANE_TITLE })
1085    }
1086
1087    return started
1088  })
1089
1090  // A pane the person closed stays closed in later sessions, until they open it again.
1091  on('ui.close', { id: PANE }, async ($, e, next) => {
1092    if (e.origin.kind === 'person') {
1093      await $.store.set(CLOSED_KEY, true)
1094    }
1095
1096    return next(e)
1097  })
1098
1099  on('command.run', { command: 'env-tabs' }, async ($, e) => ({
1100    text: await runCommand($, e.args),
1101  }))
1102
1103  on('tool.call', { tool: TOOL }, async ($, e) => ({
1104    result: await runTool($, e as unknown as ToolInput),
1105  })).catch(() => ({ result: 'env-tabs could not answer; run /env-tabs status to see why.' }))
1106
1107  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1108    const folder = await read($, here)
1109    if (e.props.hasSurvey || folder === '') {
1110      return next(e)
1111    }
1112
1113    const list = await read($, pins)
1114    const mine = await read($, thisSession)
1115    const others = await read($, sessions)
1116    const value = await read($, states)
1117    const rows = mine === null ? others : [mine, ...others.filter(row => row.id !== mine.id)]
1118    const current = pinOf(folder, list)
1119    const els = $.ui.resolve(e)
1120    const { Box, Button } = els
1121    const keep = keeperOf(e)
1122    const quiet = quietOf(e.surface)
1123
1124    return (
1125      <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1}>
1126        {list.map(pin => {
1127          const key = `tab:${normalize(pin.folder)}`
1128          const isCurrent = current?.folder === pin.folder
1129          const ids = rows.filter(row => pinOf(row.folder, list)?.folder === pin.folder).map(row => row.id)
1130
1131          return (
1132            <Box
1133              key={`tabrow:${normalize(pin.folder)}`}
1134              flexDirection="row"
1135              alignItems="center"
1136              columnGap={1}
1137              paddingRight={1}
1138              {...fillOf(e.surface, isCurrent)}
1139            >
1140              {iconOf(els, e.surface, keyOf(pin.folder), pin.label)}
1141              {keep(key, [pin, isCurrent], () => (
1142                <Button
1143                  key={key}
1144                  plain
1145                  dimColor={!isCurrent}
1146                  {...quiet}
1147                  label={pin.label}
1148                  onPress={() => goTo($, pin)}
1149                />
1150              ))}
1151              {markOf(els, e.surface, ids, value)}
1152            </Box>
1153          )
1154        })}
1155        {current === undefined &&
1156          !isScratch(folder) &&
1157          keep('pin-here', [folder], () => (
1158            <Button
1159              key="pin-here"
1160              plain
1161              label="+ Pin this folder"
1162              onPress={async () => $.ui.toast(await pinFolder($, projectFolder(folder)))}
1163            />
1164          ))}
1165        {keep('projects', [], () => (
1166          <Button
1167            key="projects"
1168            plain
1169            dimColor
1170            label="Projects"
1171            onPress={async () => {
1172              const said = await showPane($)
1173              if (said !== '') {
1174                $.ui.toast(said)
1175              }
1176            }}
1177          />
1178        ))}
1179      </Box>
1180    )
1181  })
1182
1183  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1184    const els = $.ui.resolve(e)
1185    const { Box, Button, Text } = els
1186    const list = await read($, pins)
1187    const mine = await read($, thisSession)
1188    const others = await read($, sessions)
1189    const folder = await read($, here)
1190    const flips = await read($, flipped)
1191    const value = await read($, states)
1192    const last = await read($, probe)
1193    const now = last?.at ?? 0
1194    const room = Math.max(8, e.props.bodyColumns - 10)
1195    // A project's row also holds its + New and Pin or Unpin buttons.
1196    const labelRoom = Math.max(8, e.props.bodyColumns - 18)
1197    const rows = mine === null ? others : [mine, ...others.filter(row => row.id !== mine.id)]
1198    const { pinned, rest } = projectsOf(list, rows)
1199    const current = folder === '' ? '' : keyOf(folder)
1200    const keep = keeperOf(e)
types/index.d.ts 53 lines
1/** A pinned folder: one tab above the prompt, and a place at the top of the Projects pane. */
2export type Pin = {
3  /** The folder, absolute, as the session that pinned it reported it. */
4  folder: string
5  /** The text on the tab, and the project's name in the pane. */
6  label: string
7}
8
9/** One Code session as the desktop app lists it. */
10export type SessionRow = {
11  id: string
12  title: string
13  /** The session's working folder. */
14  folder: string
15  /** A claude:// link that opens the session in the app; null where the app gives none. */
16  link: string | null
17  isRunning: boolean
18  /** ISO time of the session's last activity. */
19  lastActivityAt: string
20}
21
22/**
23 * What a session wants, as the desktop's sidebar dot says: waiting on the
24 * person (a permission or a question), finished since they last looked at it,
25 * or running. An idle session has none.
26 */
27export type SessionState = 'waiting' | 'unread' | 'running'
28
29/** How the last read of the session list went. */
30export type Probe = {
31  ok: boolean
32  error: string | null
33  at: number
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    'env-tabs': {
39      pins: Pin[]
40      /** The other sessions, as list_sessions answers: it leaves this one out. */
41      sessions: SessionRow[]
42      /** This session, or null while the app has not said. */
43      thisSession: SessionRow | null
44      here: string
45      /** Projects the person opened or closed in the pane, by their normalized folder. */
46      flipped: string[]
47      /** Each listed session's state, by its id; an idle session is left out. */
48      states: Record<string, SessionState>
49      probe: Probe | null
50    }
51  }
52}
53