SLOPSHOPPER

convo-digest

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…

newpanebandcommandtoaststatus
v0.5.2no licenseupdated 2026-10-09pariidanDKE/convo-digest
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · convo-digest
│ ┃ Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ Mission Control │ ┃ Couldn't collect the data: JSON Parse error: ⏺ Read(src/auth.ts) │ ┃ Unexpected identifier "dev" ⎿ Read 6 lines │ ┃ [ Retry ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mission │ ⎿ convo-digest: Mission Control is open. │ │ ◉ Mission Control Summarize this chat ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◉ Mission Control Summarize this chat
Pane · Mission Control
Mission Control Couldn't collect the data: JSON Parse error: Unexpected identifier "dev" [ Retry ]
README

convo-digest

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.

What you get

Skills (namespaced under convo-digest):

SkillWhat it does
/convo-digest:digestSummarize conversations that changed since the last run into the recall index.
/convo-digest:recallFind a relevant past conversation for what you're starting on, and offer to resume it.
/convo-digest:digest-archiveReview recent conversations and archive the ones you're done with so they stop cluttering recall.
/convo-digest:setup-nightlySchedule the digest to run itself overnight (macOS, Linux, Windows).
/convo-digest:profile-reposTag 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.

Set it and forget it

If you'd rather never be asked, let the digest run itself overnight:

/convo-digest:setup-nightly

You pick one of two mechanisms:

  • OS scheduler (default) — launchd on macOS, a systemd user timer (or cron) on Linux, Task Scheduler on Windows — all firing the same generated launcher at ~/.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.
  • Claude Desktop task — runs inside the desktop app, so it uses the app's login, always the current plugin version, and leaves a run history in the app sidebar. Only fires while the app is open, and draws your interactive subscription usage like any session. Desktop app only. Schedule it for a time your machine is awake (e.g. 09:20): runs that started while the laptop was asleep, or caught up right after it woke, account for nearly every failed night — the machine slept mid-run, or the login had gone stale.

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

Run record and issue log

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, Desktop or Downloads — the job would die with a bare EPERM every 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.

Requirements

  • Claude Code (with plugins enabled).
  • 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.
  • (Optional) pip install tiktoken for sharper token counts. Not required; without it, a conservative character heuristic is used.

Install

/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.

Titles in the desktop app

With title writeback on, each conversation gets the digest's title in two places:

  • the transcript's custom-title record — the claude --resume picker;
  • the desktop app's own session title — the sidebar and the app's search box.

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.

Mission Control

A pane over the same index: type /mission, or click the ◉ Mission Control band above the prompt.

  • The day's standup brief up top, folded to its first bullets.
  • An Ask bar that answers from your summaries and links the conversations it cites.
  • A timeline of your conversations, grouped by workstream, project, status, kind of work or day. Filter it by workstream and kind; pick today, yesterday, the last 7 days or your own dates.
  • Per conversation: its gist, a read-only reader with the messages, and buttons to open it, summarize it again, move it to another project, pin it to the sidebar or archive it.
  • Your routines and the digest's health at the bottom.

/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.

The flow

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.

How it works

  • Reads your local Claude Code transcripts (~/.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.
  • Every conversation is filed under a workstream: an epic-level goal that spans several tickets and conversations ("Atlas AI tool access"). The list lives in ~/.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.
  • Large conversations are downsampled to a tail-weighted view and expanded on demand under a token budget, so no single summary blows the model's context.
  • Summarization runs as a background workflow orchestrating small Read-only agents on the Haiku model — your interactive session stays free.
  • Everything is incremental and checkpointed: only changed conversations are re-summarized, and the change-detector only advances after a summary is written.

Privacy

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/.

Source 5 files
hooks/mission-control/register.tsx 1297 lines
1// 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 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
419
hooks/mission-control/askbar.tsx 139 lines
1// 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
139
hooks/mission-control/timeline.tsx 211 lines
1// 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
211
types/index.d.ts 214 lines
1// 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