Summarize your finished Claude Code conversations into a searchable recall index, surface relevant past work when you start something new, and see it all in…

A Claude Code plugin that summarizes your finished conversations into a searchable recall index, so you (and Claude) can find past work when you start something new — and triage what you're done with.
Runs entirely locally on your Claude Code subscription. No API key, no data leaves your machine.
Skills (namespaced under convo-digest):
| Skill | What it does |
|---|---|
/convo-digest:digest | Summarize conversations that changed since the last run into the recall index. |
/convo-digest:recall | Find a relevant past conversation for what you're starting on, and offer to resume it. |
/convo-digest:digest-archive | Review recent conversations and archive the ones you're done with so they stop cluttering recall. |
/convo-digest:setup-nightly | Schedule the digest to run itself overnight (macOS, Linux, Windows). |
/convo-digest:profile-repos | Tag repos work/personal so recall can label and rank results. |
Plus a SessionStart nudge: when finished conversations aren't indexed yet, it offers to refresh (once per conversation). Because a SessionStart hook can only reach you through the model relaying it, the offer is self-healing — it keeps re-surfacing each new conversation until the backlog is actually cleared or you opt out ("not today" or off for good), so a silently-dropped offer isn't lost for the day.
If you'd rather never be asked, let the digest run itself overnight:
/convo-digest:setup-nightly
You pick one of two mechanisms:
~/.claude/digest/run-nightly.*, which you can also run by hand to debug a bad night. Fires even when the app is closed (macOS/systemd catch up on wake), and is the only option on CLI-only / headless / server installs. On-plan: no API key, no impact on your interactive session usage.Once it's set up the daily nudge goes quiet, because the schedule owns the drain. A few things still get through, so automation can't fail silently: if the last run failed or never finished, if no run has succeeded in 36 hours, if the backlog grows past 25 (OS scheduler), or if the scheduled job disappears, the hook tells you the automation is broken instead of saying nothing.
Scheduled-task sessions themselves (the nightly run, and any other scheduled task such as a standup brief) are kept out of the index and never renamed.
Manage it directly with:
python3 <plugin>/src/install_schedule.py --status # installed? which mechanism?
python3 <plugin>/src/install_schedule.py --start # run it right now
python3 <plugin>/src/install_schedule.py --time 02:00 # re-schedule
python3 <plugin>/src/install_schedule.py --uninstall # remove it
python3 <plugin>/src/ledger.py show # recent runs + logged issues
Every digest run — scheduled or manual — records its start and end in ~/.claude/digest/ledger.jsonl, and logs anything that went wrong along the way (timeouts, failed summarizers, refused tools, retries) as an issue in the same append-only file. Review it with ledger.py show [--days N]. A lock in the same directory keeps two digests from draining at once.
Unattended runs follow strict rules: they never act on setup nudges, never change code, config or memory, retry a failed step at most once, and log every problem instead of improvising a fix.
macOS note: launchd agents have no Full Disk Access, so the installer refuses to schedule a plugin living under
Documents,DesktoporDownloads— the job would die with a bareEPERMevery night. Keep the plugin somewhere like~/convo-digest.Linux note: without
sudo loginctl enable-linger $USER, a user timer only runs while you're logged in.
python3 ≥ 3.10 on your PATH. That's the only hard dependency — the engine is pure Python and falls back to a dependency-free token estimator.pip install tiktoken for sharper token counts. Not required; without it, a conservative character heuristic is used./plugin marketplace add pariidanDKE/convo-digest
/plugin install convo-digest@pariidan-plugins
Then start a new session (the SessionStart hook installs the summarization workflow into ~/.claude/workflows/ on first run). After that, just say "refresh the digest" or run /convo-digest:digest.
With title writeback on, each conversation gets the digest's title in two places:
custom-title record — the claude --resume picker;App titles are set through the app's own rename tool after each run, and every indexed conversation is re-checked each time, so a title the app reverts is put back on the next run. Only titles the app generated (or the digest set earlier) are replaced; a name you gave a session yourself is never touched.
Know the app search's limits: it matches titles only across your 50 most recently active sessions, and its text search scans message content (not titles) across roughly the 200 most recent. For anything older, use /convo-digest:recall.
A pane over the same index: type /mission, or click the ◉ Mission Control band above the prompt.
/mission home makes the chat you type it in Mission Control's home: it opens the pane by itself, and the band in every other chat takes you there. On macOS, tools/raise/build.sh builds a small helper app that brings that chat's own window to the front (it needs Accessibility permission); without it the band says so.
flowchart TD
N["🔔 SessionStart nudge<br/><i>“N finished convos aren't indexed — refresh?”</i>"]
T[("📄 Local transcripts<br/>~/.claude/projects/…/*.jsonl")]
P["<b>Prepare</b> · no model calls<br/>detect changed convos, strip to text,<br/>tier: whole / over-cap / trivial"]
S["<b>Summarize</b> · background workflow<br/>one small Haiku agent per convo<br/>→ record<br/>(title · topics · gist · status · unresolved · entities · workstream · kind)"]
I[("🗂 Recall index<br/>~/.claude/digest/index.json")]
R["🔍 <b>/recall</b> — pull<br/>search past work,<br/>offer to resume it"]
A["🧹 <b>/digest-archive</b> — push<br/>morning triage: archive<br/>what you're done with"]
N -. offers /digest .-> P
T --> P --> S --> I
I --> R
I --> A
Everything runs locally, incrementally, and checkpointed — a digest run only touches conversations that changed since the last one.
~/.claude/projects/**/*.jsonl), strips each to user+assistant text, and summarizes changed ones into a compact record (title, topics, gist, status, unresolved, key entities, workstream, kind) stored in ~/.claude/digest/index.json.~/.claude/digest/workstreams.json and grows as new epics appear; the summarizer reuses an existing name when one fits, and a small curator pass merges a new name into an existing one when they are the same epic. Each conversation also gets a kind: build, fix, review, investigate, plan or admin. Conversations summarized before these existed are tagged from their stored summary, a few batches per run.All processing is local. The plugin never sends your conversations anywhere; it only reads local transcript files and writes a local index under ~/.claude/digest/.
hooks/mission-control/register.tsx 1297 lines1// Mission Control: one pane over all your Claude Code work.
2//
3// /mission [today|yesterday|week] open the pane (optionally on a range)
4// /mission ask <question> open it and ask the agent straight away
5// /mission home [off] this session opens the pane by itself
6// /mission band on|off the one-line way in above every prompt
7//
8// Top to bottom: the day's standup brief (folded, unfolds in full), an Ask box
9// answered from the digest index, a timeline of your conversations grouped by workstream
10// (click one for its gist and a way to open it), then the routines (scheduled tasks)
11// and the digest's run health. A status-line entry keeps the routines' state visible
12// with the pane closed.
13//
14// Data comes from src/mission_control.py (read-only over the transcripts, the digest
15// index, the app's session files and the digest run ledger).
16import { atom, read, update } from 'claude-code'
17import type { Elements, EngineInterface, Register } from 'claude-code'
18
19import type {
20 McAsk,
21 McColorMode,
22 McKind,
23 McRangeKey,
24 McReader,
25 McRef,
26 McRoutine,
27 McRowsMode,
28 McSession,
29 McSnapshot,
30 McStatus,
31 McTask,
32 McView,
33} from '../../types'
34import {
35 duration,
36 hm,
37 rowsOptions,
38 routineName,
39 runDot,
40 statusColor,
41 statusLabel,
42 timeline,
43 visible as inView,
44 workedMinutes,
45 timelineStrips,
46 truncate,
47 when,
48} from './layout'
49
50const PLUGIN = 'convo-digest'
51const PANE = 'mission-control'
52const TITLE = 'Mission Control'
53const ASK_MODEL = 'sonnet'
54const VIEW_VERSION = 3 // bump to move everyone onto a new default view
55const PX_PER_CELL = 8 // the desktop's pane width per text cell, about
56const WIDE = 140 // a pane this many cells wide reads beside the timeline
57const READ_LAST = 40 // turns the reader shows; "Show earlier" adds as many
58const READ_POLL_MS = 4000 // how often an open reader looks for new messages
59
60const snapAtom = atom({ plugin: 'convo-digest', key: 'snap' } as const, null)
61const viewAtom = atom({ plugin: 'convo-digest', key: 'view' } as const,
62 { range: 'today', rows: 'workstream', color: 'project' } as McView)
63const selectedAtom = atom({ plugin: 'convo-digest', key: 'selected' } as const, null)
64const askAtom = atom({ plugin: 'convo-digest', key: 'ask' } as const,
65 { question: '', busy: false, answer: null, refs: {}, error: null } as McAsk)
66const statusAtom = atom({ plugin: 'convo-digest', key: 'status' } as const,
67 { loading: false, error: null, tasks: null } as McStatus)
68const bandAtom = atom({ plugin: 'convo-digest', key: 'band' } as const, true)
69const standupOpenAtom = atom({ plugin: 'convo-digest', key: 'standupOpen' } as const, false)
70const summarizingAtom = atom({ plugin: 'convo-digest', key: 'summarizing' } as const, null as number | null)
71const collapsedAtom = atom({ plugin: 'convo-digest', key: 'collapsed' } as const, [] as string[])
72const actingAtom = atom({ plugin: 'convo-digest', key: 'acting' } as const, null as string | null)
73const readerAtom = atom({ plugin: 'convo-digest', key: 'reader' } as const, null as McReader | null)
74const resummarizingAtom = atom({ plugin: 'convo-digest', key: 'resummarizing' } as const, null as string | null)
75
76/** Fold or unfold one timeline group, by its heading. */
77async function toggleGroup($: $T, label: string): Promise<void> {
78 await update($, collapsedAtom, list => (list.includes(label) ? list.filter(l => l !== label) : [...list, label]))
79}
80const DIGEST = { command: 'convo-digest:digest', args: 'now' } as const
81const SESSION_ACTION = 'convo-digest:session' // this plugin's skill: the app's archive and pin tools
82
83type $T = EngineInterface
84type El = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Select' | 'Input' | 'Markdown' | 'Client'>
85
86const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
87
88/** Best-effort trace at ~/.claude/mission-control/mod-status.json: when the module
89 * started, the last refresh and its error, so a pane that stays empty can be diagnosed. */
90async function breadcrumb($: $T, patch: Record<string, unknown>): Promise<void> {
91 try {
92 const home = await $.env.get('HOME')
93 if (!home) return
94 const path = `${home}/.claude/mission-control/mod-status.json`
95 let cur: Record<string, unknown> = {}
96 try {
97 cur = JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
98 } catch {
99 cur = {}
100 }
101 await $.fs.write(path, JSON.stringify({ ...cur, ...patch }, null, 1))
102 } catch {
103 // tracing must never break the pane
104 }
105}
106
107// ------------------------------------------------------------------ data
108async function collect($: $T, args: string[]): Promise<unknown> {
109 const script = `${$.plugin.root}/src/mission_control.py`
110 const r = await $.process.run(['python3', script, ...args], { timeoutMs: 120_000 })
111 if (r.exitCode !== 0) {
112 const last = r.stderr.trim().split('\n').pop()
113 throw new Error(last || `collect.py exited ${r.exitCode}`)
114 }
115 return JSON.parse(r.stdout)
116}
117
118async function loadTasks($: $T): Promise<McTask[] | null> {
119 try {
120 const res = await $.mcp.call('scheduled-tasks', 'list_scheduled_tasks', {})
121 if (res.isError) return null
122 const block = res.content.find(b => b.type === 'text') as { text?: string } | undefined
123 const list = JSON.parse(block?.text ?? '[]') as Record<string, unknown>[]
124 return list.map(t => ({
125 taskId: String(t.taskId),
126 enabled: t.enabled !== false,
127 schedule: typeof t.schedule === 'string' ? t.schedule : null,
128 nextRunAt: typeof t.nextRunAt === 'string' ? t.nextRunAt : null,
129 }))
130 } catch {
131 return null // the app's task server isn't reachable from here
132 }
133}
134
135const MARK: Record<string, string> = { ok: '✓', nothing: '✓', warn: '!', failed: '✗', running: '…' }
136
137/** The status line says only what needs attention (a routine whose last run failed or
138 * warned, digest issues since its last good run) and is empty otherwise. */
139function statusLine($: $T, snap: McSnapshot): void {
140 const parts: string[] = []
141 for (const r of snap.routines.filter(x => !x.oneTime)) {
142 const run = r.runs[0]
143 if (run && (run.status === 'failed' || run.status === 'warn')) {
144 parts.push(`${routineName(r).toLowerCase()} ${MARK[run.status]}`)
145 }
146 }
147 if (snap.digest.issuesSinceOk) parts.push(`${snap.digest.issuesSinceOk} digest issue(s)`)
148 $.ui.status(parts.length ? `◉ MC · ${parts.join(' · ')}` : undefined)
149}
150
151/** A local calendar day `back` days before `now`, as YYYY-MM-DD and as a label. */
152function dayAt(now: number, tzOffsetMin: number, back: number): { value: string; label: string } {
153 const d = new Date(now + tzOffsetMin * 60_000 - back * 86_400_000)
154 const value = d.toISOString().slice(0, 10)
155 const label = back === 0 ? 'Today' : back === 1 ? 'Yesterday'
156 : `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getUTCDay()]} ${d.getUTCDate()} ` +
157 `${['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getUTCMonth()]}` +
158 (back > 300 ? ` ${d.getUTCFullYear()}` : '')
159 return { value, label }
160}
161
162const CUSTOM_DAYS = 60 // how far back the pickers reach (a Select holds 64 at most)
163const MAX_SPAN = 31 // the collector's MAX_DAYS
164
165/** The collector's --range for a view: a custom range is its two days. */
166function rangeArg(view: McView): string {
167 return view.range === 'custom' && view.from && view.to ? `${view.from}..${view.to}` : view.range === 'custom'
168 ? '7d' : view.range
169}
170
171/** Whether the view shows a single day (where one row per day means nothing). */
172function oneDay(view: McView): boolean {
173 return view.range === 'today' || view.range === 'yesterday' ||
174 (view.range === 'custom' && !!view.from && view.from === view.to)
175}
176
177async function refresh($: $T): Promise<void> {
178 const key = rangeArg(await read($, viewAtom))
179 await update($, statusAtom, s => ({ ...s, loading: true }))
180 try {
181 const [snap, tasks] = await Promise.all([
182 collect($, ['snapshot', '--range', key]) as Promise<McSnapshot>,
183 loadTasks($),
184 ])
185 await update($, snapAtom, () => snap)
186 await update($, statusAtom, s => ({ ...s, loading: false, error: null, tasks: tasks ?? s.tasks }))
187 statusLine($, snap)
188 await breadcrumb($, { lastRefreshAt: snap.generatedAt, range: key, tookMs: snap.tookMs,
189 tasksReachable: tasks !== null, lastError: null })
190 } catch (err) {
191 await update($, statusAtom, s => ({ ...s, loading: false, error: errText(err) }))
192 await breadcrumb($, { lastErrorAt: await $.clock.now(), lastError: errText(err) })
193 }
194}
195
196async function setView($: $T, patch: Partial<McView>): Promise<void> {
197 const before = await read($, viewAtom)
198 const next = { ...before, ...patch }
199 if (next.range === 'custom') {
200 // From after To swaps; a span past what the collector draws keeps its end
201 if (next.from && next.to && next.from > next.to) [next.from, next.to] = [next.to, next.from]
202 if (next.from && next.to) {
203 const span = (Date.parse(next.to) - Date.parse(next.from)) / 86_400_000 + 1
204 if (span > MAX_SPAN) {
205 const keep = patch.from ? 'from' : 'to'
206 const shift = (MAX_SPAN - 1) * 86_400_000
207 if (keep === 'from') next.to = new Date(Date.parse(next.from) + shift).toISOString().slice(0, 10)
208 else next.from = new Date(Date.parse(next.to) - shift).toISOString().slice(0, 10)
209 }
210 }
211 }
212 if (oneDay(next) && next.rows === 'day') next.rows = 'project'
213 await update($, viewAtom, () => next)
214 await $.store.set('view', { ...next, version: VIEW_VERSION })
215 if (rangeArg(next) !== rangeArg(before)) {
216 await update($, selectedAtom, () => null)
217 await refresh($)
218 }
219}
220
221function findSession(snap: McSnapshot | null, ask: McAsk, id: string): { app: string | null; title: string } | null {
222 const s = snap?.sessions.find(x => x.id === id)
223 if (s) return { app: s.app, title: s.title }
224 const ref = Object.values(ask.refs).find(r => r.id === id)
225 return ref ? { app: ref.app, title: ref.title } : null
226}
227
228async function openSession($: $T, id: string, surface?: Parameters<$T['ui']['copy']>[0]['surface']): Promise<void> {
229 const found = findSession(await read($, snapAtom), await read($, askAtom), id)
230 if (!found?.app) {
231 await $.ui.copy({ text: `claude --resume ${id}`, surface })
232 $.ui.toast('No desktop session for this one — copied `claude --resume` to the clipboard')
233 return
234 }
235 const r = await $.process.run(['open', `claude://claude.ai/epitaxy/${found.app}`])
236 if (r.exitCode !== 0) $.ui.toast(`Couldn't open the session: ${r.stderr.trim() || r.exitCode}`)
237}
238
239// ------------------------------------------------------------------ reader
240/**
241 * Open a conversation in the reader: beside the timeline in a wide pane, in place of
242 * the overview in a narrow one. It reads the transcript, read-only, and reads it
243 * again every few seconds while it's open, so a running conversation shows its replies.
244 */
245async function openReader($: $T, id: string, wide: boolean): Promise<void> {
246 const cur = await read($, readerAtom)
247 if (cur?.id === id) return closeReader($)
248 await update($, readerAtom, () => ({ id, last: READ_LAST, loading: true, expanded: false }))
249 await update($, selectedAtom, () => id)
250 await loadReader($, true)
251 await $.ui.scroll(wide ? { to: { key: 'reader' }, in: PANE, block: 'nearest' } : { to: 'start', in: PANE })
252 .catch(() => undefined)
253}
254
255async function closeReader($: $T): Promise<void> {
256 const cur = await read($, readerAtom)
257 await update($, readerAtom, () => null)
258 // back where the conversation was clicked
259 if (cur) await $.ui.scroll({ to: { key: `go-${cur.id}` }, in: PANE, block: 'center' }).catch(() => undefined)
260}
261
262/** Read the open conversation; unless `force`, only when its file has changed. */
263async function loadReader($: $T, force: boolean): Promise<void> {
264 const cur = await read($, readerAtom)
265 if (!cur) return
266 const args = ['transcript', '--id', cur.id, '--last', String(cur.last)]
267 if (!force && cur.mtime !== undefined) args.push('--if-newer', String(cur.mtime))
268 try {
269 const got = await collect($, args) as Omit<McReader, 'last' | 'loading'> & { unchanged?: boolean }
270 await update($, readerAtom, r => {
271 if (!r || r.id !== cur.id) return r // closed or switched meanwhile
272 if (got.unchanged) return { ...r, loading: false }
273 return { ...got, id: r.id, last: r.last, loading: false, expanded: r.expanded }
274 })
275 } catch (err) {
276 await update($, readerAtom, r => (r && r.id === cur.id ? { ...r, loading: false, error: errText(err) } : r))
277 }
278}
279
280async function readEarlier($: $T): Promise<void> {
281 await update($, readerAtom, r => (r ? { ...r, last: r.last + READ_LAST, loading: true } : r))
282 await loadReader($, true)
283}
284
285/** The reader: what the conversation was about, the way in, then its messages. */
286function Reader(el: El, $: $T, r: McReader, s: McSession | null, snap: McSnapshot, now: number, self: string,
287 busy: string | null) {
288 const { Box, Text, Button, Markdown } = el
289 const tz = snap.tzOffsetMin
290 const color = s ? statusColor(s.scheduled ? 'routine' : s.status) : '#30363d'
291 const title = s?.title ?? r.id.slice(0, 8)
292 const live = s ? now - s.last < 5 * 60_000 : false
293 const turns = r.turns ?? []
294 return (
295 <Box key="reader" flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
296 <Box flexDirection="row" justifyContent="space-between" gap={2}>
297 <Text bold wrap="wrap">{title}</Text>
298 <Box flexDirection="row" gap={2}>
299 <Button key="reader-open" label={!s || s.app ? 'Open ↗' : 'Copy resume command'} variant="primary"
300 onPress={e => { void openSession($, r.id, e.surface) }} />
301 <Button key="reader-close" label="Close ✕" plain dimColor onPress={() => { void closeReader($) }} />
302 </Box>
303 </Box>
304 {s
305 ? (
306 <Text dimColor wrap="wrap">
307 {`${s.project}${s.branch ? ` · ${s.branch}` : ''} · ${when(s.first, snap, now)}–${hm(s.last, tz)} · ` +
308 `${duration(s.activeMin)} active${s.tickets.length ? ` · ${s.tickets.join(', ')}` : ''}` +
309 (s.archived ? ' · archived' : '')}
310 {live ? <Text color="#3fb950">{' ● live'}</Text> : ''}
311 </Text>
312 )
313 : null}
314 {s?.gist ? <Markdown text={s.gist} /> : null}
315 {s?.open ? <Text dimColor wrap="wrap">{`Left for later: ${s.open}`}</Text> : null}
316 {s && !s.scheduled
317 ? (
318 <Box flexDirection="row" gap={2} marginTop={1}>
319 {SummarizeButton(el, $, s, `summarize-r-${s.id}`, self, busy)}
320 {PinButton(el, $, s, `pin-r-${s.id}`)}
321 {ArchiveButton(el, $, s, `archive-r-${s.id}`, self)}
322 {MoveTo(el, $, s, snap)}
323 </Box>
324 )
325 : null}
326 <Box flexDirection="row" gap={2} marginTop={1} alignItems="center">
327 <Button key="reader-toggle" plain
328 label={`${r.expanded ? '▾' : '▸'} Conversation · ${r.loading && r.total === undefined ? '…'
329 : `${r.total ?? 0} message${r.total === 1 ? '' : 's'}`}`}
330 onPress={() => { void update($, readerAtom, x => (x ? { ...x, expanded: !x.expanded } : x)) }} />
331 {r.expanded
332 ? <Text dimColor>{`${r.hidden ? `last ${turns.length} shown · ` : ''}read-only, updates while open`}</Text>
333 : null}
334 </Box>
335 {r.error ? <Text color="#f85149" wrap="wrap">{r.error}</Text> : null}
336 {r.expanded && r.hidden
337 ? <Button key="reader-earlier" label={`↑ Show earlier messages (${r.hidden} more)`} plain dimColor
338 onPress={() => { void readEarlier($) }} />
339 : null}
340 {(r.expanded ? turns : []).map((t, i) => t.role === 'note'
341 ? <Text key={`t-${i}`} dimColor italic>{`— ${t.text} —`}</Text>
342 : (
343 <Box key={`t-${i}`} flexDirection="column" marginTop={1}
344 {...(t.role === 'user' ? { backgroundColor: '#262626', paddingX: 1 } : {})}>
345 <Text bold color={t.role === 'user' ? '#58a6ff' : '#d2a8ff'}>
346 {t.role === 'user' ? 'You' : 'Claude'}
347 <Text dimColor>{t.ts ? ` ${when(t.ts, snap, now)}` : ''}</Text>
348 </Text>
349 {t.text ? <Markdown text={t.text} /> : null}
350 {t.tools.length ? <Text dimColor wrap="wrap">{`⚙ ${t.tools.join(' · ')}`}</Text> : null}
351 </Box>
352 ))}
353 {r.expanded && turns.length > 3
354 ? (
355 <Box flexDirection="row" gap={2} marginTop={1}>
356 <Button key="reader-open-2" label={!s || s.app ? 'Open ↗' : 'Copy resume command'} plain
357 onPress={e => { void openSession($, r.id, e.surface) }} />
358 <Button key="reader-collapse" label="▴ Fold the conversation" plain dimColor
359 onPress={() => { void update($, readerAtom, x => (x ? { ...x, expanded: false } : x)) }} />
360 <Button key="reader-close-2" label="Close ✕" plain dimColor onPress={() => { void closeReader($) }} />
361 </Box>
362 )
363 : null}
364 </Box>
365 )
366}
367
368/** Drop the answer and empty the field, ready for the next question. */
369async function clearAsk($: $T): Promise<void> {
370 await update($, askAtom, a => a.busy ? a : { ...a, question: '', answer: null, refs: {}, error: null })
371}
372
373async function runAsk($: $T, question: string): Promise<void> {
374 const q = question.trim()
375 if (!q) return
376 await update($, askAtom, a => ({ ...a, question: q, busy: true, error: null }))
377 try {
378 const view = await read($, viewAtom)
379 const ctx = await collect($, ['ask', '--question', q, '--range', rangeArg(view)]) as {
380 system: string; prompt: string; refs: Record<string, McRef>
381 }
382 const res = await $.model.complete({
383 model: ASK_MODEL, system: ctx.system, prompt: ctx.prompt, maxTokens: 900, timeoutMs: 120_000,
384 })
385 if (!res.isAnswered) throw new Error(`the model gave no answer (${res.reason})`)
386 await update($, askAtom, a => ({ ...a, busy: false, answer: res.text, refs: ctx.refs }))
387 } catch (err) {
388 await update($, askAtom, a => ({ ...a, busy: false, error: errText(err) }))
389 }
390}
391
392async function runNow($: $T, r: McRoutine): Promise<void> {
393 try {
394 const res = await $.mcp.call('scheduled-tasks', 'run_scheduled_task', { taskId: r.task })
395 const text = res.content.find(b => b.type === 'text') as { text?: string } | undefined
396 $.ui.toast(res.isError ? `Couldn't start ${routineName(r)}: ${text?.text ?? 'error'}` : `Started ${routineName(r)}`)
397 if (!res.isError) setTimeout30($)
398 } catch (err) {
399 $.ui.toast(`Couldn't reach the app's scheduled tasks: ${errText(err)}`)
400 }
401}
402
403function setTimeout30($: $T): void {
404 $.clock.after(30_000, () => { void refresh($) })
405}
406
407// ------------------------------------------------------------------ drawing
408function cites(answer: string, refs: Record<string, McRef>): McRef[] {
409 const seen = new Set<string>()
410 const out: McRef[] = []
411 for (const m of answer.matchAll(/\[(\d+)\]/g)) {
412 const ref = m[1] ? refs[m[1]] : undefined
413 if (ref && !seen.has(ref.id)) {
414 seen.add(ref.id)
415 out.push(ref)
416 }
417 }
418 return out.slice(0, 8)
419}
420
421/** True when a run ended badly enough to call out. */
422const isBad = (status: string | undefined) => status === 'failed' || status === 'warn'
423
424function Routines(el: El, $: $T, snap: McSnapshot, tasks: McTask[] | null, now: number) {
425 const { Box, Text, Button } = el
426 // one-off tasks only earn a line when their run went wrong
427 const list = snap.routines.filter(r => !r.oneTime || isBad(r.runs[0]?.status))
428 if (!list.length) return <Text dimColor>No scheduled tasks have run in the last two weeks.</Text>
429 return (
430 <Box flexDirection="column">
431 {list.map(r => {
432 const last = r.runs[0]
433 const dot = runDot(last?.status)
434 const history = [...r.runs].slice(0, 7).reverse()
435 const task = tasks?.find(t => t.taskId === r.task)
436 const next = task?.nextRunAt ? Date.parse(task.nextRunAt) : NaN
437 const lastText = last?.start ? `${when(last.start, snap, now)} · ${dot.word}` : 'no runs yet'
438 return (
439 <Box key={`routine-${r.task}`} flexDirection="column">
440 <Box flexDirection="row" gap={1} alignItems="center">
441 <Text color={dot.color}>●</Text>
442 <Box width={22}><Text bold wrap="truncate-end">{routineName(r)}</Text></Box>
443 <Box width={30}>
444 <Text dimColor wrap="truncate-end">
445 {`${lastText}${Number.isFinite(next) ? ` · next ${when(next, snap, now)}` : ''}` +
446 `${task && !task.enabled ? ' · paused' : ''}`}
447 </Text>
448 </Box>
449 <Text>{history.map(x => <Text color={runDot(x.status).color}>{runDot(x.status).glyph}</Text>)}</Text>
450 {last?.app
451 ? <Button key={`open-run-${r.task}`} label="Open" plain dimColor
452 onPress={() => { void openSessionByApp($, last.app as string) }} />
453 : null}
454 {!r.oneTime && tasks !== null
455 ? <Button key={`run-${r.task}`} label="Run now" plain dimColor
456 onPress={() => { void runNow($, r) }} />
457 : null}
458 </Box>
459 {isBad(last?.status) && last?.detail
460 ? <Text color={dot.color} wrap="truncate-end">{` ${truncate(last.detail, 160)}`}</Text>
461 : null}
462 </Box>
463 )
464 })}
465 </Box>
466 )
467}
468
469/** Conversations the digest hasn't summarized yet, or that moved on since it did —
470 * less this session, which the digest never summarizes while it is being written. */
471function unsummarized(snap: McSnapshot, selfId: string): McSession[] {
472 return snap.sessions.filter(s => !s.scheduled && s.id !== selfId && (!s.digested || s.changedSinceDigest))
473}
474
475/** Summarize now: run `/convo-digest:digest now` in this session, as if typed. The skill
476 * does the rest — run log, lock, title sync — and `turn.complete` refreshes the pane
477 * when it is done. (A mod may not type a slash command into the prompt; it runs it.) */
478async function summarizeNow($: $T): Promise<void> {
479 if (await read($, summarizingAtom)) return // one at a time
480 const startedAt = await $.clock.now()
481 await update($, summarizingAtom, () => startedAt)
482 // not awaited: the command is queued until the session is idle and runs a long turn
483 $.command.run({ command: DIGEST.command, args: DIGEST.args }).catch(async err => {
484 await update($, summarizingAtom, () => null)
485 $.ui.toast(`Couldn't start the digest: ${errText(err)}`)
486 })
487}
488
489// ------------------------------------------------------------------ home session
490// Which chat is Mission Control's home lives in a file, not $.store: every copy of the
491// plugin (the installed one, a development one) keeps a store of its own, and the band
492// in every chat has to agree on where home is.
493type Home = { cli: string; app: string | null }
494
495async function homeFile($: $T): Promise<string | null> {
496 const home = await $.env.get('HOME').catch(() => undefined)
497 return home ? `${home}/.claude/mission-control/home.json` : null
498}
499
500async function readHome($: $T): Promise<Home | null> {
501 const path = await homeFile($)
502 if (!path) return null
503 try {
504 const h = JSON.parse(String(await $.fs.read(path))) as Partial<Home>
505 return typeof h.cli === 'string' ? { cli: h.cli, app: typeof h.app === 'string' ? h.app : null } : null
506 } catch {
507 return null
508 }
509}
510
511/** Make this chat the home, with its desktop id so the band elsewhere can link to it. */
512async function writeHome($: $T): Promise<void> {
513 const path = await homeFile($)
514 if (!path) return
515 const raw = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID').catch(() => undefined)
516 const app = typeof raw === 'string' && /^local_[\w-]+$/.test(raw) ? raw : null
517 await $.fs.write(path, JSON.stringify({ cli: await $.session.id(), app }, null, 1))
518}
519
520/**
521 * Go to the home chat's own window. The app's link can only open a chat in the main window,
522 * so the band opens ~/Applications/Mission Control Raise.app, a tiny AppleScript app with
523 * Accessibility permission that switches to the other Claude window through the Window menu,
524 * and reports when it can't. No fallback: without it the band says so.
525 */
526async function goHome($: $T): Promise<void> {
527 const home = await $.env.get('HOME').catch(() => undefined)
528 const raise = home ? `${home}/Applications/Mission Control Raise.app` : null
529 if (!raise || !(await $.fs.exists(raise).catch(() => false))) {
530 $.ui.toast('Mission Control Raise.app is not in ~/Applications, so the band cannot switch windows')
531 return
532 }
533 const r = await $.process.run(['open', '-a', raise])
534 if (r.exitCode !== 0) $.ui.toast(`Couldn't start Mission Control Raise: ${r.stderr.trim() || r.exitCode}`)
535}
536
537async function clearHome($: $T): Promise<void> {
538 const path = await homeFile($)
539 if (path) await $.fs.write(path, '{}')
540}
541
542/**
543 * Open the pane and fill it: what the command, the band and the home session share.
544 * The desktop asks for a wide pane (room to read a conversation beside the timeline);
545 * the home session, which is there only for this, for all the room the app will give.
546 * Both are requests: a width the person dragged the pane to wins, and is kept.
547 */
548async function openPane($: $T, isHome = false): Promise<boolean> {
549 const desktop = (await $.session.surface().catch(() => null)) === 'desktop'
550 const columns = desktop ? (isHome ? 1000 : 240) : undefined
551 const opened = await $.ui.open({ id: PANE, title: TITLE, ...(columns ? { columns } : {}) })
552 await refresh($)
553 return opened.isPlaced
554}
555
556/**
557 * Archive a conversation or bring it back, pin it or unpin it. Only the app's own session
558 * and sidebar tools can, and a plugin can't reach the app's tools, so this hands the
559 * session a short turn that calls them; the pane refreshes when that turn ends.
560 */
561type SessionVerb = 'archive' | 'unarchive' | 'pin' | 'unpin'
562const DOING: Record<SessionVerb, string> = { archive: 'Archiving', unarchive: 'Unarchiving', pin: 'Pinning',
563 unpin: 'Unpinning' }
564
565async function sessionAction($: $T, s: McSession, verb: SessionVerb): Promise<void> {
566 if (!s.app || (await read($, actingAtom))) return
567 await update($, actingAtom, () => s.app)
568 $.ui.toast(`${DOING[verb]} “${truncate(s.title, 40)}”`)
569 // not awaited: the command is queued until the session is idle
570 $.command.run({ command: SESSION_ACTION, args: `${verb} ${s.app}` }).catch(async err => {
571 await update($, actingAtom, () => null)
572 $.ui.toast(`Couldn't ${verb} it: ${errText(err)}`)
573 })
574}
575
576/**
577 * Summarize one conversation now, or again: hand this session a digest turn for just that
578 * conversation (`/convo-digest:digest conversation <id>`), even if it is unchanged. The
579 * pane refreshes when the turn ends. One at a time.
580 */
581async function summarizeOne($: $T, s: Pick<McSession, 'id' | 'title' | 'digested'>): Promise<void> {
582 if (await read($, resummarizingAtom)) return
583 await update($, resummarizingAtom, () => s.id)
584 $.ui.toast(`${s.digested ? 'Re-summarizing' : 'Summarizing'} “${truncate(s.title, 40)}”`)
585 // not awaited: the command is queued until the session is idle
586 $.command.run({ command: DIGEST.command, args: `conversation ${s.id}` }).catch(async err => {
587 await update($, resummarizingAtom, () => null)
588 $.ui.toast(`Couldn't start the digest: ${errText(err)}`)
589 })
590}
591
592/** Summarize or Re-summarize — not for routine runs, which the digest keeps out. */
593function SummarizeButton(el: El, $: $T, s: McSession, key: string, self: string, busy: string | null) {
594 const { Button } = el
595 if (s.scheduled) return null
596 const label = busy === s.id ? 'Summarizing…' : s.digested ? 'Re-summarize' : 'Summarize'
597 return <Button key={key} label={label} plain dimColor onPress={() => { void summarizeOne($, s) }} />
598}
599
600/** Archive or Unarchive — never for the session the pane runs in, which archiving would end. */
601function ArchiveButton(el: El, $: $T, s: McSession, key: string, self: string) {
602 const { Button } = el
603 if (!s.app || s.id === self) return null
604 return <Button key={key} label={s.archived ? 'Unarchive' : 'Archive'} plain dimColor
605 onPress={() => { void sessionAction($, s, s.archived ? 'unarchive' : 'archive') }} />
606}
607
608/** Pin it in the app's sidebar. Whether it is pinned already only the app knows (in no
609 * file a plugin can read), so this pins; pinning a pinned chat changes nothing, and
610 * unpinning stays in the sidebar's own menu. */
611function PinButton(el: El, $: $T, s: McSession, key: string) {
612 const { Button } = el
613 if (!s.app) return null
614 return <Button key={key} label="Pin to sidebar" plain dimColor onPress={() => { void sessionAction($, s, 'pin') }} />
615}
616
617/** Pin a conversation to another project; the collector remembers it. */
618async function moveSession($: $T, s: McSession, project: string): Promise<void> {
619 if (project === s.project) return
620 try {
621 await collect($, ['move', '--id', s.id, '--project', project])
622 $.ui.toast(`Moved “${truncate(s.title, 40)}” to ${project}`)
623 await refresh($)
624 } catch (err) {
625 $.ui.toast(`Couldn't move it: ${errText(err)}`)
626 }
627}
628
629/** The project picker under a conversation; the list is every known project. */
630function MoveTo(el: El, $: $T, s: McSession, snap: McSnapshot) {
631 const { Box, Text, Select } = el
632 const names = [...new Set([...(snap.projects ?? []), s.project])].sort((a, b) => a.localeCompare(b))
633 return (
634 <Box key={`mv-${s.id}`} flexDirection="row" gap={1}>
635 <Text dimColor>Project</Text>
636 <Select key={`move-${s.id}`} value={s.project}
637 options={names.map(n => ({ value: n, label: n }))}
638 onSelect={v => { void moveSession($, s, v) }} />
639 </Box>
640 )
641}
642
643async function openSessionByApp($: $T, app: string): Promise<void> {
644 const r = await $.process.run(['open', `claude://claude.ai/epitaxy/${app}`])
645 if (r.exitCode !== 0) $.ui.toast(`Couldn't open the session: ${r.stderr.trim() || r.exitCode}`)
646}
647
648function Detail(el: El, $: $T, s: McSession, snap: McSnapshot, now: number, self: string, busy: string | null) {
649 const { Box, Text, Button, Markdown } = el
650 const tz = snap.tzOffsetMin
651 const status = s.scheduled ? 'routine run' : statusLabel(s.status)
652 const color = statusColor(s.scheduled ? 'routine' : s.status)
653 return (
654 <Box key="detail" flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
655 <Text bold wrap="wrap">{s.title}</Text>
656 <Text dimColor wrap="wrap">
657 {`${s.project}${s.branch ? ` · ${s.branch}` : ''} · ${when(s.first, snap, now)}–${hm(s.last, tz)} · ` +
658 `${duration(s.activeMin)} active · `}
659 <Text color={color}>{status}</Text>
660 {s.changedSinceDigest ? ' · has new activity since its summary' : ''}
661 {s.tickets.length ? ` · ${s.tickets.join(', ')}` : ''}
662 {s.archived ? ' · archived' : ''}
663 </Text>
664 {s.gist
665 ? <Markdown text={s.gist} />
666 : <Text dimColor>{s.scheduled
667 ? 'Scheduled runs are kept out of the digest.'
668 : 'Not summarized yet — the next digest run will pick it up.'}</Text>}
669 {s.open ? <Text color={statusColor('unresolved')} wrap="wrap">{`Still open: ${s.open}`}</Text> : null}
670 {s.needsAction ? <Text color={statusColor('routine-failed')} wrap="wrap">{`Needs action: ${s.needsAction}`}</Text> : null}
671 <Box flexDirection="row" gap={2} marginTop={1}>
672 <Button key="read-selected" label="Read" variant="primary" hotkey="v"
673 onPress={() => { void openReader($, s.id, false) }} />
674 <Button key="open-selected" label={s.app ? 'Open in app' : 'Copy resume command'}
675 hotkey="o" onPress={e => { void openSession($, s.id, e.surface) }} />
676 {SummarizeButton(el, $, s, 'summarize-selected', self, busy)}
677 {PinButton(el, $, s, 'pin-selected')}
678 {ArchiveButton(el, $, s, 'archive-selected', self)}
679 <Button key="clear-selected" label="Clear" plain dimColor
680 onPress={() => { void update($, selectedAtom, () => null) }} />
681 {s.scheduled ? null : MoveTo(el, $, s, snap)}
682 </Box>
683 </Box>
684 )
685}
686
687const ASK_HINT = 'Ask about your work — e.g. what is still open from last week?'
688
689function Ask(el: El, $: $T, ask: McAsk, snap: McSnapshot, view: McView, surface: string) {
690 const { Box, Text, Button, Input, Markdown, Client } = el
691 const cited = ask.answer ? cites(ask.answer, ask.refs) : []
692 const range = snap.range.key === 'today' ? 'today' : snap.range.key === 'yesterday' ? 'yesterday'
693 : snap.range.key === '7d' ? 'this week' : snap.range.days.length > 1 ? 'in this period' : 'that day'
694 // the two workstreams with most time in view
695 const time = new Map<string, number>()
696 for (const s of inView(snap, view)) if (s.workstream) time.set(s.workstream, (time.get(s.workstream) ?? 0) + s.activeMin)
697 const top = [...time.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([n]) => n)
698 const suggestions = [
699 `What did I work on ${range}, by workstream?`,
700 'What is still unfinished that I should pick back up?',
701 ...top.map(n => `Where does "${n}" stand?`),
702 ]
703 return (
704 <Box flexDirection="column" width="100%">
705 {surface === 'desktop'
706 // the desktop's Input can't be widened; this draws a full-width field itself
707 ? <Client key="askbar" module="./askbar.tsx" width="100%" height={3}
708 props={{ placeholder: ASK_HINT, question: ask.question, busy: ask.busy }} />
709 : <Input key="ask" placeholder={ASK_HINT} submitLabel="Ask" value={ask.busy ? ask.question : undefined}
710 onSubmit={value => { void runAsk($, value) }} />}
711 <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
712 {suggestions.map((q, i) => (
713 <Box key={`chip-${i}`} borderStyle="round" borderColor="#3a3a3a" paddingX={1}
714 hover={{ borderColor: '#a371f7' }}>
715 <Button key={`suggest-${i}`} label={q} plain dimColor onPress={() => { void runAsk($, q) }} />
716 </Box>
717 ))}
718 </Box>
719 {ask.busy && surface !== 'desktop'
720 ? <Text dimColor>{`Thinking about “${truncate(ask.question, 80)}”…`}</Text> : null}
721 {ask.error
722 ? (
723 <Box flexDirection="row" gap={2}>
724 <Text color="#f85149" wrap="wrap">{ask.error}</Text>
725 <Button key="ask-clear-error" label="Clear" plain dimColor onPress={() => { void clearAsk($) }} />
726 </Box>
727 )
728 : null}
729 {!ask.busy && ask.answer
730 ? (
731 <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="#30363d" paddingX={1}>
732 <Box flexDirection="row" justifyContent="space-between" gap={2}>
733 <Text dimColor wrap="wrap">{ask.question}</Text>
734 <Button key="ask-clear" label="Clear ✕" plain dimColor onPress={() => { void clearAsk($) }} />
735 </Box>
736 <Markdown text={ask.answer} />
737 {cited.length
738 ? (
739 <Box flexDirection="column" marginTop={1}>
740 {cited.map((ref, i) => (
741 <Button key={`cite-${i}`} label={`Open: ${truncate(ref.title, 90)}`} plain
742 onPress={e => { void openCited($, ref, e.surface) }} />
743 ))}
744 </Box>
745 )
746 : null}
747 </Box>
748 )
749 : null}
750 </Box>
751 )
752}
753
754/** The first few bullets of the script: what the folded standup shows. */
755function preview(script: string, n: number): { text: string; more: number } {
756 const lines = script.split('\n')
757 const bullets = lines.filter(l => /^\s*[-*] /.test(l))
758 const shown = bullets.slice(0, n).map(l => truncate(l, 140))
759 return { text: shown.join('\n'), more: Math.max(0, bullets.length - shown.length) }
760}
761
762/** The day's standup brief, up top: folded to its first bullets, the rest a click away. */
763function Standup(el: El, $: $T, snap: McSnapshot, now: number, isOpen: boolean) {
764 const { Box, Text, Button, Markdown } = el
765 const st = snap.standup
766 if (!st) {
767 return <Text dimColor>No standup brief yet — the standup-brief task writes one each weekday morning.</Text>
768 }
769 const short = preview(st.script, 3)
770 return (
771 <Box flexDirection="column" borderStyle="round" borderColor="#3fb950" paddingX={1}>
772 <Box flexDirection="row" justifyContent="space-between">
773 <Text bold>{st.heading}</Text>
774 <Text dimColor>{st.at ? `written ${when(st.at, snap, now)}` : ''}</Text>
775 </Box>
776 {isOpen
777 ? (
778 <Box flexDirection="column">
779 {st.preamble ? <Text dimColor italic wrap="wrap">{st.preamble}</Text> : null}
780 <Markdown text={st.script} />
781 {st.table ? <Markdown text={st.table} /> : null}
782 </Box>
783 )
784 : <Markdown text={short.text || truncate(st.script, 300)} />}
785 <Box flexDirection="row" gap={2} marginTop={1}>
786 <Button key="standup-toggle" plain
787 label={isOpen ? '▴ Hide the full standup' : `▾ Show the full standup${short.more ? ` (+${short.more} more)` : ''}`}
788 onPress={() => { void update($, standupOpenAtom, v => !v) }} />
789 <Button key="standup-copy" label="Copy script" plain dimColor onPress={e => {
790 void (async () => {
791 const r = await $.ui.copy({ text: `${st.heading}\n\n${st.script}`, surface: e.surface })
792 $.ui.toast(r.isCopied ? 'Standup script copied' : "Couldn't copy the script")
793 })()
794 }} />
795 {st.app
796 ? <Button key="standup-open" label="Open the run" plain dimColor
797 onPress={() => { void openSessionByApp($, st.app as string) }} />
798 : null}
799 </Box>
800 </Box>
801 )
802}
803
804/** The panel under a clicked conversation on the desktop: what it was, then a way in. */
805function Expanded(el: El, $: $T, s: McSession, snap: McSnapshot, now: number, color: string, self: string,
806 busy: string | null) {
807 const { Box, Text, Button, Markdown } = el
808 const tz = snap.tzOffsetMin
809 return (
810 <Box key={`x-${s.id}`} flexDirection="column" borderStyle="round" borderColor={color}
811 paddingX={1} marginLeft={2} marginBottom={1}>
812 <Text bold wrap="wrap">{s.title}</Text>
813 <Text dimColor wrap="wrap">
814 {`${s.project}${s.branch ? ` · ${s.branch}` : ''} · ${when(s.first, snap, now)}–${hm(s.last, tz)} · ` +
815 `${duration(s.activeMin)} active${s.tickets.length ? ` · ${s.tickets.join(', ')}` : ''}` +
816 (s.archived ? ' · archived' : '')}
817 </Text>
818 {s.gist
819 ? <Markdown text={s.gist} />
820 : <Text dimColor>{s.scheduled ? 'A scheduled run — kept out of the digest.'
821 : 'Not summarized yet — the next digest run will pick it up.'}</Text>}
822 {s.open ? <Text dimColor wrap="wrap">{`Left for later: ${s.open}`}</Text> : null}
823 <Box flexDirection="row" gap={2} marginTop={1}>
824 <Button key={`read-${s.id}`} label="Read conversation" variant="primary"
825 onPress={() => { void openReader($, s.id, false) }} />
826 <Button key={`open-${s.id}`} label={s.app ? 'Open ↗' : 'Copy resume command'}
827 onPress={e => { void openSession($, s.id, e.surface) }} />
828 {SummarizeButton(el, $, s, `summarize-${s.id}`, self, busy)}
829 {PinButton(el, $, s, `pin-${s.id}`)}
830 {ArchiveButton(el, $, s, `archive-${s.id}`, self)}
831 {s.scheduled ? null : MoveTo(el, $, s, snap)}
832 </Box>
833 </Box>
834 )
835}
836
837async function openCited($: $T, ref: McRef, surface?: Parameters<$T['ui']['copy']>[0]['surface']) {
838 if (ref.app) return openSessionByApp($, ref.app)
839 await $.ui.copy({ text: `claude --resume ${ref.id}`, surface })
840 $.ui.toast('No desktop session for this one — copied `claude --resume` to the clipboard')
841}
842
843function Health(el: El, snap: McSnapshot, now: number) {
844 const { Box, Text } = el
845 const run = snap.digest.lastRun
846 const g = runDot(run?.status === 'ok' ? 'ok' : run?.status === 'nothing' ? 'nothing'
847 : run?.status === 'failed' ? 'failed' : run?.status ? 'warn' : undefined)
848 return (
849 <Box flexDirection="column">
850 <Text bold>Digest health</Text>
851 <Text wrap="wrap">
852 <Text color={g.color}>● </Text>
853 {run?.start
854 ? `last run ${when(run.start, snap, now)} (${run.trigger ?? '?'}): ${run.status ?? 'never finished'}` +
855 `${run.indexed != null ? ` · ${run.indexed} indexed` : ''}` +
856 `${run.renamed != null ? ` · ${run.renamed} titles synced` : ''}`
857 : 'no runs recorded yet'}
858 </Text>
859 {snap.digest.issuesSinceOk
860 ? <Text color={statusColor('unresolved')}>{`${snap.digest.issuesSinceOk} issue(s) since the last good run`}</Text>
861 : <Text dimColor>No issues since the last good run.</Text>}
862 {snap.digest.issues.slice(0, 3).map((i, n) => (
863 <Text dimColor wrap="truncate-end">
864 {` ${i.ts ? when(i.ts, snap, now) : ''} ${i.kind}: ${truncate(i.detail ?? '', 140)}`}
865 </Text>
866 ))}
867 </Box>
868 )
869}
870
871// ------------------------------------------------------------------ register
872export const register: Register = on => {
873 let ticks = 0
874 let selfId = ''
875
876 on('session.start', async ($, e, next) => {
877 await $.command.register({
878 name: 'mission',
879 description: 'Mission Control: routines, a clickable timeline of your conversations, and an Ask box',
880 argumentHint: '[today|yesterday|week|YYYY-MM-DD..YYYY-MM-DD] | ask <question> | home [off] | band on|off',
881 })
882 await breadcrumb($, { sessionStartAt: await $.clock.now(), surface: e.surface })
883 selfId = await $.session.id().catch(() => '')
884 const stored = await $.store.get('view')
885 if (stored && typeof stored === 'object') {
886 const saved = stored as Partial<McView> & { version?: number }
887 // a new default grouping (workstreams) replaces an older saved one, once
888 // and the digest's open/solved guess stops being the default colour, once
889 const fresh = saved.version === VIEW_VERSION
890 await update($, viewAtom, v => ({ ...v, ...saved,
891 rows: fresh && saved.rows ? saved.rows : 'workstream',
892 color: fresh && saved.color ? saved.color : 'project' }))
893 }
894 const band = await $.store.get('band')
895 if (band === false) await update($, bandAtom, () => false)
896 // the home session opens the pane by itself: click it in the sidebar and it's there
897 const home = await readHome($)
898 if (home && home.cli === (await $.session.id())) {
899 await writeHome($) // refreshes the desktop id the band links to
900 void openPane($, true)
901 } else {
902 void refresh($) // fills the status line too
903 }
904 $.clock.every(READ_POLL_MS, () => {
905 void (async () => {
906 if (!(await read($, readerAtom))) return
907 if ((await $.ui.panes()).some(p => p.id === PANE)) await loadReader($, false)
908 })()
909 })
910 $.clock.every(60_000, () => {
911 ticks += 1
912 void (async () => {
913 const up = (await $.ui.panes()).some(p => p.id === PANE)
914 if (up || ticks % 10 === 0) await refresh($)
915 })()
916 })
917 return next(e)
918 })
919
920 on('command.run', { command: 'mission' }, async ($, e) => {
921 const args = e.args.trim()
922 const word = args.split(/\s+/)[0]?.toLowerCase() ?? ''
923 const second = args.split(/\s+/)[1]?.toLowerCase()
924 if (word === 'home') {
925 if (second === 'off') {
926 await clearHome($)
927 return { text: 'This is no longer the Mission Control home session.' }
928 }
929 await writeHome($)
930 await openPane($, true)
931 return { text: 'This session is now Mission Control\'s home: it opens the pane by itself, as wide as ' +
932 'the app allows, and the Mission Control band in every other chat brings you here. Pin it in the ' +
933 'sidebar (right-click → Pin), and give it a window of its own to keep it full screen.' }
934 }
935 if (word === 'band') {
936 const show = second !== 'off'
937 await update($, bandAtom, () => show)
938 await $.store.set('band', show)
939 return { text: show ? 'The Mission Control band shows above the prompt.' : 'The band is hidden. `/mission band on` brings it back.' }
940 }
941 const range: McRangeKey | undefined = word === 'today' ? 'today' : word === 'yesterday'
942 ? 'yesterday' : word === 'week' || word === '7d' ? '7d' : undefined
943 const custom = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/.exec(word ?? '')
944 if (range) await setView($, { range })
945 else if (custom) await setView($, { range: 'custom', from: custom[1], to: custom[2] })
946 const placed = range || custom ? (await $.ui.open({ id: PANE, title: TITLE })).isPlaced : await openPane($)
947 const opened = { isPlaced: placed }
948 if (word === 'ask' && args.length > 3) void runAsk($, args.slice(3))
949 return { text: opened.isPlaced ? 'Mission Control is open.' : 'Mission Control opens once there is room for the pane.' }
950 })
951
952 on('ui.message', async ($, e, next) => {
953 if (e.requestId === PANE && e.element === 'askbar') {
954 const data = e.data as { type?: string; question?: unknown; text?: unknown }
955 if (data.type === 'copy' && typeof data.text === 'string') {
956 await $.ui.copy({ text: data.text, surface: e.surface })
957 } else if (typeof data.question === 'string') {
958 await runAsk($, data.question)
959 }
960 return {}
961 }
962 if (e.requestId !== PANE || e.element !== 'timeline') return next(e)
963 const data = e.data as { type?: string; id?: string | null }
964 if (data.type === 'select') await update($, selectedAtom, () => data.id ?? null)
965 if (data.type === 'open' && data.id) await openSession($, data.id, e.surface)
966 if (data.type === 'toggle' && typeof (data as { group?: unknown }).group === 'string') {
967 await toggleGroup($, (data as { group: string }).group)
968 }
969 return {}
970 })
971
972 // the digest turn Summarize now started has ended: show what it summarized
973 on('turn.complete', async ($, e, next) => {
974 const result = await next(e)
975 // the main thread's turn, not one of the digest's own summarizer agents
976 if (!e.agentId && (await read($, resummarizingAtom))) {
977 await update($, resummarizingAtom, () => null)
978 await refresh($)
979 }
980 if (!e.agentId && (await read($, actingAtom))) {
981 await update($, actingAtom, () => null)
982 await refresh($)
983 }
984 if (!e.agentId && (await read($, summarizingAtom))) {
985 await update($, summarizingAtom, () => null)
986 await refresh($)
987 }
988 return result
989 })
990
991 // a one-line band above the prompt in every session: the way in without typing
992 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
993 if (e.props.hasSurvey || !(await read($, bandAtom))) return next(e)
994 const snap = await read($, snapAtom)
995 const { Box, Text, Button } = $.ui.resolve(e)
996 // with a home session elsewhere the band goes there (its window, where it has one);
997 // "here" still opens the pane in this chat
998 const home = await readHome($)
999 const homeApp = home?.app ?? null
1000 const elsewhere = homeApp !== null && home?.cli !== selfId
1001 // this chat, summarized now or again (the digest summarizes the chat it runs in only
1002 // when asked for it by id)
1003 const here = snap?.sessions.find(s => s.id === selfId)
1004 const busy = await read($, resummarizingAtom)
1005 const thisChat = { id: selfId, title: here?.title ?? 'this chat', digested: here?.digested ?? false }
1006 return (
1007 <Box flexDirection="row" gap={2}>
1008 {elsewhere
1009 ? <Button key="band-open" label="◉ Mission Control ↗" plain onPress={() => { void goHome($) }} />
1010 : <Button key="band-open" label="◉ Mission Control" plain onPress={() => { void openPane($) }} />}
1011 {elsewhere ? <Button key="band-here" label="open here" plain dimColor onPress={() => { void openPane($) }} /> : null}
1012 {selfId && !here?.scheduled
1013 ? <Button key="band-summarize" plain dimColor
1014 label={busy === selfId ? 'Summarizing this chat…' : thisChat.digested ? 'Re-summarize this chat' : 'Summarize this chat'}
1015 onPress={() => { void summarizeOne($, thisChat) }} />
1016 : null}
1017 </Box>
1018 )
1019 })
1020
1021 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1022 const snap = await read($, snapAtom)
1023 const view = await read($, viewAtom)
1024 const selected = await read($, selectedAtom)
1025 const ask = await read($, askAtom)
1026 const status = await read($, statusAtom)
1027 const standupOpen = await read($, standupOpenAtom)
1028 const summarizing = await read($, summarizingAtom)
1029 const collapsed = await read($, collapsedAtom)
1030 const reader = await read($, readerAtom)
1031 const busyOne = await read($, resummarizingAtom)
1032 const now = await $.clock.now()
1033 // wide enough to read a conversation beside the timeline; else it takes the pane
1034 const wide = e.surface === 'desktop' && e.props.bodyColumns >= WIDE
1035
1036 if (e.surface === 'mobile' || e.surface === 'vscode') {
1037 const { Box, Text } = $.ui.resolve(e)
1038 return (
1039 <Box flexDirection="column">
1040 <Text bold>{TITLE}</Text>
1041 {(snap?.sessions ?? []).filter(s => !s.scheduled).slice(-20).map(s => (
1042 <Text wrap="truncate-end">{`${hm(s.first, snap?.tzOffsetMin ?? 0)} ${s.project} · ${s.title}`}</Text>
1043 ))}
1044 </Box>
1045 )
1046 }
1047
1048 const el = $.ui.resolve(e) as unknown as El
1049 const { Box, Text, Button, Select, Client } = el
1050
1051 if (!snap) {
1052 return (
1053 <Box flexDirection="column">
1054 <Text bold>{TITLE}</Text>
1055 {status.error
1056 ? <Text color="#f85149" wrap="wrap">{`Couldn't collect the data: ${status.error}`}</Text>
1057 : <Text dimColor>Collecting your conversations…</Text>}
1058 <Button key="refresh" label="Retry" onPress={() => { void refresh($) }} />
1059 </Box>
1060 )
1061 }
1062
1063 const tz = snap.tzOffsetMin
1064 const pending = unsummarized(snap, selfId)
1065 const t = timeline(snap, view, now)
1066 const shown = inView(snap, view)
1067 const filtered = Boolean(view.ws || view.kind)
1068 // the filters' choices: what this range holds, most time first, and the current pick
1069 const tally = (key: (s: McSession) => string | null) => {
1070 const m = new Map<string, number>()
1071 for (const s of snap.sessions) {
1072 const k = s.scheduled ? null : key(s)
1073 if (k) m.set(k, (m.get(k) ?? 0) + s.activeMin)
1074 }
1075 return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k)
1076 }
1077 const wsChoices = tally(s => s.workstream)
1078 if (view.ws && !wsChoices.includes(view.ws)) wsChoices.push(view.ws)
1079 const kindChoices = tally(s => s.kind)
1080 if (view.kind && !kindChoices.includes(view.kind)) kindChoices.push(view.kind)
1081 const sel = selected ? snap.sessions.find(s => s.id === selected) ?? null : null
1082 const multiDay = snap.range.days.length > 1
1083 const rows = rowsOptions(multiDay)
1084 const pickDays = Array.from({ length: CUSTOM_DAYS }, (_, i) => dayAt(now, tz, i))
1085 // older days come from `/mission YYYY-MM-DD..YYYY-MM-DD`: keep those picked
1086 const pickWith = (v: string | undefined) => v && !pickDays.some(d => d.value === v)
1087 ? [...pickDays, { value: v, label: v }] : pickDays
1088 // axis + rows + a blank line before each heading but the first + blank + footer
1089 // the terminal timeline's rows: axis, headings (a blank line before all but the
1090 // first), the conversations of unfolded groups, then a blank line and the footer
1091 let foldedNow = false
1092 const visible = t.rows.filter(r => {
1093 if (r.kind === 'header') {
1094 foldedNow = collapsed.includes(r.label)
1095 return true
1096 }
1097 return !foldedNow
1098 }).length
1099 const height = 1 + visible + t.rows.filter((r, i) => r.kind === 'header' && i > 0).length + 2
1100 const groups = t.rows.filter(r => r.kind === 'header').map(r => r.label)
1101 const allFolded = groups.length > 0 && groups.every(g => collapsed.includes(g))
1102 const tl = { collapsed, rows: t.rows, hourFrom: t.hourFrom, hourTo: t.hourTo, ticks: t.ticks, now: t.now,
1103 nowBase: t.nowBase, selected, order: t.order }
1104 const readSession = reader ? snap.sessions.find(s => s.id === reader.id) ?? null : null
1105
1106 if (reader && !wide) {
1107 // a narrow pane: the conversation takes it over, the way back on top
1108 return (
1109 <Box flexDirection="column" gap={1}>
1110 <Box flexDirection="row" justifyContent="space-between">
1111 <Button key="reader-back" label={`← ${TITLE} · ${snap.range.label}`} plain
1112 onPress={() => { void closeReader($) }} />
1113 <Text dimColor>{reader.loading ? 'reading…' : `updated ${hm(now, tz)}`}</Text>
1114 </Box>
1115 {Reader(el, $, reader, readSession, snap, now, selfId, busyOne)}
1116 </Box>
1117 )
1118 }
1119
1120 return (
1121 <Box flexDirection="column" gap={1}>
1122 <Box flexDirection="column">
1123 <Box flexDirection="row" justifyContent="space-between">
1124 <Text bold>{`${TITLE} · ${snap.range.label}`}</Text>
1125 <Box flexDirection="row" gap={1}>
1126 <Text dimColor>{status.loading ? 'refreshing…' : `updated ${hm(snap.generatedAt, tz)}`}</Text>
1127 <Button key="refresh" label="Refresh" hotkey="r" plain onPress={() => { void refresh($) }} />
1128 </Box>
1129 </Box>
1130 <Box flexDirection="row" gap={2} alignItems="center" flexWrap="wrap">
1131 <Text dimColor wrap="wrap">
1132 {`${snap.totals.conversations} conversations · ${duration(snap.totals.activeMin)} active · ` +
1133 `${snap.totals.projects} project${snap.totals.projects === 1 ? '' : 's'}`}
1134 </Text>
1135 {pending.length || summarizing
1136 ? <Button key="summarize" plain variant="primary"
1137 label={summarizing ? 'Summarizing… (follow it in the conversation)'
1138 : `Summarize ${pending.length} now`}
1139 onPress={() => { void summarizeNow($) }} />
1140 : <Text dimColor>· everything is summarized</Text>}
1141 </Box>
1142 {status.error ? <Text color="#f85149" wrap="wrap">{`Last refresh failed: ${status.error}`}</Text> : null}
1143 </Box>
1144
1145 {Standup(el, $, snap, now, standupOpen)}
1146
1147 {Ask(el, $, ask, snap, view, e.surface)}
1148
1149 <Box flexDirection="row" gap={2} flexWrap="wrap">
1150 <Select key="range" value={view.range}
1151 options={[{ value: 'today', label: 'Today' }, { value: 'yesterday', label: 'Yesterday' },
1152 { value: '7d', label: 'Last 7 days' }, { value: 'custom', label: 'Custom range…' }]}
1153 onSelect={v => {
1154 // a first custom range starts as the last two weeks
1155 void setView($, v === 'custom' && !view.from
1156 ? { range: 'custom', from: pickDays[13]!.value, to: pickDays[0]!.value }
1157 : { range: v as McRangeKey })
1158 }} />
1159 {view.range === 'custom'
1160 ? (
1161 <Box key="custom-range" flexDirection="row" gap={1} alignItems="center">
1162 <Text dimColor>From</Text>
1163 <Select key="from" value={view.from ?? pickDays[13]!.value} options={pickWith(view.from)}
1164 onSelect={v => { void setView($, { from: v }) }} />
1165 <Text dimColor>to</Text>
1166 <Select key="to" value={view.to ?? pickDays[0]!.value} options={pickWith(view.to)}
1167 onSelect={v => { void setView($, { to: v }) }} />
1168 </Box>
1169 )
1170 : null}
1171 <Select key="rows" value={rows.some(r => r.value === view.rows) ? view.rows : 'project'}
1172 options={rows} onSelect={v => { void setView($, { rows: v as McRowsMode }) }} />
1173 {t.rows.some(r => r.kind === 'header')
1174 ? <Button key="fold-all" plain dimColor label={allFolded ? '▾ Unfold all' : '▸ Fold all'}
1175 onPress={() => { void update($, collapsedAtom, () => (allFolded ? [] : groups)) }} />
1176 : null}
1177 <Select key="filter-ws" value={view.ws ?? '*'}
1178 options={[{ value: '*', label: 'All workstreams' }, ...wsChoices.slice(0, 60).map(n => ({ value: n, label: n }))]}
1179 onSelect={v => { void setView($, { ws: v === '*' ? undefined : v }) }} />
1180 <Select key="filter-kind" value={view.kind ?? '*'}
1181 options={[{ value: '*', label: 'All kinds of work' }, ...kindChoices.map(k => ({ value: k, label: k }))]}
1182 onSelect={v => { void setView($, { kind: v === '*' ? undefined : v as McKind }) }} />
1183 {filtered
1184 ? <Button key="filter-clear" label="Clear filters" plain dimColor
1185 onPress={() => { void setView($, { ws: undefined, kind: undefined }) }} />
1186 : null}
1187 <Select key="color" value={view.color}
1188 options={[{ value: 'project', label: 'Colour: project' }, { value: 'status', label: 'Colour: digest status' }]}
1189 onSelect={v => { void setView($, { color: v as McColorMode }) }} />
1190 </Box>
1191
1192 <Box flexDirection="row" gap={2} alignItems="flex-start">
1193 <Box flexDirection="column" width={wide && reader ? '45%' : '100%'} flexShrink={0}>
1194 <Text bold>Timeline</Text>
1195 {filtered
1196 ? <Text dimColor>{`Showing ${shown.length} of ${snap.totals.conversations} conversations · ` +
1197 `${duration(workedMinutes(shown))} active` +
1198 `${view.ws ? ` · ${view.ws}` : ''}${view.kind ? ` · ${view.kind}` : ''}`}</Text>
1199 : null}
1200 {e.surface === 'desktop'hooks/mission-control/layout.ts 419 lines1// Pure layout for the Mission Control pane: snapshot + view → the timeline's rows,
2// the legend, and the small formatters. No `$`, no clock: times are placed against
3// the local midnights the collector computed and formatted with its timezone offset,
4// so the sandbox's own timezone never matters.
5import type {
6 McBar,
7 McColorMode,
8 McRoutine,
9 McRow,
10 McRowsMode,
11 McSession,
12 McSnapshot,
13 McTick,
14 McTimelineProps,
15 McView,
16} from '../../types'
17
18const HOUR = 3_600_000
19const DAY = 86_400_000
20
21export const STATUS_COLORS: Record<string, string> = {
22 solved: '#3fb950',
23 unresolved: '#d29922',
24 exploratory: '#58a6ff',
25 abandoned: '#8b949e',
26 new: '#bc8cff',
27 routine: '#8b949e',
28 'routine-failed': '#f85149',
29 'routine-warn': '#d29922',
30}
31
32export const STATUS_LABELS: Record<string, string> = {
33 solved: 'solved',
34 unresolved: 'open',
35 exploratory: 'exploratory',
36 abandoned: 'abandoned',
37 new: 'not summarized yet',
38 routine: 'routine run',
39}
40
41const PROJECT_COLORS = ['#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#f778ba', '#39c5cf',
42 '#ff9a4c', '#a5d6ff', '#7ee787', '#e3b341']
43
44/** A run's status as a coloured dot (the routine history) and a word. */
45export type Dot = { glyph: string; color: string; word: string }
46const DOTS: Record<string, Dot> = {
47 ok: { glyph: '●', color: '#3fb950', word: 'ok' },
48 nothing: { glyph: '●', color: '#6e7681', word: 'nothing to do' },
49 warn: { glyph: '●', color: '#d29922', word: 'needs a look' },
50 failed: { glyph: '●', color: '#f85149', word: 'failed' },
51 running: { glyph: '●', color: '#58a6ff', word: 'running' },
52}
53const UNKNOWN_DOT: Dot = { glyph: '○', color: '#6e7681', word: 'no summary' }
54export const runDot = (status: string | null | undefined): Dot =>
55 (status ? DOTS[status] : undefined) ?? UNKNOWN_DOT
56export const statusColor = (status: string): string => STATUS_COLORS[status] ?? '#8b949e'
57export const statusLabel = (status: string): string => STATUS_LABELS[status] ?? status
58
59const NAMES: Record<string, string> = {
60 'convo-digest-nightly': 'Digest',
61 'standup-brief': 'Standup brief',
62 'ai-daily-writeup': 'AI daily write-up',
63}
64
65/** "convo-digest-nightly" → "Digest"; others title-cased, "ai" kept as "AI". */
66export function routineName(r: Pick<McRoutine, 'task' | 'name'>): string {
67 const known = NAMES[r.task]
68 if (known) return known
69 const words = r.task.replace(/-\d{4}-\d\d-\d\d$/, '').split('-').filter(Boolean)
70 const name = words.map((w, i) => (w === 'ai' ? 'AI' : i === 0 ? w[0]!.toUpperCase() + w.slice(1) : w)).join(' ')
71 const date = r.task.match(/(\d{4}-\d\d-\d\d)$/)?.[1]
72 return date ? `${name} (one-off ${date})` : name
73}
74
75// ---------------------------------------------------------------- formatting
76const pad = (n: number) => String(n).padStart(2, '0')
77
78/** Local clock parts of an epoch-ms instant, from the collector's offset. */
79export function local(ms: number, tzOffsetMin: number) {
80 const d = new Date(ms + tzOffsetMin * 60_000)
81 return { hh: d.getUTCHours(), mm: d.getUTCMinutes(), day: d.getUTCDate(),
82 month: d.getUTCMonth(), dow: d.getUTCDay() }
83}
84
85const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
86const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
87
88export function hm(ms: number, tz: number): string {
89 const p = local(ms, tz)
90 return `${pad(p.hh)}:${pad(p.mm)}`
91}
92
93export function dayLabel(ms: number, tz: number): string {
94 const p = local(ms, tz)
95 return `${DOW[p.dow]} ${p.day} ${MON[p.month]}`
96}
97
98/** "today 09:22", "yesterday 18:04", or "Mon 5 Oct 09:22". */
99export function when(ms: number, snap: McSnapshot, nowMs: number): string {
100 const tz = snap.tzOffsetMin
101 const sameDay = (a: number, b: number) => {
102 const x = local(a, tz)
103 const y = local(b, tz)
104 return x.day === y.day && x.month === y.month
105 }
106 if (sameDay(ms, nowMs)) return `today ${hm(ms, tz)}`
107 if (sameDay(ms, nowMs - DAY)) return `yesterday ${hm(ms, tz)}`
108 return `${dayLabel(ms, tz)} ${hm(ms, tz)}`
109}
110
111/**
112 * Time worked across conversations, counting parallel ones once: the union of their
113 * active stretches, optionally clipped to [from, to).
114 */
115export function workedMinutes(sessions: McSession[], from = -Infinity, to = Infinity): number {
116 const merged: [number, number][] = []
117 const segs = sessions.flatMap(s => s.segments)
118 .map(([a, b]) => [Math.max(a, from), Math.min(b, to)] as [number, number])
119 .filter(([a, b]) => b > a)
120 .sort((x, y) => x[0] - y[0])
121 for (const [a, b] of segs) {
122 const last = merged[merged.length - 1]
123 if (last && a <= last[1]) last[1] = Math.max(last[1], b)
124 else merged.push([a, b])
125 }
126 return Math.round(merged.reduce((n, [a, b]) => n + (b - a) / 60_000, 0))
127}
128
129export function duration(min: number): string {
130 if (min < 60) return `${min}m`
131 const h = Math.floor(min / 60)
132 const m = min % 60
133 return m ? `${h}h${pad(m)}` : `${h}h`
134}
135
136export function truncate(text: string, max: number): string {
137 const t = text.replace(/\s+/g, ' ').trim()
138 return t.length <= max ? t : `${t.slice(0, Math.max(1, max - 1))}…`
139}
140
141// ---------------------------------------------------------------- colouring
142export function projectColors(sessions: McSession[]): Record<string, string> {
143 const names = [...new Set(sessions.filter(s => !s.scheduled).map(s => s.project))].sort()
144 return Object.fromEntries(names.map((n, i) => [n, PROJECT_COLORS[i % PROJECT_COLORS.length] ?? '#58a6ff']))
145}
146
147/** The colour half-way to grey: what a bar looks like while another one is selected. */
148export function mute(hex: string): string {
149 const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex)
150 if (!m) return hex
151 const grey = 0x6e
152 const mix = (c: string) => Math.round((parseInt(c, 16) + grey * 2) / 3).toString(16).padStart(2, '0')
153 return `#${mix(m[1]!)}${mix(m[2]!)}${mix(m[3]!)}`
154}
155
156function runStatus(s: McSession, snap: McSnapshot): string {
157 for (const r of snap.routines) {
158 const run = r.runs.find(x => x.cli === s.id)
159 if (run?.status === 'failed') return 'routine-failed'
160 if (run?.status === 'warn') return 'routine-warn'
161 }
162 return 'routine'
163}
164
165export function colorOf(s: McSession, snap: McSnapshot, mode: McColorMode,
166 projects: Record<string, string>): string {
167 if (s.scheduled) return statusColor(runStatus(s, snap))
168 if (mode === 'project') return projects[s.project] ?? '#8b949e'
169 return statusColor(s.status)
170}
171
172export function legend(snap: McSnapshot, view: McView): { label: string; color: string }[] {
173 if (view.color === 'project') {
174 return Object.entries(projectColors(snap.sessions)).map(([label, color]) => ({ label, color }))
175 }
176 const present = new Set(snap.sessions.filter(s => !s.scheduled).map(s => s.status))
177 const out = ['solved', 'unresolved', 'exploratory', 'abandoned', 'new']
178 .filter(k => present.has(k))
179 .map(k => ({ label: statusLabel(k), color: statusColor(k) }))
180 return out
181}
182
183// ---------------------------------------------------------------- timeline
184function tip(s: McSession, tz: number): string {
185 const status = s.scheduled ? 'routine run' : statusLabel(s.status)
186 return `${hm(s.first, tz)}–${hm(s.last, tz)} · ${duration(s.activeMin)} active · ${s.project} · ` +
187 `${status} · ${s.title}`
188}
189
190/** Where a conversation without a workstream or kind yet goes, last. */
191export const UNFILED = 'Not summarized yet'
192
193function groupKey(s: McSession, rows: McRowsMode): string {
194 if (rows === 'status') return statusLabel(s.status)
195 if (rows === 'category') return s.category === 'unknown' ? 'not profiled' : s.category
196 if (rows === 'workstream') return s.workstream ?? UNFILED
197 if (rows === 'kind') return s.kind ?? UNFILED
198 return s.project
199}
200
201/** The conversations a view shows: routine runs left out, then its workstream and kind
202 * filters applied. */
203export function visible(snap: McSnapshot, view: McView): McSession[] {
204 return snap.sessions.filter(s => !s.scheduled && (!view.ws || s.workstream === view.ws) &&
205 (!view.kind || s.kind === view.kind))
206}
207
208const STATUS_ORDER = ['open', 'not summarized yet', 'exploratory', 'solved', 'abandoned']
209
210/** Snapshot + view → everything the timeline Client draws, plus the legend. */
211export function timeline(snap: McSnapshot, view: McView, nowMs: number): McTimelineProps & {
212 legend: { label: string; color: string }[]
213} {
214 const tz = snap.tzOffsetMin
215 const projects = projectColors(snap.sessions)
216 const bar = (s: McSession): McBar => {
217 const color = colorOf(s, snap, view.color, projects)
218 return { id: s.id, color, muted: mute(color), segs: s.segments, tip: tip(s, tz),
219 ...(s.scheduled ? { routine: true as const } : {}) }
220 }
221 const convos = visible(snap, view)
222 const days = snap.range.days
223 const multiDay = days.length > 1
224 const byDay = multiDay && view.rows === 'day'
225 const rows: McRow[] = []
226 const minutes = (g: McSession[]) => workedMinutes(g)
227
228 if (byDay) {
229 // one row per day, the hours of the day across: when in the day you work
230 for (const d of days) {
231 const inDay = (s: McSession) => s.segments.some(([a, b]) => a < d.to && b > d.from)
232 const clip = (s: McSession): McSession =>
233 ({ ...s, segments: s.segments.filter(([a, b]) => a < d.to && b > d.from) })
234 const work = convos.filter(inDay).map(clip)
235 const mins = workedMinutes(work, d.from, d.to)
236 rows.push({ kind: 'track', label: d.label, sub: mins ? duration(mins) : '', base: d.from, id: null,
237 bars: work.map(bar) })
238 }
239 } else {
240 const base = snap.range.from
241 const keyOf = (s: McSession) => groupKey(s, view.rows)
242 const groups = new Map<string, McSession[]>()
243 for (const s of convos) groups.set(keyOf(s), [...(groups.get(keyOf(s)) ?? []), s])
244 // most time first; the not-yet-summarized last; status keeps its own order
245 const ordered = [...groups.entries()].sort((a, b) =>
246 view.rows === 'status'
247 ? STATUS_ORDER.indexOf(a[0]) - STATUS_ORDER.indexOf(b[0])
248 : a[0] === UNFILED ? 1 : b[0] === UNFILED ? -1 : minutes(b[1]) - minutes(a[1]))
249 const described = new Map(snap.workstreams.map(w => [w.name, w.description]))
250 for (const [k, g] of ordered) {
251 const note = view.rows === 'workstream' ? described.get(k) : null
252 rows.push({ kind: 'header', label: k,
253 sub: `${g.length} conversation${g.length > 1 ? 's' : ''} · ${duration(minutes(g))}`,
254 // Client props are plain data: leave `note` out rather than undefined
255 ...(note ? { note } : {}) })
256 // the conversation you spent most time on first, in every grouping; ties by start
257 for (const s of [...g].sort((a, b) => b.activeMin - a.activeMin || a.first - b.first)) {
258 rows.push({ kind: 'track', label: s.title, sub: duration(s.activeMin), base, id: s.id, bars: [bar(s)] })
259 }
260 }
261 }
262
263 // x axis: the days across for a multi-day range, else the hours worth showing
264 let hourFrom = 0
265 let hourTo = 24
266 const ticks: McTick[] = []
267 const tracks = rows.filter((r): r is Extract<McRow, { kind: 'track' }> => r.kind === 'track')
268 if (multiDay && !byDay) {
269 hourTo = days.length * 24
270 days.forEach((d, i) => ticks.push({ at: i * 24, label: d.label.split(' ').slice(0, 2).join(' ') }))
271 } else {
272 const hours: number[] = []
273 const work = tracks.some(r => r.bars.some(b => !b.routine))
274 for (const row of tracks) {
275 for (const b of row.bars) {
276 if (work && b.routine) continue
277 for (const [x, y] of b.segs) hours.push((x - row.base) / HOUR, (y - row.base) / HOUR)
278 }
279 }
280 const isToday = !multiDay && nowMs >= snap.range.from && nowMs < snap.range.to
281 if (isToday) hours.push((nowMs - snap.range.from) / HOUR)
282 if (hours.length) {
283 hourFrom = Math.max(0, Math.floor(Math.min(...hours)) - 1)
284 hourTo = Math.min(24, Math.ceil(Math.max(...hours)) + 1)
285 } else {
286 hourFrom = 8
287 hourTo = 18
288 }
289 if (hourTo - hourFrom < 8) hourTo = Math.min(24, hourFrom + 8)
290 if (hourTo - hourFrom < 8) hourFrom = Math.max(0, hourTo - 8)
291 const step = hourTo - hourFrom > 14 ? 2 : 1
292 for (let h = Math.ceil(hourFrom / step) * step; h < hourTo; h += step) ticks.push({ at: h, label: pad(h) })
293 }
294
295 const isToday = nowMs >= snap.range.from && nowMs < snap.range.to
296 const nowBase = !isToday ? null : byDay
297 ? (days.find(d => nowMs >= d.from && nowMs < d.to)?.from ?? null)
298 : snap.range.from
299 // keyboard order: the conversation rows top to bottom, as drawn
300 const order = tracks.filter(r => r.id).map(r => r.id as string)
301 return { rows, hourFrom, hourTo, ticks, now: isToday ? nowMs : null, nowBase, selected: null,
302 order, collapsed: [], legend: legend(snap, view) }
303}
304
305// ---------------------------------------------------------------- timeline as a picture
306// Text colours for a drawing that can't see the app's theme: tuned for the dark theme,
307// still legible on a light one.
308const INK = '#a5adb7'
309const INK_DIM = '#7d8590'
310const GRID = '#8b949e'
311
312const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
313/** Roughly how many characters of `size`-px text fit in `px` (proportional font). */
314const fit = (text: string, px: number, size: number) =>
315 truncate(text, Math.max(4, Math.floor(px / (size * 0.56))))
316
317// ---------------------------------------------------------------- timeline as strips
318/**
319 * The timeline as one small vector strip per row, for the desktop: the pane lays the
320 * rows out (a clickable name, then its strip), so names and bars always line up, and
321 * each strip draws just that row's bars — or, over a multi-day range, one shaded
322 * cell per day with the time worked in it. `axis` is the strip of hour/day labels.
323 * Every strip is `width` px wide with the same horizontal scale.
324 */
325export function timelineStrips(t: McTimelineProps, width: number): { axis: string; rows: (string | null)[] } {
326 const W = Math.max(200, Math.round(width))
327 const span = Math.max(1, t.hourTo - t.hourFrom)
328 const multiDay = span > 24
329 const days = multiDay ? Math.round(span / 24) : 0
330 const dayW = multiDay ? W / days : 0
331 const xHour = (h: number) => ((h - t.hourFrom) / span) * W
332 const xAt = (ms: number, base: number) => xHour((ms - base) / HOUR)
333 const svg = (h: number, body: string) =>
334 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${h}" viewBox="0 0 ${W} ${h}" ` +
335 `font-family="-apple-system, BlinkMacSystemFont, Helvetica, sans-serif">${body}</svg>`
336 const grid = (h: number) => t.ticks.map(tick => {
337 const x = xHour(tick.at).toFixed(1)
338 return `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="${GRID}" stroke-opacity="0.18"/>`
339 }).join('') + (multiDay ? t.ticks.map((tick, i) => i % 2 ? '' :
340 `<rect x="${xHour(tick.at).toFixed(1)}" y="0" width="${dayW.toFixed(1)}" height="${h}" fill="${GRID}" fill-opacity="0.06"/>`).join('') : '')
341
342 // a long range has narrow days: label every few so the labels don't run together
343 const labelEvery = multiDay ? Math.max(1, Math.ceil(48 / dayW)) : 1
344 const axis = svg(18, t.ticks.map((tick, i) => {
345 if (i % labelEvery) return ''
346 const x = multiDay ? xHour(tick.at) + dayW / 2 : xHour(tick.at)
347 return `<text x="${x.toFixed(1)}" y="13" fill="${INK_DIM}" font-size="11" text-anchor="middle">${esc(tick.label)}</text>`
348 }).join('') + (t.now !== null && t.nowBase !== null && !multiDay
349 ? `<text x="${(xAt(t.now, t.nowBase) + 3).toFixed(1)}" y="13" fill="#f85149" font-size="10">now</text>` : ''))
350
351 const H = 20
352 type Track = Extract<McRow, { kind: 'track' }>
353 const strip = (row: Track) => {
354 const parts: string[] = [grid(H)]
355 if (multiDay) {
356 for (let d = 0; d < days; d++) {
357 const from = row.base + (t.hourFrom + d * 24) * HOUR
358 const to = from + 24 * HOUR
359 const mins = Math.round(row.bars.reduce((n, bar) => n + bar.segs.reduce((m, [a, b]) =>
360 m + Math.max(0, Math.min(b, to) - Math.max(a, from)) / 60_000, 0), 0))
361 if (!mins) continue
362 const bar = row.bars.find(b => b.segs.some(([a, z]) => a < to && z > from)) ?? row.bars[0]!
363 const strength = 0.5 + 0.5 * Math.min(1, mins / 120)
364 const faded = t.selected !== null && !row.bars.some(b => b.id === t.selected)
365 const cx = d * dayW
366 parts.push(`<rect x="${(cx + 3).toFixed(1)}" y="2" width="${(dayW - 6).toFixed(1)}" height="16" rx="4" ` +
367 `fill="${bar.color}" fill-opacity="${(faded ? strength * 0.35 : strength).toFixed(2)}"/>`)
368 if (dayW >= 44) {
369 parts.push(`<text x="${(cx + dayW / 2).toFixed(1)}" y="14" font-size="10" text-anchor="middle" ` +
370 `fill="${strength > 0.8 && !faded ? '#0d1117' : '#f0f6fc'}">${duration(mins)}</text>`)
371 }
372 }
373 } else {
374 for (const bar of row.bars) {
375 const opacity = t.selected === null ? 0.9 : bar.id === t.selected ? 1 : 0.3
376 const [by, bh] = bar.routine && !row.id ? [14, 3] : [4, 12]
377 for (const [a, b] of bar.segs) {
378 const xa = Math.max(0, xAt(a, row.base))
379 const xb = Math.min(W, xAt(b, row.base))
380 if (xb <= 0 || xa >= W) continue
381 parts.push(`<rect x="${xa.toFixed(1)}" y="${by}" width="${Math.max(3, xb - xa).toFixed(1)}" height="${bh}" ` +
382 `rx="${bh > 4 ? 3 : 1}" fill="${bar.color}" fill-opacity="${opacity}"/>`)
383 }
384 }
385 if (t.now !== null && row.base === t.nowBase) {
386 const x = xAt(t.now, row.base)
387 if (x >= 0 && x <= W) parts.push(`<line x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${H}" stroke="#f85149" stroke-width="1.5"/>`)
388 }
389 }
390 return svg(H, parts.join(''))
391 }
392 // a heading's strip is its whole group's bars in one row: what a folded group shows
393 const rows = t.rows.map((row, i) => {
394 if (row.kind === 'track') return strip(row)
395 const group: Track[] = []
396 for (const r of t.rows.slice(i + 1)) {
397 if (r.kind !== 'track') break
398 group.push(r)
399 }
400 if (!group.length) return null
401 return strip({ kind: 'track', label: row.label, sub: '', base: group[0]!.base, id: null,
402 bars: group.flatMap(r => r.bars) })
403 })
404 return { axis, rows }
405}
406
407/** The rows choices that make sense for a range: "day" only for multi-day ranges. */
408export function rowsOptions(multiDay: boolean): { value: McRowsMode; label: string }[] {
409 const base: { value: McRowsMode; label: string }[] = [
410 { value: 'workstream', label: 'Group: workstream' },
411 { value: 'project', label: 'Group: project' },
412 { value: 'status', label: 'Group: status' },
413 { value: 'kind', label: 'Group: kind of work' },
414 { value: 'category', label: 'Group: work / personal' },
415 ]
416 return multiDay ? [{ value: 'day', label: 'One row per day' }, ...base] : base
417}
418
419hooks/mission-control/askbar.tsx 139 lines1// The Ask bar on the desktop: a Client surface module, because the desktop's own Input
2// draws at a fixed narrow width with no way to widen it. This one fills the row: a
3// filled field with a caret, the placeholder while empty, and an Ask pill at the end.
4//
5// A click gives it the keyboard (Escape hands it back). Cmd/Ctrl+A selects the text, so
6// typing replaces it and Backspace clears it; Cmd/Ctrl+C and X copy and cut it through
7// the hooks module, which has the clipboard. The text lives in local state;
8// Enter, or a click on the pill, posts the question to the hooks module, which asks.
9// When the hooks module asks something else (a suggestion was clicked) the field takes
10// that question over, so what's in the field is always what was last asked.
11import type { ClientKeyEvent, ClientModule, ClientPointerEvent, RenderElement } from 'claude-code'
12
13import type { McAskBarProps } from '../../types'
14
15type Local = { text: string; cursor: number; focused: boolean; seen: string; pillHover: boolean; all: boolean }
16
17const FILL = '#262626'
18const FILL_FOCUS = '#2d2d2d'
19const ACCENT = '#a371f7'
20const SELECTED = '#4c3a75'
21const PILL = '#3a3a3a'
22const PILL_HOVER = '#4a4a4a'
23const PILL_W = 9 // " Ask ↵ " and a cell of air
24const ICON_W = 3
25
26/** Key names that are keys, not text; anything else that arrives is typed (or pasted). */
27const SPECIAL = new Set(['up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete',
28 'pageup', 'pagedown', 'home', 'end', 'escape', 'insert'])
29
30const AskBar: ClientModule<McAskBarProps, Local> = (props, surface) => {
31 const { Box, Text } = surface.elements
32 const columns = surface.columns
33 if (!columns) return <Text dimColor>…</Text>
34
35 const prev = surface.state
36 // a question asked from outside the field (a suggestion) replaces what's typed
37 const st: Local = !prev
38 ? { text: props.question, cursor: props.question.length, focused: false, seen: props.question, pillHover: false,
39 all: false }
40 : prev.seen !== props.question
41 ? { ...prev, text: props.question, cursor: props.question.length, seen: props.question, all: false }
42 : prev
43
44 const set = (patch: Partial<Local>) => surface.setState({ ...st, ...patch })
45 const submit = () => {
46 const q = st.text.trim()
47 if (q && !props.busy) surface.post({ type: 'ask', question: q })
48 }
49 const pillFrom = columns - 1 - PILL_W
50
51 surface.onPointer((ev: ClientPointerEvent) => {
52 const onPill = ev.x >= pillFrom && ev.x < columns - 1
53 if (ev.type === 'down' && ev.button === 'left') {
54 if (onPill) submit()
55 if (!st.focused || st.all) set({ focused: true, all: false })
56 } else if (ev.type === 'move' || ev.type === 'enter') {
57 if (onPill !== st.pillHover) set({ pillHover: onPill })
58 } else if (ev.type === 'leave' && st.pillHover) {
59 set({ pillHover: false })
60 }
61 })
62
63 surface.onKey((ev: ClientKeyEvent) => {
64 const { text, cursor } = st
65 const k = ev.key
66 const mod = ev.meta || ev.ctrl
67 if (k === 'return' || k === 'enter') return submit()
68 if (mod && k === 'a') return set({ all: text.length > 0, cursor: text.length, focused: true })
69 if (mod && (k === 'c' || k === 'x')) {
70 if (!st.all || !text) return
71 surface.post({ type: 'copy', text })
72 if (k === 'x') set({ text: '', cursor: 0, all: false })
73 return
74 }
75 if (st.all) {
76 // the whole text is selected: an edit replaces it, a move drops the selection
77 if (k === 'backspace' || k === 'delete') return set({ text: '', cursor: 0, all: false })
78 if (k === 'left' || k === 'home' || k === 'up') return set({ cursor: 0, all: false })
79 if (k === 'right' || k === 'end' || k === 'down') return set({ cursor: text.length, all: false })
80 if (SPECIAL.has(k) || /^f\d{1,2}$/.test(k) || mod) return set({ all: false })
81 const typed = (k === 'space' ? ' ' : k).replace(/[\r\n\t]+/g, ' ')
82 return set({ text: typed, cursor: typed.length, all: false })
83 }
84 if (k === 'backspace') {
85 if (ev.meta || (ev.ctrl && !ev.shift)) return set({ text: text.slice(cursor), cursor: 0 })
86 if (cursor > 0) set({ text: text.slice(0, cursor - 1) + text.slice(cursor), cursor: cursor - 1 })
87 return
88 }
89 if (k === 'delete') {
90 if (cursor < text.length) set({ text: text.slice(0, cursor) + text.slice(cursor + 1) })
91 return
92 }
93 if (k === 'left') return set({ cursor: ev.meta ? 0 : Math.max(0, cursor - 1) })
94 if (k === 'right') return set({ cursor: ev.meta ? text.length : Math.min(text.length, cursor + 1) })
95 if (k === 'home') return set({ cursor: 0 })
96 if (k === 'end' || (ev.ctrl && k === 'e')) return set({ cursor: text.length })
97 if (ev.ctrl && k === 'u') return set({ text: text.slice(cursor), cursor: 0 })
98 if (SPECIAL.has(k) || /^f\d{1,2}$/.test(k) || ev.ctrl || ev.meta) return
99 const typed = (k === 'space' ? ' ' : k).replace(/[\r\n\t]+/g, ' ')
100 set({ text: text.slice(0, cursor) + typed + text.slice(cursor), cursor: cursor + typed.length, focused: true })
101 })
102
103 // what fits between the icon and the pill; scrolled so the caret stays in view
104 const room = Math.max(4, columns - 2 - ICON_W - PILL_W)
105 const start = Math.max(0, st.cursor - (room - 2))
106 const shown = st.text.slice(start, start + room - 1)
107 const at = st.cursor - start
108
109 let field: RenderElement[]
110 if (!st.text) {
111 field = [
112 ...(st.focused ? [<Text color={ACCENT}>▏</Text>] : []),
113 <Text dimColor wrap="truncate-end">{props.placeholder}</Text>,
114 ]
115 } else if (st.all && !props.busy) {
116 field = [<Text backgroundColor={SELECTED}>{shown}</Text>]
117 } else if (st.focused && !props.busy) {
118 field = [<Text>{shown.slice(0, at)}</Text>, <Text color={ACCENT}>▏</Text>, <Text>{shown.slice(at)}</Text>]
119 } else {
120 field = [<Text wrap="truncate-end">{shown}</Text>]
121 }
122
123 return (
124 <Box flexDirection="row" width={columns} height={3} alignItems="center"
125 backgroundColor={st.focused ? FILL_FOCUS : FILL}>
126 <Box width={1} height={3} />
127 <Box width={ICON_W} height={1}><Text color={ACCENT}>✦</Text></Box>
128 <Box width={room} height={1} flexDirection="row" overflow="hidden">{field}</Box>
129 <Box width={PILL_W} height={1} justifyContent="center"
130 backgroundColor={props.busy ? FILL : st.pillHover ? PILL_HOVER : PILL}>
131 {props.busy ? <Text dimColor>thinking…</Text> : <Text bold>Ask ↵</Text>}
132 </Box>
133 <Box width={1} height={3} />
134 </Box>
135 )
136}
137
138export default AskBar
139hooks/mission-control/timeline.tsx 211 lines1// The Mission Control timeline: a Client surface module, so hover and click work on
2// both the desktop and the terminal.
3//
4// Everything is laid out in whole cells as a row of fixed-width boxes — never by
5// padding text with spaces, since the desktop draws text in a proportional font, and
6// never with absolute offsets, which the desktop resolves against the whole region
7// rather than the row. A label column of fixed width, then a track: each run of cells
8// is one box, a solid coloured bar where a session was active, a 1-cell hour guide
9// or "now" mark, or blank space.
10// Clicks and keys go back to the hooks module through `post`; the selection lives in
11// the plugin's state and arrives here as props, only the hover is local.
12import type { ClientModule, ClientPointerEvent, RenderElement } from 'claude-code'
13
14import type { McBar, McRow, McTimelineProps } from '../../types'
15
16type Local = { hover: string | null }
17type Shown = McRow | { kind: 'spacer' }
18
19const HOUR = 3_600_000
20const NOW = '#f85149'
21const SUB_W = 6 // the active-time column at the end of a label
22
23const Timeline: ClientModule<McTimelineProps, Local> = (props, surface) => {
24 const { Box, Text } = surface.elements
25 const columns = surface.columns
26 if (!columns) return <Text dimColor>…</Text>
27
28 // the title matters more than the hours: it gets most of the row
29 const labelW = Math.max(24, Math.min(90, Math.floor(columns * 0.55)))
30 const trackW = Math.max(12, columns - labelW - 1)
31 const span = Math.max(1, props.hourTo - props.hourFrom)
32 const perHour = trackW / span
33 const cellAt = (ms: number, base: number) => ((ms - base) / HOUR - props.hourFrom) * perHour
34 const hover = surface.state?.hover ?? null
35
36 // a blank line before every heading but the first: the rows as drawn, axis at y 0
37 const shown: Shown[] = []
38 let folded = false
39 props.rows.forEach((row, i) => {
40 if (row.kind === 'header') {
41 if (i > 0) shown.push({ kind: 'spacer' })
42 folded = props.collapsed.includes(row.label)
43 shown.push(row)
44 } else if (!folded) {
45 shown.push(row)
46 }
47 })
48
49 const spans = (row: Extract<McRow, { kind: 'track' }>) =>
50 row.bars.flatMap((b: McBar) => b.segs.map(([a, z]) => {
51 const c0 = Math.max(0, Math.floor(cellAt(a, row.base)))
52 const c1 = Math.min(trackW, Math.max(c0 + 1, Math.ceil(cellAt(z, row.base))))
53 return { bar: b, c0, c1 }
54 })).filter(s => s.c0 < trackW && s.c1 > 0)
55
56 const hit = (x: number, y: number): string | null => {
57 const row = shown[y - 1]
58 if (!row || row.kind !== 'track') return null
59 if (x < labelW) return row.id
60 const col = x - labelW - 1
61 const found = spans(row).find(s => col >= s.c0 - 1 && col < s.c1 + 1)
62 return found ? found.bar.id : row.id
63 }
64
65 surface.onPointer((ev: ClientPointerEvent) => {
66 if (ev.type === 'leave') {
67 if (hover !== null) surface.setState({ hover: null })
68 return
69 }
70 const id = hit(ev.x, ev.y)
71 if (ev.type === 'down' && ev.button === 'left') {
72 const row = shown[ev.y - 1]
73 if (row?.kind === 'header') surface.post({ type: 'toggle', group: row.label })
74 else surface.post({ type: 'select', id })
75 return
76 }
77 if ((ev.type === 'move' || ev.type === 'enter') && id !== hover) surface.setState({ hover: id })
78 })
79
80 surface.onKey(ev => {
81 const order = props.order
82 const at = props.selected ? order.indexOf(props.selected) : -1
83 if (ev.key === 'down' || ev.key === 'right') {
84 surface.post({ type: 'select', id: order[Math.min(order.length - 1, at + 1)] ?? null })
85 } else if (ev.key === 'up' || ev.key === 'left') {
86 surface.post({ type: 'select', id: order[Math.max(0, at - 1)] ?? null })
87 } else if ((ev.key === 'return' || ev.key === 'o') && props.selected) {
88 surface.post({ type: 'open', id: props.selected })
89 }
90 })
91
92 const tickCells = props.ticks
93 .map(t => ({ ...t, c: Math.round((t.at - props.hourFrom) * perHour) }))
94 .filter(t => t.c >= 0 && t.c < trackW)
95 const nowCell = (base: number) =>
96 props.now !== null && props.nowBase === base ? Math.floor(cellAt(props.now, base)) : -1
97 const axisNow = props.nowBase !== null ? nowCell(props.nowBase) : -1
98
99 // the axis: tick labels and the "now" mark, each a box starting at its cell
100 const marks = [
101 ...tickCells.map(t => ({ c: t.c, text: t.label, isNow: false })),
102 ...(axisNow >= 0 ? [{ c: axisNow, text: '▼', isNow: true }] : []),
103 ].sort((a, b) => a.c - b.c)
104 const axisParts: RenderElement[] = []
105 let pos = 0
106 marks.forEach((m, i) => {
107 if (m.c < pos) return // no room left for this one
108 if (m.c > pos) axisParts.push(<Box width={m.c - pos} height={1} />)
109 const next = marks.slice(i + 1).find(n => n.c > m.c)?.c ?? trackW
110 const w = Math.max(1, Math.min(next - m.c, m.isNow ? 1 : m.text.length + 1))
111 axisParts.push(
112 <Box width={w} height={1} overflow="hidden">
113 {m.isNow ? <Text color={NOW}>{m.text}</Text> : <Text dimColor wrap="truncate-end">{m.text}</Text>}
114 </Box>,
115 )
116 pos = m.c + w
117 })
118 const axis = (
119 <Box flexDirection="row" height={1}>
120 <Box width={labelW + 1} height={1} />
121 <Box width={trackW} height={1} flexDirection="row">{axisParts}</Box>
122 </Box>
123 )
124
125 /** One track as consecutive fixed-width boxes. */
126 const track = (row: Extract<McRow, { kind: 'track' }>): RenderElement[] => {
127 const owner: (McBar | null)[] = new Array(trackW).fill(null)
128 for (const s of spans(row)) for (let c = s.c0; c < s.c1; c++) owner[c] = s.bar
129 const ticks = new Set(tickCells.map(t => t.c))
130 const now = nowCell(row.base)
131 const parts: RenderElement[] = []
132 let c = 0
133 while (c < trackW) {
134 const bar = owner[c]
135 if (bar) {
136 let e = c + 1
137 while (e < trackW && owner[e] === bar) e++
138 const lit = props.selected === null || bar.id === props.selected || bar.id === hover
139 parts.push(<Box width={e - c} height={1} backgroundColor={lit ? bar.color : bar.muted} />)
140 c = e
141 } else if (c === now) {
142 parts.push(<Box width={1} height={1}><Text color={NOW}>│</Text></Box>)
143 c += 1
144 } else if (ticks.has(c)) {
145 parts.push(<Box width={1} height={1}><Text dimColor>┆</Text></Box>)
146 c += 1
147 } else {
148 let e = c + 1
149 while (e < trackW && !owner[e] && e !== now && !ticks.has(e)) e++
150 parts.push(<Box width={e - c} height={1} />)
151 c = e
152 }
153 }
154 return parts
155 }
156
157 const lines: RenderElement[] = shown.map(row => {
158 if (row.kind === 'spacer') return <Box height={1} />
159 if (row.kind === 'header') {
160 return (
161 <Box flexDirection="row" height={1} overflow="hidden">
162 <Text bold>{`${props.collapsed.includes(row.label) ? '▸' : '▾'} ${row.label}`}</Text>
163 <Text dimColor wrap="truncate-end">
164 {`${row.sub ? ` ${row.sub}` : ''}${row.note ? ` — ${row.note}` : ''}`}
165 </Text>
166 </Box>
167 )
168 }
169 const isSel = row.id !== null && row.id === props.selected
170 const isHover = row.id !== null && row.id === hover
171 return (
172 <Box flexDirection="row" height={1}>
173 <Box width={labelW} height={1} flexDirection="row" overflow="hidden">
174 <Box width={2} height={1}>
175 {isSel
176 ? <Text bold>▸</Text>
177 : row.id && row.bars[0] ? <Text color={row.bars[0].color}>●</Text> : <Text> </Text>}
178 </Box>
179 <Box width={labelW - 2 - SUB_W} height={1} overflow="hidden">
180 <Text wrap="truncate-end" bold={isSel} underline={isHover}
181 dimColor={!isSel && !isHover && props.selected !== null}>
182 {row.label}
183 </Text>
184 </Box>
185 <Box width={SUB_W} height={1} justifyContent="flex-end">
186 <Text dimColor>{row.sub}</Text>
187 </Box>
188 </Box>
189 <Box width={1} height={1} />
190 <Box width={trackW} height={1} flexDirection="row">{track(row)}</Box>
191 </Box>
192 )
193 })
194
195 const focus = props.rows
196 .flatMap(r => (r.kind === 'track' ? r.bars : []))
197 .find(b => b.id === (hover ?? props.selected))
198 return (
199 <Box flexDirection="column">
200 {axis}
201 {lines.length ? lines : [<Text dimColor>Nothing in this range.</Text>]}
202 <Box height={1} />
203 <Text dimColor={!focus} wrap="truncate-end">
204 {focus ? focus.tip : 'Hover a bar for details · click a bar or a name to select it · ↑/↓ step · Enter opens'}
205 </Text>
206 </Box>
207 )
208}
209
210export default Timeline
211types/index.d.ts 214 lines1// Mission Control's state contract: what src/mission_control.py hands the pane,
2// and the values the pane keeps in `$.state` for the session.
3
4export type McRangeKey = 'today' | 'yesterday' | '7d' | 'custom'
5/** How the timeline groups conversations: by workstream (the digest's epic for each),
6 * project, status, kind of work, work/personal — or one row per day. */
7export type McRowsMode = 'workstream' | 'project' | 'status' | 'kind' | 'category' | 'day'
8/** What sort of work a conversation was, as the digest's summarizer judged it. */
9export type McKind = 'build' | 'fix' | 'review' | 'investigate' | 'plan' | 'admin'
10/** One of the digest's workstreams (~/.claude/digest/workstreams.json). */
11export type McWorkstream = { name: string; description: string | null; count: number }
12export type McColorMode = 'status' | 'project'
13export type McSegment = [number, number]
14
15export type McSession = {
16 id: string
17 app: string | null
18 apps: (string | null)[]
19 title: string
20 appTitle: string | null
21 project: string
22 root: string | null
23 cwd: string | null
24 category: string
25 status: string
26 changedSinceDigest: boolean
27 digested: boolean
28 gist: string | null
29 open: string | null
30 topics: string[]
31 tickets: string[]
32 branch: string | null
33 segments: McSegment[]
34 first: number
35 last: number
36 activeMin: number
37 scheduled: string | null
38 archived: boolean
39 needsAction: string | null
40 /** the digest's epic for it, and what sort of work it was; null until summarized */
41 workstream: string | null
42 kind: McKind | null
43}
44
45export type McRun = {
46 start: number | null
47 end: number | null
48 status: string
49 detail: string | null
50 app: string | null
51 cli: string | null
52 title: string | null
53}
54
55export type McRoutine = {
56 task: string
57 name: string
58 description: string | null
59 oneTime: boolean
60 runs: McRun[]
61}
62
63export type McDay = { date: string; label: string; from: number; to: number }
64
65export type McLedgerRun = {
66 start: number | null
67 end?: number | null
68 trigger: string | null
69 status: string | null
70 indexed?: number | null
71 renamed?: number | null
72 note?: string | null
73}
74
75export type McIssue = { ts: number | null; kind: string | null; detail: string | null; severity: string }
76
77/** The latest standup brief, read from the standup-brief task's last run. */
78export type McStandup = {
79 app: string | null
80 cli: string | null
81 at: number | null
82 heading: string
83 script: string
84 table: string | null
85 preamble: string | null
86}
87
88export type McSnapshot = {
89 generatedAt: number
90 tookMs: number
91 tzOffsetMin: number
92 range: { key: string; label: string; from: number; to: number; days: McDay[] }
93 sessions: McSession[]
94 totals: { conversations: number; activeMin: number; open: number; new: number; projects: number }
95 routines: McRoutine[]
96 digest: { lastRun: McLedgerRun | null; lastOk: number | null; issues: McIssue[]; issuesSinceOk: number }
97 standup: McStandup | null
98 /** projects a conversation can be moved to */
99 projects: string[]
100 /** the digest's workstreams, most recently seen first */
101 workstreams: McWorkstream[]
102}
103
104/** One turn of a conversation in the reader: the person's message or Claude's reply. */
105export type McTurn = { role: 'user' | 'assistant' | 'note'; ts: number | null; text: string; tools: string[] }
106
107/** A conversation as the reader shows it (collect.py transcript). */
108export type McReader = {
109 id: string
110 mtime?: number
111 total?: number
112 hidden?: number
113 turns?: McTurn[]
114 error?: string
115 /** how many turns were asked for: "Show earlier" raises it */
116 last: number
117 /** whether the messages are unfolded; closed by default, the gist is enough to start */
118 expanded: boolean
119 loading: boolean
120}
121
122/** What the desktop Ask bar (hooks/askbar.tsx) is drawn from. */
123export type McAskBarProps = { placeholder: string; question: string; busy: boolean }
124
125export type McView = {
126 range: McRangeKey
127 rows: McRowsMode
128 color: McColorMode
129 /** a custom range's first and last day, local YYYY-MM-DD, both included */
130 from?: string
131 to?: string
132 /** show only this workstream / this kind of work; absent shows all */
133 ws?: string
134 kind?: McKind
135}
136
137export type McRef = { id: string; app: string | null; title: string }
138
139export type McAsk = {
140 question: string
141 busy: boolean
142 answer: string | null
143 refs: Record<string, McRef>
144 error: string | null
145}
146
147/** What the app's scheduled-tasks server says about a task, when the mod can reach it. */
148export type McTask = {
149 taskId: string
150 enabled: boolean
151 schedule: string | null
152 nextRunAt: string | null
153}
154
155export type McStatus = {
156 loading: boolean
157 error: string | null
158 tasks: McTask[] | null
159}
160
161/** One bar's worth of a session on a timeline row: its active stretches. */
162export type McBar = { id: string; color: string; muted: string; segs: McSegment[]; tip: string; routine?: true }
163
164/**
165 * One row of the timeline: a group heading, or a track (a conversation, a routine,
166 * or a whole day) whose bars are placed against `base`, the local midnight its
167 * hours count from. `id` is set when the row IS one conversation, so its label
168 * selects it too.
169 */
170export type McRow =
171 | { kind: 'header'; label: string; sub: string; note?: string }
172 | { kind: 'track'; label: string; sub: string; base: number; id: string | null; bars: McBar[] }
173
174export type McTick = { at: number; label: string }
175
176export type McTimelineProps = {
177 rows: McRow[]
178 hourFrom: number
179 hourTo: number
180 ticks: McTick[]
181 now: number | null
182 nowBase: number | null
183 selected: string | null
184 order: string[]
185 /** group headings whose conversations are folded away */
186 collapsed: string[]
187}
188
189declare module 'claude-code' {
190 interface PluginState {
191 'convo-digest': {
192 snap: McSnapshot | null
193 view: McView
194 selected: string | null
195 ask: McAsk
196 status: McStatus
197 /** whether the one-line "Mission Control" band shows above the prompt */
198 band: boolean
199 /** whether the standup brief is unfolded in full */
200 standupOpen: boolean
201 /** when Summarize now handed the digest a turn, until that turn completes */
202 summarizing: number | null
203 /** timeline groups folded away (by heading); empty, the default, is all unfolded */
204 collapsed: string[]
205 /** the app session an Archive or Pin click handed a turn for, until that turn completes */
206 acting: string | null
207 /** the conversation open in the reader, and what was read of it */
208 reader: McReader | null
209 /** the conversation a Summarize click handed a digest turn for, until that turn ends */
210 resummarizing: string | null
211 }
212 }
213}
214