SLOPSHOPPER

desk

Awareness across your Claude Code terminal sessions: what needs you, where each one stands, and a pane to jump between them

newpaneguardcommandpromptmodel
v0.1.0MITupdated 2026-10-24tylerhagy/claude-desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk
│ ┃ Desk ✕ › fix the failing auth test and add an audit log call │ ┃ 0 sessions · 0 waiting on you[ This chat onl │ ┃ No sessions reporting yet. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /whereami │ ⎿ desk: OK │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Desk
0 sessions · 0 waiting on you [ This chat only ] No sessions reporting yet.
Pane · viewer
Nothing to view. Use /view <path>.
README

claude-desk

A Claude Code mod for keeping track of many terminal sessions at once. It shows which sessions are waiting on you, where each one stands in plain words, and lets you jump between them.

What it does

  • A pane that opens by itself and is the only thing it shows. Nothing is added to the chat: no band above the prompt, no toasts, no status line text. /desk reopens it. Every session in a fixed order, each with its name, folder, state, a plain-language summary, and what it is blocked on. A Go To button focuses that session's Ghostty tab, and a section shows a tab: line with the tab's actual title only when it differs from the section's name (for example, one you renamed by hand). The session you are in is outlined.
  • A toggle at the top of the pane switches between all sessions and just the chat you are in. In the single-chat view every open item shows, with no "+N more" button.
  • Open items. Up to three open questions and action items per session, with expand, remove, and a button that drafts a prompt (not sent) in that session. A question only appears when Claude is genuinely waiting on you.
  • Tab names. The mod never changes them. Each session in the pane shows the same name as its Ghostty tab.
  • /view <path> shows an image, markdown or code file in a pane. With no path it shows your newest Desktop screenshot.
  • /whereami refreshes the summary now.

Requirements

  • Claude Code with mods (plugins with a hooks module).
  • macOS. Go To, tab names in the pane, and drafts into other sessions use Ghostty 1.3 or later and its AppleScript support. Image viewing uses sips. Without Ghostty the pane still works, but not those parts.
  • A terminal at least 110 columns wide for the pane.

Install

/plugin install desk --marketplace tylerhagy/claude-desk

Or load it from a folder for every session by adding its path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Sessions only report in once the mod is loaded in them, so restart open terminals (claude --continue) after installing.

How it works

Each session writes a small status file to ~/.claude/desk/sessions/ and refreshes it every 15 seconds. Every session reads that folder to draw the band and pane. The summary, label and open items come from one small-model call (Haiku) over the recent conversation after each finished turn, made through your own session. Nothing leaves your machine except that call.

Usage

The only thing that uses Claude usage is the summary: one small-model (Haiku) call after a finished turn, at most one per session every 20 seconds, sent only the messages added since the last summary plus the previous summary. A running tally of calls and tokens is kept in ~/.claude/desk/usage.json.

Status

An early version. The summary and open-item capture are model judgments and will be wrong sometimes.

License

MIT

Source 2 files
hooks/register.tsx 790 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, Session, SessionState, Viewing } from '../types'
5
6const DESK = 'desk'
7const VIEWER = 'viewer'
8const HEARTBEAT_MS = 15_000
9const SCAN_MS = 4_000
10const STALE_MS = 45_000
11
12const sessions = atom({ plugin: 'desk', key: 'sessions' } as const, [])
13const self = atom({ plugin: 'desk', key: 'self' } as const, '')
14const only = atom({ plugin: 'desk', key: 'only' } as const, false)
15const note = atom({ plugin: 'desk', key: 'note' } as const, '')
16const expanded = atom({ plugin: 'desk', key: 'expanded' } as const, [])
17const tabs = atom({ plugin: 'desk', key: 'tabs' } as const, {})
18const tick = atom({ plugin: 'desk', key: 'tick' } as const, 0)
19const hidden = atom({ plugin: 'desk', key: 'hidden' } as const, [])
20const viewing = atom({ plugin: 'desk', key: 'viewing' } as const, null)
21
22// what the person has to do, most urgent first
23const ORDER: SessionState[] = ['permission', 'question', 'done', 'working', 'idle']
24const WAITING: SessionState[] = ['permission', 'question', 'done']
25const LABEL: Record<SessionState, string> = {
26  permission: 'needs permission',
27  question: 'asked a question',
28  done: 'finished',
29  working: 'working',
30  idle: 'idle',
31}
32
33let home = ''
34let tty: string | null = null
35let isBriefing = false
36let titleAt = 0
37let tabsAt = 0
38let bindAt = 0
39let bindTries = 0
40let lastSig = ''
41let lastTabs = ''
42let seenCount = -1
43let briefAt = 0
44let briefPending = false
45let me: Session | null = null
46
47const waiting = (list: Session[]) => list.filter(s => WAITING.includes(s.state))
48
49const ago = (since: number) => {
50  const m = Math.floor((Date.now() - since) / 60_000)
51
52  return m < 1 ? '<1m' : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h`
53}
54
55const snippet = (text: string, n = 90) => {
56  const flat = text.replace(/\s+/g, ' ').trim()
57
58  return flat.length > n ? `${flat.slice(0, n - 1)}…` : flat
59}
60
61const dir = () => `${home}/.claude/desk/sessions`
62
63const name = (s: Session) => s.custom || s.label || s.title || s.project
64
65const threeWords = (text: string) =>
66  text
67    .replace(/[^\p{L}\p{N}\s'-]/gu, ' ')
68    .split(/\s+/)
69    .filter(Boolean)
70    .slice(0, 3)
71    .join(' ')
72
73// the session's name: the last title entry in its transcript, one you set winning over the generated one
74async function refreshTitle($: EngineInterface) {
75  if (me === null || Date.now() - titleAt < 10_000) {
76    return
77  }
78  titleAt = Date.now()
79  const folder = me.cwd.replace(/[^a-zA-Z0-9]/g, '-')
80  const file = `${home}/.claude/projects/${folder}/${me.id}.jsonl`
81  const found = await $.process.run([
82    'sh', '-c', 'grep -E \'"type":"[a-z-]*title"\' "$1" | tail -n 40', '_', file,
83  ])
84  let custom = ''
85  let generated = ''
86  for (const line of found.stdout.split('\n')) {
87    try {
88      const row = JSON.parse(line) as Record<string, unknown>
89      const value = Object.entries(row).find(
90        ([key, v]) => key !== 'type' && key !== 'sessionId' && typeof v === 'string',
91      )?.[1] as string | undefined
92      if (value) {
93        if (String(row.type) === 'ai-title') {
94          generated = value
95        } else {
96          custom = value
97        }
98      }
99    } catch {
100      // not a title row
101    }
102  }
103  me = { ...me, custom, title: generated || me.title }
104}
105
106async function osa($: EngineInterface, lines: string[], args: string[] = []) {
107  return $.process.run(['osascript', ...lines.flatMap(l => ['-e', l]), ...args], { timeoutMs: 8000 })
108}
109
110type Terminal = { id: string; wd: string; name: string }
111
112// every Ghostty terminal: its id, working directory and the title it shows now
113async function listTerminals($: EngineInterface) {
114  const ran = await osa($, [
115    'tell application "Ghostty"',
116    'set out to ""',
117    'repeat with t in terminals',
118    'set out to out & (id of t) & (ASCII character 9) & (working directory of t) & (ASCII character 9) & (name of t) & (ASCII character 10)',
119    'end repeat',
120    'return out',
121    'end tell',
122  ])
123  const list: Terminal[] = []
124  if (ran.exitCode === 0) {
125    for (const line of ran.stdout.split('\n')) {
126      const [id = '', wd = '', ...rest] = line.split('\t')
127      if (id) {
128        list.push({ id, wd, name: rest.join('\t').trim() })
129      }
130    }
131  }
132
133  return list
134}
135
136// find this session's Ghostty terminal: by folder, then or by name; if those cannot tell, it stays unbound and nothing is written to the tab
137async function bind($: EngineInterface) {
138  if (me === null || tty === null || me.term) {
139    return
140  }
141  const dirOf = (path: string) => path.toLowerCase().replace(/\/+$/, '')
142  const here = dirOf(me.cwd)
143  const names = [name(me), me.title].filter(Boolean).map(n => n.toLowerCase())
144  const guess = async () => {
145    const near = (await listTerminals($)).filter(t => dirOf(t.wd) === here)
146    const named = near.filter(t => names.some(n => t.name.toLowerCase().includes(n)))
147
148    return named.length === 1 ? named[0] : near.length === 1 ? near[0] : undefined
149  }
150  const found = await guess()
151  if (found !== undefined && me !== null) {
152    me = { ...me, term: found.id }
153    await save($)
154  }
155}
156
157// focus a session's tab, and with `text` paste it into that prompt without sending; returns an error or ''
158async function reach($: EngineInterface, s: Session, text = ''): Promise<string> {
159  const paste = text ? ['input text (item 2 of argv) to t'] : []
160  const script = (by: string) => [
161    'on run argv',
162    'tell application "Ghostty"',
163    `set t to first terminal whose ${by} is (item 1 of argv)`,
164    'focus t',
165    ...paste,
166    'end tell',
167    'end run',
168  ]
169  const flat = text.replace(/\s+/g, ' ')
170  if (s.term) {
171    const ran = await osa($, script('id'), [s.term, flat])
172
173    return ran.exitCode === 0 ? '' : snippet(ran.stderr, 100)
174  }
175
176  return 'this tab is not matched yet'
177}
178
179// a short message for the pane's top line; the chat never gets one
180async function tell($: EngineInterface, text: string) {
181  await update($, note, () => text)
182  $.clock.after(8_000, () => void update($, note, current => (current === text ? '' : current)))
183}
184
185async function openSession($: EngineInterface, s: Session) {
186  const error = await reach($, s)
187  if (error) {
188    await tell($, `Could not open ${name(s)}: ${error}`)
189  }
190}
191
192async function save($: EngineInterface) {
193  if (me === null) {
194    return
195  }
196  me = { ...me, updatedAt: Date.now() }
197  await refreshTitle($)
198  await $.fs.write(`${dir()}/${me.id}.json`, JSON.stringify(me))
199}
200
201async function setState($: EngineInterface, state: SessionState, detail = '', prompt?: string) {
202  if (me === null) {
203    return
204  }
205  const changed = me.state !== state
206  me = {
207    ...me,
208    state,
209    detail,
210    prompt: prompt ?? me.prompt,
211    since: changed ? Date.now() : me.since,
212  }
213  await save($)
214}
215
216async function scan($: EngineInterface) {
217  let names: string[] = []
218  try {
219    names = (await $.fs.list(dir())).map(entry => entry.name).filter(name => name.endsWith('.json'))
220  } catch {
221    // the folder does not exist until a session has written
222  }
223
224  if (me !== null) {
225    try {
226      const notes = (await $.fs.list(`${home}/.claude/desk/commands`)).filter(n => n.name.startsWith(`${me?.id}-`))
227      for (const note of notes) {
228        const path = `${home}/.claude/desk/commands/${note.name}`
229        try {
230          const { itemId } = JSON.parse((await $.fs.read(path)) as string) as { itemId: string }
231          await dismissOwn($, itemId)
232        } catch {
233          // a half-written note: the next scan has it
234        }
235        void $.process.run(['rm', '-f', path])
236      }
237    } catch {
238      // no notes folder yet
239    }
240  }
241
242  const now = Date.now()
243  const found: Session[] = []
244  for (const name of names) {
245    try {
246      const one = JSON.parse((await $.fs.read(`${dir()}/${name}`)) as string) as Session & { ended?: boolean }
247      const age = now - one.updatedAt
248      if (one.ended || age > STALE_MS) {
249        if (age > STALE_MS * 4) {
250          void $.process.run(['rm', '-f', `${dir()}/${name}`])
251        }
252        continue
253      }
254      found.push(one)
255    } catch {
256      // half-written file: the next scan has it
257    }
258  }
259
260  const id = await $.session.id()
261  // a fixed order: when the session began, never its state
262  found.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0) || a.id.localeCompare(b.id))
263  // a write redraws the pane: write only when something it shows has changed
264  const sig = JSON.stringify(found, (key, value) => (key === 'updatedAt' ? undefined : value))
265  if (sig !== lastSig) {
266    lastSig = sig
267    await update($, sessions, () => found)
268  }
269  if ((await read($, self)) !== id) {
270    await update($, self, () => id)
271  }
272
273  if (me !== null) {
274    if (!me.term && tty !== null && bindTries < 6 && Date.now() - bindAt > 30_000) {
275      bindTries += 1
276      bindAt = Date.now()
277      void bind($)
278    }
279      const isPaneUp = (await $.ui.panes()).some(pane => pane.id === DESK && pane.isPlaced)
280    if (isPaneUp && Date.now() - tabsAt > 6000) {
281      tabsAt = Date.now()
282      const map: Record<string, string> = {}
283      for (const t of await listTerminals($)) {
284        map[t.id] = t.name
285      }
286      const mapSig = JSON.stringify(map)
287      if (mapSig !== lastTabs) {
288        lastTabs = mapSig
289        await update($, tabs, () => map)
290      }
291    }
292  }
293}
294
295const BRIEF_PROMPT =
296  'Below is the recent conversation of a Claude Code session. Reply in this exact format:\n' +
297  'LABEL: at most three plain words naming the work happening now, like "Tab title design".\n' +
298  'BRIEF: one or two plain sentences saying what the person is trying to get done and what ' +
299  'just happened or is next. Plain words, no jargon, no markdown, under 40 words.\n' +
300  'ITEMS: then up to five lines, each starting with "- QUESTION:" or "- ACTION:". ' +
301  'Use QUESTION only when Claude is genuinely waiting on the person for an answer or a decision, ' +
302  'never for rhetorical questions, offers or things Claude can decide itself. ' +
303  'Use ACTION only for something the person still has to do or decide. ' +
304  'Each under 15 words, plain wording. List only what is still open now; leave out anything ' +
305  'already answered or done. If there is nothing open, write "- none".\n' +
306  'Keep the previous LABEL, and the wording of previous items that are still open, unless things have clearly changed.'
307
308const idOf = (text: string) => {
309  let h = 5381
310  for (const c of text.toLowerCase().replace(/[^a-z0-9]/g, '')) {
311    h = ((h * 33) ^ c.charCodeAt(0)) >>> 0
312  }
313
314  return h.toString(36)
315}
316
317// the person removed it, or acted on it: it is gone and the next summary may not bring it back
318async function dismissOwn($: EngineInterface, itemId: string) {
319  if (me === null) {
320    return
321  }
322  me = {
323    ...me,
324    items: me.items.filter(i => i.id !== itemId),
325    dismissed: [...me.dismissed.filter(x => x !== itemId), itemId].slice(-60),
326  }
327  await save($)
328}
329
330async function dismiss($: EngineInterface, s: Session, item: Item) {
331  await update($, hidden, list => [...list, item.id].slice(-200))
332  if (me !== null && s.id === me.id) {
333    await dismissOwn($, item.id)
334
335    return
336  }
337  // another session owns its record: leave it a note it reads on its next scan
338  await $.fs.write(`${home}/.claude/desk/commands/${s.id}-${Date.now()}.json`, JSON.stringify({ itemId: item.id }))
339}
340
341// act on an item: a draft in that session's prompt box, not sent
342async function actOn($: EngineInterface, s: Session, item: Item) {
343  const draft =
344    item.kind === 'question' ? `${item.text} My answer: ` : `Let's take care of this: ${item.text}`
345  if (me !== null && s.id === me.id) {
346    await $.prompt.fill({ text: draft, mode: 'replace' })
347    await dismiss($, s, item)
348
349    return
350  }
351  const error = await reach($, s, draft)
352  if (error) {
353    await tell($, `Could not open ${name(s)}: ${error}`)
354
355    return
356  }
357  await dismiss($, s, item)
358}
359
360// one small-model call over the recent conversation; the answer is the session's brief
361// a running tally of what the summaries cost, kept in one small file
362async function meter($: EngineInterface, input: number, output: number) {
363  const path = `${home}/.claude/desk/usage.json`
364  let total = { calls: 0, input: 0, output: 0 }
365  try {
366    total = JSON.parse((await $.fs.read(path)) as string) as typeof total
367  } catch {
368    // the first call starts the tally
369  }
370  await $.fs.write(path, JSON.stringify({ calls: total.calls + 1, input: total.input + input, output: total.output + output, since: Date.now() }))
371}
372
373// at most one summary every 20 seconds per session; a turn inside that gap is picked up when it ends
374function scheduleBrief($: EngineInterface) {
375  const wait = 20_000 - (Date.now() - briefAt)
376  if (wait <= 0) {
377    void refreshBrief($)
378  } else if (!briefPending) {
379    briefPending = true
380    $.clock.after(wait, () => {
381      briefPending = false
382      scheduleBrief($)
383    })
384  }
385}
386
387async function refreshBrief($: EngineInterface) {
388  if (me === null || isBriefing) {
389    return ''
390  }
391  isBriefing = true
392  try {
393    const messages = await $.session.messages()
394    // only what is new since the last summary, which already carries the rest; a clear or compaction starts over
395    const from = seenCount >= 0 && seenCount <= messages.length ? Math.max(seenCount, messages.length - 14) : messages.length - 14
396    const recent = messages
397      .slice(Math.max(0, from))
398      .map(m => `${m.role}: ${snippet(String(m.text ?? ''), 500)}`)
399      .join('\n')
400    if (recent === '') {
401      return ''
402    }
403    const previous = `${me.label ? `Previous LABEL: ${me.label}\n` : ''}${me.brief ? `Previous BRIEF: ${me.brief}\n` : ''}${me.items.length ? `Previous ITEMS:\n${me.items.map(i => `- ${i.kind.toUpperCase()}: ${i.text}`).join('\n')}\n` : ''}\n`
404    const reply = await $.model.complete({
405      model: 'haiku',
406      prompt: `${BRIEF_PROMPT}\n\n${previous}${recent}`,
407      maxTokens: 220,
408    })
409    if (!reply.isAnswered) {
410      return ''
411    }
412    seenCount = messages.length
413    briefAt = Date.now()
414    await meter($, reply.usage.input_tokens, reply.usage.output_tokens)
415    const raw = reply.text
416    const at = raw.search(/^ITEMS:/im)
417    const head = at >= 0 ? raw.slice(0, at) : raw
418    const tail = at >= 0 ? raw.slice(at) : ''
419    const said = /^LABEL:\s*(.+)$/im.exec(head)?.[1] ?? ''
420    const text = snippet(/^BRIEF:\s*([\s\S]+)$/im.exec(head)?.[1] ?? head, 600)
421    let items = me.items
422    if (tail !== '') {
423      const seen = new Set<string>()
424      items = []
425      for (const [, kind = '', body = ''] of tail.matchAll(/^\s*-\s*(QUESTION|ACTION):\s*(.+)$/gim)) {
426        const itemText = snippet(body, 110)
427        const id = idOf(itemText)
428        if (!me.dismissed.includes(id) && !seen.has(id)) {
429          seen.add(id)
430          items.push({ id, kind: kind.toUpperCase() === 'QUESTION' ? 'question' : 'action', text: itemText })
431        }
432      }
433      items = items.slice(0, 6)
434    }
435    me = { ...me, brief: text, label: threeWords(said) || me.label, items }
436    await save($)
437
438    return text
439  } catch {
440    return ''
441  } finally {
442    isBriefing = false
443  }
444}
445
446const summarize = (tool: string, input: unknown) => {
447  const i = (input ?? {}) as Record<string, unknown>
448  const detail = i.command ?? i.file_path ?? i.path ?? i.url ?? i.pattern ?? ''
449  const first = String(detail).split('\n')[0]?.split(' <<')[0] ?? ''
450
451  return `${tool}${first ? `: ${snippet(first, 80)}` : ''}`
452}
453
454const IMAGE = /\.(png|jpe?g|gif|webp|heic|tiff?|bmp|pdf)$/i
455const MARKDOWN = /\.(md|markdown)$/i
456const LANGUAGE: Record<string, string> = {
457  ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', json: 'json', py: 'python',
458  go: 'go', html: 'html', css: 'css', yaml: 'yaml', yml: 'yaml', sh: 'bash', sql: 'sql',
459}
460
461export const register: Register = on => {
462  on('session.start', async ($, e, next) => {
463    home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
464    const cwd = await $.session.cwd()
465    const id = await $.session.id()
466    me = {
467      id,
468      project: cwd.split('/').filter(Boolean).pop() ?? cwd,
469      startedAt: Date.now(),
470      title: '',
471      custom: '',
472      label: '',
473      tty: '',
474      term: '',
475      cwd,
476      state: 'idle',
477      detail: '',
478      prompt: '',
479      brief: '',
480      items: [],
481      dismissed: [],
482      since: Date.now(),
483      updatedAt: Date.now(),
484    }
485    try {
486      const old = JSON.parse((await $.fs.read(`${dir()}/${id}.json`)) as string) as Session
487      me = { ...me, startedAt: old.startedAt || me.startedAt, brief: old.brief ?? '', prompt: old.prompt ?? '', title: old.title ?? '', custom: old.custom ?? '', label: old.label ?? '', items: old.items ?? [], dismissed: old.dismissed ?? [], term: old.term ?? '' }
488    } catch {
489      // a new session has no file yet
490    }
491    const found = (await $.process.run(['sh', '-c', 'ps -o tty= -p $PPID'])).stdout.trim()
492    tty = found && found !== '??' ? found : null
493    me = { ...me, tty: tty ?? '' }
494    await save($)
495    void bind($)
496    await $.command.register({ name: 'whereami', description: 'Refresh the one-line summary of where this session stands' })
497    await $.command.register({ name: 'desk', description: 'Open the desk: every Claude session and what it is waiting on' })
498    await $.command.register({ name: 'view', description: 'View a file or image in a pane (no argument: newest screenshot)' })
499    void $.ui.open({ id: DESK, title: 'Desk' })
500    void scan($)
501    $.clock.every(HEARTBEAT_MS, () => void save($))
502    $.clock.every(SCAN_MS, () => void scan($))
503    $.clock.every(60_000, () => void update($, tick, n => n + 1))
504
505    return next(e)
506  })
507
508  on('session.end', async ($, e, next) => {
509    if (me !== null) {
510      await $.fs.write(`${dir()}/${me.id}.json`, JSON.stringify({ ...me, ended: true, updatedAt: Date.now() }))
511    }
512
513    return next(e)
514  })
515
516  on('prompt.submit', async ($, e, next) => {
517    const isSystem = /^\s*</.test(e.text)
518    await setState($, 'working', '', isSystem ? undefined : snippet(e.text))
519
520    return next(e)
521  })
522
523  on('tool.call', async ($, e, next) => {
524    if (e.tool === 'AskUserQuestion') {
525      await setState($, 'question', snippet(e.questions[0]?.question ?? ''))
526      const ran = await next(e)
527      await setState($, 'working')
528
529      return ran
530    }
531
532    // a tool running means any permission or question before it is answered
533    if (me !== null && me.state !== 'working') {
534      await setState($, 'working')
535    }
536
537    return next(e)
538  })
539
540  // a verdict of ask is the permission prompt about to show; a query has no tool_use_id
541  on('tool.check', async ($, e, next) => {
542    const verdict = await next(e)
543    if (verdict.decision === 'ask' && e.tool_use_id !== undefined && e.tool !== 'AskUserQuestion') {
544      await setState($, 'permission', summarize(e.tool, e.input))
545    }
546
547    return verdict
548  })
549
550  on('turn.complete', async ($, e, next) => {
551    if (me !== null && me.state !== 'done') {
552      await setState($, 'done')
553    }
554    if (e.agentId === undefined && e.reason === 'answer') {
555      void refreshBrief($)
556    }
557
558    return next(e)
559  })
560
561  on('command.run', { command: 'whereami' }, async $ => {
562    seenCount = -1
563    const text = await refreshBrief($)
564
565    return { text: text || 'No brief yet: nothing to summarize, or the model did not answer.' }
566  })
567
568  on('command.run', { command: 'desk' }, async $ => {
569    await scan($)
570    const opened = await $.ui.open({ id: DESK, title: 'Desk' })
571
572    return { text: opened.isPlaced ? 'Desk opened.' : 'The pane needs a wider terminal (110 columns).' }
573  })
574
575  on('command.run', { command: 'view' }, async ($, e) => {
576    const cwd = await $.session.cwd()
577    let arg = e.args.trim()
578
579    if (arg === '') {
580      const desktop = `${home}/Desktop`
581      const ls = await $.process.run(['ls', '-t', desktop])
582      const shot = ls.stdout.split('\n').find(name => /^Screenshot/i.test(name))
583      if (!shot) {
584        return { text: 'No screenshot on the Desktop; pass a path.' }
585      }
586      arg = `${desktop}/${shot}`
587    }
588
589    const path = arg.startsWith('/') ? arg : arg.startsWith('~/') ? `${home}${arg.slice(1)}` : `${cwd}/${arg}`
590    let next: Viewing
591
592    try {
593      if (IMAGE.test(path)) {
594        const cache = `${home}/.cache/desk-view`
595        await $.process.run(['mkdir', '-p', cache])
596        const file = `${cache}/${Date.now()}.png`
597        const made = await $.process.run(['sips', '-s', 'format', 'png', path, '--out', file])
598        if (made.exitCode !== 0) {
599          throw new Error(made.stderr.trim() || 'could not convert the image')
600        }
601        const dims = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file])
602        const width = Number(/pixelWidth: (\d+)/.exec(dims.stdout)?.[1] ?? 0)
603        const height = Number(/pixelHeight: (\d+)/.exec(dims.stdout)?.[1] ?? 0)
604        next = { kind: 'image', path, file, width, height }
605      } else {
606        const text = (await $.fs.read(path)) as string
607        const ext = path.split('.').pop()?.toLowerCase() ?? ''
608        next = MARKDOWN.test(path)
609          ? { kind: 'markdown', path, text: text.slice(0, 60_000) }
610          : { kind: 'code', path, text: text.slice(0, 60_000), language: LANGUAGE[ext] ?? '' }
611      }
612    } catch (error) {
613      next = { kind: 'error', path, message: String(error) }
614    }
615
616    await update($, viewing, () => next)
617    const opened = await $.ui.open({ id: VIEWER, title: path.split('/').pop() ?? 'View' })
618
619    return { text: opened.isPlaced ? `Viewing ${path}` : 'The pane needs a wider terminal (110 columns).' }
620  })
621
622  on('ui.render', { component: 'Pane', requestId: DESK }, async ($, e) => {
623    const { Box, Button, Text } = $.ui.resolve(e)
624    const all = await read($, sessions)
625    const id = await read($, self)
626    const gone = await read($, hidden)
627    const open = await read($, expanded)
628    const seen = await read($, tabs)
629    const said = await read($, note)
630    const justThis = await read($, only)
631    await read($, tick)
632    const columns = e.viewport?.columns ?? 60
633    const rows = e.viewport?.rows ?? 30
634    const need = waiting(all.filter(s => s.id !== id)).length
635    const rule = '─'.repeat(250)
636    const inner = Math.max(30, columns - 18)
637
638    // every session takes the same rows: a header, a two-line brief, then what it is blocked on and its items
639    const shownSessions = justThis ? all.filter(s => s.id === id) : all
640    const block = shownSessions.map(s => {
641      const mine = (s.items ?? []).filter(i => !gone.includes(i.id))
642      const isOpen = justThis || open.includes(s.id)
643      const shown = isOpen ? mine : mine.slice(0, 3)
644      const isBlocked = (s.state === 'permission' || s.state === 'question') && Boolean(s.detail)
645      const text = s.brief || (s.prompt && !s.prompt.startsWith('<') ? `Asked: ${s.prompt}` : '')
646      const lines = Math.max(1, Math.ceil(text.length / inner))
647      const extras = (isBlocked ? 1 : 0) + shown.length + (mine.length > 3 && !justThis ? 1 : 0)
648      const height = 2 + lines + extras + (s.id === id ? 2 : 0) + (extras > 0 ? 1 : 0)
649
650      return { s, mine, isOpen, shown, isBlocked, height }
651    })
652    let used = 2
653    const fits = block.filter(b => (used += b.height) <= rows)
654    const hiddenCount = block.length - fits.length
655
656    return (
657      <Box flexDirection="column">
658        <Box justifyContent="space-between">
659          <Text bold>
660            {justThis ? 'This chat' : `${all.length} sessions · ${need} waiting on you`}
661          </Text>
662          <Button
663            key="only"
664            label={justThis ? 'All sessions' : 'This chat only'}
665            onPress={() => void update($, only, value => !value)}
666          />
667        </Box>
668        {said !== '' && <Text color="yellow">{said}</Text>}
669        {shownSessions.length === 0 && <Text dimColor>{justThis ? 'This chat has not reported yet.' : 'No sessions reporting yet.'}</Text>}
670        {fits.map(({ s, mine, isOpen, shown, isBlocked }) => {
671          const color = s.state === 'permission' || s.state === 'question' ? 'yellow' : s.state === 'done' ? 'green' : undefined
672
673          return (
674            <Box key={s.id} flexDirection="column">
675              <Box height={1} overflow="hidden">
676                <Text dimColor wrap="wrap">{rule}</Text>
677              </Box>
678              <Box
679                flexDirection="column"
680                borderStyle={s.id === id ? 'round' : undefined}
681                borderColor={s.id === id ? 'cyan' : undefined}
682                paddingX={s.id === id ? 0 : 1}
683              >
684              <Box justifyContent="space-between">
685                <Box flexGrow={1} flexShrink={1}>
686                  <Text bold color={color} wrap="truncate-end">
687                    {(s.term && seen[s.term]) || name(s)}
688                  </Text>
689                </Box>
690                <Box flexShrink={0} paddingLeft={2}>
691                  <Text dimColor>
692                    {s.project} · {s.state === 'idle' ? '' : `${LABEL[s.state]} · `}{ago(s.since)}{'  '}
693                  </Text>
694                  {s.id === id ? (
695                    <Text bold color="cyan">you are here</Text>
696                  ) : (
697                    <Button key={`go-${s.id}`} label="Go To" onPress={() => void openSession($, s)} />
698                  )}
699                </Box>
700              </Box>
701              <Box paddingLeft={3}>
702                {s.brief || (s.prompt && !s.prompt.startsWith('<')) ? (
703                  <Text wrap="wrap">{s.brief || `Asked: ${s.prompt}`}</Text>
704                ) : (
705                  <Text dimColor>No summary yet; it appears after the first finished turn.</Text>
706                )}
707              </Box>
708              {(isBlocked || shown.length > 0) && <Text> </Text>}
709              {isBlocked && (
710                <Box paddingLeft={3}>
711                  <Text color="yellow" wrap="truncate-end">→ {s.detail}</Text>
712                </Box>
713              )}
714              {shown.map(i => (
715                <Box key={i.id} paddingLeft={3} justifyContent="space-between">
716                  <Box flexGrow={1} flexShrink={1} paddingRight={1}>
717                    <Text color={i.kind === 'question' ? 'yellow' : undefined} wrap="truncate-end">
718                      {i.kind === 'question' ? '?' : '☐'} {i.text}
719                    </Text>
720                  </Box>
721                  <Box flexShrink={0}>
722                    <Button key={`act-${s.id}-${i.id}`} label={i.kind === 'question' ? 'Reply' : 'Do it'} onPress={() => void actOn($, s, i)} />
723                    <Button key={`rm-${s.id}-${i.id}`} label="✕" onPress={() => void dismiss($, s, i)} />
724                  </Box>
725                </Box>
726              ))}
727              {mine.length > 3 && !justThis && (
728                <Box paddingLeft={3}>
729                  <Button
730                    key={`more-${s.id}`}
731                    label={isOpen ? 'show fewer' : `+${mine.length - 3} more`}
732                    onPress={() =>
733                      void update($, expanded, list =>
734                        list.includes(s.id) ? list.filter(x => x !== s.id) : [...list, s.id],
735                      )
736                    }
737                  />
738                </Box>
739              )}
740              </Box>
741            </Box>
742          )
743        })}
744        {hiddenCount > 0 && <Text dimColor>+{hiddenCount} more sessions below; widen or heighten the pane</Text>}
745      </Box>
746    )
747  })
748
749  on('ui.render', { component: 'Pane', requestId: VIEWER }, async ($, e) => {
750    const { Box, Code, Markdown, Text } = $.ui.resolve(e)
751    const v = await read($, viewing)
752    const columns = Math.max(20, (e.viewport?.columns ?? 100) - 4)
753    const rows = Math.max(5, (e.viewport?.rows ?? 30) - 4)
754
755    if (v === null) {
756      return <Text dimColor>Nothing to view. Use /view &lt;path&gt;.</Text>
757    }
758    if (v.kind === 'error') {
759      return <Text color="red">{v.path}: {v.message}</Text>
760    }
761    if (v.kind === 'markdown') {
762      return <Markdown text={v.text} />
763    }
764    if (v.kind === 'code') {
765      return <Code source={v.text} language={v.language || undefined} path={v.path} />
766    }
767
768    if (e.surface !== 'terminal') {
769      return <Text dimColor>Images show in the terminal only.</Text>
770    }
771    const { Image } = $.ui.resolve(e)
772
773    // a terminal cell is about twice as tall as wide
774    const ratio = v.height / Math.max(1, v.width) / 2
775    let cols = columns
776    let imgRows = Math.max(1, Math.round(cols * ratio))
777    if (imgRows > rows) {
778      imgRows = rows
779      cols = Math.max(1, Math.round(imgRows / ratio))
780    }
781
782    return (
783      <Box flexDirection="column">
784        <Text dimColor>{v.path} · {v.width}×{v.height}</Text>
785        <Image source={{ file: v.file, format: 'png' }} columns={Math.min(255, cols)} rows={Math.min(255, imgRows)} alt={v.path} />
786      </Box>
787    )
788  })
789}
790
types/index.d.ts 36 lines
1export type SessionState = 'permission' | 'question' | 'done' | 'working' | 'idle'
2
3export type Item = { id: string; kind: 'question' | 'action'; text: string }
4
5export type Session = {
6  id: string
7  project: string
8  startedAt: number
9  title: string
10  custom: string
11  label: string
12  tty: string
13  term: string
14  cwd: string
15  state: SessionState
16  detail: string
17  prompt: string
18  brief: string
19  items: Item[]
20  dismissed: string[]
21  since: number
22  updatedAt: number
23}
24
25export type Viewing =
26  | { kind: 'image'; path: string; file: string; width: number; height: number }
27  | { kind: 'markdown'; path: string; text: string }
28  | { kind: 'code'; path: string; text: string; language: string }
29  | { kind: 'error'; path: string; message: string }
30
31declare module 'claude-code' {
32  interface PluginState {
33    desk: { sessions: Session[]; self: string; note: string; only: boolean; expanded: string[]; hidden: string[]; tabs: Record<string, string>; tick: number; viewing: Viewing | null }
34  }
35}
36