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

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.
/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./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.sips. Without Ghostty the pane still works, but not those parts./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.
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.
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.
An early version. The summary and open-item capture are model judgments and will be wrong sometimes.
MIT
hooks/register.tsx 790 lines1import { 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 <path>.</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}
790types/index.d.ts 36 lines1export 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