SLOPSHOPPER

channel

Your chats in a pane beside Claude Code: read, reply, and get a toast when someone messages you. iMessage today, more chat apps to come.

newpanecommandtoaststatusprocess
★ 1v0.4.0MITupdated 2026-10-02jamubc/toolbox/plugins/channel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · channel
│ ┃ Chats ✕ › fix the failing auth test and add an audit log call │ ┃ ● Chats │ ┃ iMessage ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ Loading conversations… ⎿ 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 │ │ › /channel │ ⎿ channel: channel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chats
● Chats iMessage ──────────────────────────────────────────────────────── Loading conversations…
README

channel

Your favourite chat, directly in Claude Code. Read, reply, get notified and more, without leaving the terminal.

Claude Code with a haiku conversation on the left and the channel pane docked on the right, showing an iMessage group chat with messages from three people, a shared beach photo, a heart tapback, and the reply box

It can all be done, inside Claude Code

Install

Add the marketplace:

/plugin marketplace add jamubc/toolbox

Install the plugin:

/plugin install channel@toolbox

Usage

CommandWhat it does
/channelOpens the chat pane: a tab per service, and your twenty most recent conversations in each.

In the pane:

  • Tabs switch between services. With two or more set up, an All tab shows every conversation together.
  • The list shows each conversation with a colored avatar chip, when it last moved, how many people are in it, and its newest message. Unread chats are marked and counted; keys 1 to 9 open the first nine.
  • A conversation shows messages under the day they came, each person in their own color (you in cyan), with files, the message a reply answers, reactions, and edited or unsent messages. ↑ older pages back through the history; ↓ latest returns to the end.
  • Pictures (and the first page of a PDF) preview inline in the terminal: as real pixels in kitty and Ghostty, as colored blocks elsewhere. A terminal that turns out not to draw pixels falls back to blocks by itself. /config → How picture previews are drawn forces one, or turns previews off.
  • → Claude on a file puts its path in your Claude prompt, ready for you to send. copy path copies it, and pressing the file name opens it with your Mac's own app.
  • attach sends a file: pick one this Claude session touched, type a path, or drag a file onto the field.
  • Claude's reply puts Claude's last answer in the reply box for you to edit and send.
  • ↪ new in … jumps to the conversation that last notified you, and ↩ back to … returns you.

Notifications stay quiet: only one of your Claude sessions toasts, several messages in one chat fold into one toast, and a chat toasts at most once every 10 seconds. Every session still shows the unread count in its status line.

Supported connections

ConnectionPlatformSetupWhat it can do
iMessagemacOS 13 or later, terminalFull Disk AccessRead and send text and files; show replies, tapbacks, edits, unsends and members

Turn a service off in /config under channel.

iMessage

  • Reading uses sqlite3 -readonly on ~/Library/Messages/chat.db, which requires Full Disk Access for your terminal. The pane lists the steps and names your terminal app.
  • Sending text and files uses osascript. On your first send, macOS asks you to let your terminal control Messages.
  • Messages gives no way to send a tapback, edit a message or mark one read, so channel shows those and cannot do them.

A service can start only the programs and call only the hosts listed here. Anything else is refused in hooks/core/fence.ts.

ServicePrograms it startsHosts it calls
iMessage/usr/bin/sqlite3, /usr/bin/osascriptnone

The pane itself starts /usr/bin/open when you press a file, and /usr/bin/sips to make a picture's or a PDF's preview. Previews are at most twelve temporary files in your TMPDIR, each overwritten in turn. The cells of a preview are averaged from the pixels they cover and encoded once per size, so redrawing the pane costs nothing more.

  • hooks/core/contract.ts is what every service implements: a small required core, plus optional capabilities (attachments, threads, reactions, edits, read state, typing, members, search). The pane offers only what a service has.
  • hooks/core/registry.ts lists the services. Adding one is a folder under hooks/providers/ and a line there.
  • hooks/core/account.ts runs one service with its own loop and backoff; inbox.ts merges them; notify.ts decides what toasts; hub.ts ties them together.
  • hooks/ui/ draws the pane: list.tsx the conversations, conversation.tsx one chat, picture.ts thumbnails as pixels or cells, format.ts labels, colors and days. hooks/register.tsx is wiring only.
  • tests/kit/ has a scriptable fake service and the conformance checks every provider must pass.
  • What a chat says never reaches Claude by itself: no prompt, no transcript row, no tool.
  • Only you move things across. → Claude writes a received file's path into your prompt box as a draft, and you send it. Claude's reply and the offered files go to a chat only when you press and send.
  • Nothing is sent without your Enter or press.
  • Message text stays in the plugin's memory: not in session state other plugins can read, not on disk. The only thing stored is which session shows toasts.
  • Each service reaches only what the table above lists. iMessage makes no network request.

Development

claude --plugin-dir plugins/channel     # load it; edits hot-reload
claude plugin validate plugins/channel
claude plugin test plugins/channel
tsc -p plugins/channel                  # after one load writes .claude-plugin/types/
Source 15 files
hooks/register.tsx 234 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { createHub } from './core/hub'
4import type { Hub } from './core/hub'
5import { services } from './core/registry'
6import { PaneView } from './ui/pane'
7import { decodeBmp } from './ui/picture'
8import type { Picture } from './ui/picture'
9
10// Wiring only: the services run in core/, the pane is drawn in ui/.
11//
12// Chat is untrusted input and private, whichever service it comes from:
13// - Nothing a chat says reaches the model. No session.append, no
14//   prompt.submit, no tool. Messages go to the pane, toasts and the status line.
15// - One thing crosses into the session, and only on the person's press: the
16//   path of a received file, written into their prompt box as a draft
17//   (prompt.fill). They send it; the message text never goes.
18// - One thing crosses out, and only on the person's press: Claude's last
19//   reply, or a file this session touched, offered for sending.
20// - A message goes out only on the person's Enter or press.
21// - Message text lives in this mod's memory alone, never in $.state (any
22//   plugin reads that) or $.store (it outlives the session). The store holds
23//   one thing: which session toasts.
24// - Each service reaches only the commands and hosts its spec declares
25//   (core/fence.ts). The mod itself starts one more program, `open`, and only
26//   when the person presses a file. It also starts `sips` to make the small
27//   copy of a picture (or a PDF's first page) that the pane shows; those copies
28//   are at most twelve temporary files, each overwritten in turn.
29//
30// Pictures follow the browse pane: a sharp PNG the terminal reads itself where
31// it draws pixels, small bitmaps packed into cells elsewhere, a /config choice
32// between them, and a fall back to cells when the terminal turns out not to
33// draw pixels after all.
34
35const PANE = 'channel'
36const OPEN = '/usr/bin/open'
37const SIPS = '/usr/bin/sips'
38// The longest side of a picture's copy, in pixels: sharp where the terminal
39// draws pixels and reads the file itself, small where it is packed into cells.
40const SHARP = 1600
41const BLOCKS = 200
42const OFFERED_FILES = 5
43// How long after a picture is drawn the terminal is asked whether it took it.
44const PROBE_MS = 400
45// The pane's share of the terminal when docked, and the least it takes.
46const SIDEBAR = 0.36
47const MIN_COLUMNS = 44
48// What Claude's reply is cut to as a chat draft.
49const DRAFT = 2000
50
51// TERM_PROGRAM to the name the terminal goes by in System Settings.
52const APPS: Record<string, string> = {
53  Apple_Terminal: 'Terminal',
54  'iTerm.app': 'iTerm',
55  ghostty: 'Ghostty',
56  WezTerm: 'WezTerm',
57  WarpTerminal: 'Warp',
58  vscode: 'Visual Studio Code',
59}
60
61type Options = { imessage?: boolean; pictures?: string }
62
63// Real pixels where the terminal speaks the kitty graphics protocol. Not
64// through tmux, which drops it, nor over ssh.
65async function hasPixels($: EngineInterface): Promise<boolean> {
66  const isRelayed =
67    (await $.env.get('TMUX')) !== undefined ||
68    (await $.env.get('SSH_CONNECTION')) !== undefined ||
69    (await $.env.get('SSH_TTY')) !== undefined
70  if (isRelayed) {
71    return false
72  }
73  const term = (await $.env.get('TERM')) ?? ''
74  const program = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
75
76  return (await $.env.get('KITTY_WINDOW_ID')) !== undefined || term.includes('kitty') || term.includes('ghostty') || program === 'ghostty'
77}
78
79// How pictures draw, from /config and the terminal: pixels, cells, or not at all.
80// The words are what the /config picker shows, so a row there explains itself;
81// the engine has already turned anything else into the default before we run.
82async function pictureMode($: EngineInterface, options: Options): Promise<'pixels' | 'cells' | 'off'> {
83  const choice = (options.pictures ?? '').toLowerCase()
84  if (choice.startsWith('no previews')) return 'off'
85  if (choice.startsWith('always real')) return 'pixels'
86  if (choice.startsWith('always colored')) return 'cells'
87
88  return (await hasPixels($)) ? 'pixels' : 'cells'
89}
90
91async function build($: EngineInterface, surface: string, options: Options): Promise<Hub> {
92  const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
93  let generation = 0
94  // One at a time: sips is not cheap, and a slot's file must not be half-written.
95  let making: Promise<unknown> = Promise.resolve()
96
97  async function thumbnail(path: string, slot: number, asPixels: boolean): Promise<Picture | undefined> {
98    const measured = await $.process.run([SIPS, '-g', 'pixelWidth', '-g', 'pixelHeight', path])
99    const width = Number(/pixelWidth: (\d+)/.exec(measured.stdout)?.[1])
100    const height = Number(/pixelHeight: (\d+)/.exec(measured.stdout)?.[1])
101    if (measured.exitCode !== 0 || !(width > 0) || !(height > 0)) {
102      return undefined
103    }
104    const file = `${tmp}/claude-channel-picture-${slot}.${asPixels ? 'png' : 'bmp'}`
105    const made = await $.process.run([SIPS, '-s', 'format', asPixels ? 'png' : 'bmp', '-Z', String(asPixels ? SHARP : BLOCKS), path, '--out', file])
106    if (made.exitCode !== 0) {
107      return undefined
108    }
109    if (asPixels) {
110      generation += 1
111
112      return { width, height, source: { file, generation } }
113    }
114    const bitmap = decodeBmp(Uint8Array.fromBase64((await $.fs.read(file, { as: 'bytes' })).base64))
115
116    return bitmap && { width, height, source: { bitmap } }
117  }
118
119  return createHub(services, {
120    pictures: await pictureMode($, options),
121    thumbnail(path, slot, asPixels) {
122      const next = making.then(() => thumbnail(path, slot, asPixels))
123      making = next.catch(() => undefined)
124
125      return next
126    },
127    // A blit of the very source the Image already shows sends nothing new,
128    // and answers whether the terminal drew it or its alt text.
129    probe: (key, source) =>
130      new Promise(resolve => {
131        $.clock.after(PROBE_MS, () => {
132          $.ui.blit({ requestId: PANE, key, source: { file: source.file, format: 'png', generation: source.generation } })
133            .then(res => resolve(res.deny === undefined || /mount/i.test(res.deny)))
134            .catch(() => resolve(true))
135        })
136      }),
137    tools: {
138      run: argv => $.process.run(argv),
139      fetch: (url, init) => $.http.fetch(url, init),
140      spawn: argv => $.process.spawn({ argv: [...argv] }),
141    },
142    settings: {
143      home: (await $.env.get('HOME')) ?? '',
144      app: APPS[(await $.env.get('TERM_PROGRAM')) ?? ''] ?? '',
145      surface,
146      options,
147    },
148    clock: { now: () => $.clock.now(), after: (ms, fn) => $.clock.after(ms, fn) },
149    isShown: async () => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown),
150    toast: text => void $.ui.toast(text),
151    status: text => void $.ui.status(text),
152    store: {
153      get: key => $.store.get(key),
154      set: (key, value) => $.store.set(key, value),
155      delete: key => $.store.delete(key),
156    },
157    session: await $.session.id(),
158    redraw: () => void $.ui.invalidate('ui.render'),
159    toPrompt: async text => (await $.prompt.fill({ text, mode: 'insert' })).isFilled,
160    copy: async text => (await $.ui.copy({ text })).isCopied,
161    async offers() {
162      const messages = await $.session.messages()
163      const files: string[] = []
164      for (const message of [...messages].reverse()) {
165        for (const use of [...message.toolUses].reverse()) {
166          const path = use.input.file_path
167          if (typeof path === 'string' && path.startsWith('/') && !files.includes(path)) {
168            files.push(path)
169          }
170        }
171      }
172      const lastReply = messages.findLast(one => one.role === 'assistant' && one.text.trim())?.text.trim() ?? ''
173
174      return { files: files.slice(0, OFFERED_FILES), lastReply: lastReply.slice(0, DRAFT) }
175    },
176    async openPath(path) {
177      const res = await $.process.run([OPEN, path])
178      if (res.exitCode !== 0) {
179        throw new Error(res.stderr.trim() || `open exited ${res.exitCode}`)
180      }
181    },
182  })
183}
184
185export const register: Register = (on, options: Options) => {
186  let hub: Hub | undefined
187
188  on('session.start', async ($, e, next) => {
189    await $.command.register({
190      name: 'channel',
191      description: 'Open your chats in a pane',
192      immediate: true,
193    })
194    hub = await build($, e.surface ?? 'none', options)
195    if (e.isInteractive) {
196      void hub.start()
197    }
198
199    return next(e)
200  })
201
202  // /clear ends the conversation, not the process, and no session.start follows.
203  on('session.end', async ($, e, next) => {
204    if (e.reason !== 'clear') {
205      await hub?.stop()
206    }
207
208    return next(e)
209  })
210
211  on('command.run', { command: 'channel' }, async ($, e) => {
212    // A sidebar: a third of the terminal, so Claude keeps most of the width.
213    const columns = Math.max(MIN_COLUMNS, Math.round(e.presentation.columns * SIDEBAR))
214    await $.ui.open({ id: PANE, title: 'Chats', focus: true, columns })
215    await hub?.attend()
216
217    return { text: 'channel opened.' }
218  })
219
220  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
221    const elements = $.ui.resolve(e)
222    if (!hub) {
223      return <elements.Text dimColor>Starting…</elements.Text>
224    }
225
226    return PaneView(hub, elements, {
227      bodyColumns: e.props.bodyColumns,
228      bodyRows: e.props.scroll.bodyRows,
229      surface: e.surface,
230      now: await $.clock.now(),
231    })
232  })
233}
234
hooks/core/hub.ts 489 lines
1import { reasonOf, textOf } from './contract'
2import type { Attachment, Health, Message, ProviderSpec, Provider, Ref, Settings, Tools } from './contract'
3import { createAccount } from './account'
4import type { Account, Clock } from './account'
5import { createInbox } from './inbox'
6import type { Entry } from './inbox'
7import { createNotifier } from './notify'
8import type { NotifierDeps } from './notify'
9import type { Picture } from '../ui/picture'
10
11// The one object register.tsx and the pane talk to: every service's account,
12// the inbox they fill, the notifier, and what the person has selected.
13
14export const ALL = 'all'
15// Conversations a tab lists; the first nine get a hotkey.
16const ROWS = 20
17// Thumbnails kept in memory.
18export const PICTURES = 12
19// Older messages fetched per press of "older".
20const OLDER_PAGE = 50
21// Image draws refused before pictures fall back to cells for the session.
22const DENIES_BEFORE_CELLS = 3
23
24export type HubDeps = {
25  tools: Tools
26  settings: Settings
27  clock: Clock
28  isShown: () => Promise<boolean>
29  toast: NotifierDeps['toast']
30  status: NotifierDeps['status']
31  store: NotifierDeps['store']
32  session: string
33  redraw: () => void
34  // Opens a file with the system's own app for it.
35  openPath: (path: string) => Promise<void>
36  // A small copy of a picture file, or nothing when it cannot be made.
37  // `slot` is one of PICTURES places it may keep the copy in; `asPixels` asks
38  // for a sharp file the terminal reads itself, else a small bitmap for cells.
39  thumbnail: (path: string, slot: number, asPixels: boolean) => Promise<Picture | undefined>
40  // Whether the terminal draws real pixels; otherwise pictures are cells.
41  // 'off' draws no pictures at all.
42  pictures: 'pixels' | 'cells' | 'off'
43  // Whether the terminal did draw the Image under `key`: false when it drew
44  // the alt text instead, so pictures should fall back to cells.
45  probe: (key: string, source: { file: string; generation: number }) => Promise<boolean>
46  // Writes into the person's prompt box as a draft; false when it could not.
47  toPrompt: (text: string) => Promise<boolean>
48  copy: (text: string) => Promise<boolean>
49  // What this Claude session can offer a chat: the files it touched, newest
50  // first, and Claude's last reply.
51  offers: () => Promise<Offers>
52}
53
54export type Offers = { files: string[]; lastReply: string }
55
56export type Tab = { id: string; label: string; unread: number; isSelected: boolean; health: Health }
57
58export type View = {
59  tab: string
60  selected?: Ref
61  isAttaching: boolean
62  problem: string | null
63  // How many acts have finished, and what to say about the last one.
64  sent: number
65  note: string
66  // What the reply box starts with: Claude's last reply, when the person asked.
67  draft: string
68  offers: Offers
69  // How many more rows of history than fit the person asked to see.
70  depth: number
71  // Whether the oldest message held is the first the service has.
72  isAtStart: boolean
73}
74
75export type Hub = ReturnType<typeof createHub>
76
77function isSame(a: Ref | undefined, b: Ref): boolean {
78  return a !== undefined && a.service === b.service && a.conversation === b.conversation
79}
80
81export function createHub(specs: readonly ProviderSpec[], deps: HubDeps) {
82  const inbox = createInbox()
83  const view: View = {
84    tab: ALL, isAttaching: false, problem: null, sent: 0, note: '', draft: '', offers: { files: [], lastReply: '' }, depth: 0, isAtStart: false,
85  }
86  let pictureMode = deps.pictures
87  let imageDenies = 0
88
89  // Services the person can see: ready, or waiting on them.
90  const shown = (): Account[] => accounts.filter(one => one.health.state === 'ready' || one.health.state === 'setup')
91
92  const notifier = createNotifier({
93    toast: deps.toast,
94    status: deps.status,
95    store: deps.store,
96    session: deps.session,
97    now: deps.clock.now,
98    services: () => shown().map(one => ({ id: one.spec.id, label: one.spec.label })),
99    nameOf: ref => inbox.nameOf(ref),
100    unreadOf: service => inbox.unreadOf(service),
101  })
102
103  function redraw(): void {
104    notifier.showStatus()
105    deps.redraw()
106  }
107
108  const accounts: Account[] = specs.map(spec =>
109    createAccount(spec, deps, {
110      conversations: list => inbox.setConversations(spec.id, list),
111      changed: redraw,
112      async updates(list) {
113        const isShown = await deps.isShown()
114        const fresh: Message[] = []
115        for (const update of list) {
116          const message = inbox.apply(spec.id, update)
117          if (!message || message.sender.isMe) {
118            continue
119          }
120          const ref = { service: spec.id, conversation: message.conversation }
121          if (isShown && isSame(view.selected, ref)) {
122            continue
123          }
124          inbox.bump(ref)
125          fresh.push(message)
126        }
127        await notifier.pass(spec.id, fresh)
128      },
129    }),
130  )
131
132  function providerOf(ref: Ref | undefined): Provider | undefined {
133    return ref && accounts.find(one => one.spec.id === ref.service)?.provider
134  }
135
136  function labelOf(service: string): string {
137    return specs.find(one => one.id === service)?.label ?? service
138  }
139
140  async function open(ref: Ref): Promise<void> {
141    view.selected = ref
142    view.isAttaching = false
143    view.problem = null
144    view.depth = 0
145    view.isAtStart = false
146    inbox.markSeen(ref)
147    redraw()
148    if (inbox.isLoaded(ref)) {
149      return
150    }
151    try {
152      const provider = providerOf(ref)
153      if (provider) {
154        inbox.setHistory(ref, await provider.history(ref.conversation))
155      }
156    } catch (error) {
157      view.problem = `${labelOf(ref.service)}: ${reasonOf(error)}`
158    }
159    redraw()
160  }
161
162  // Thumbnails by file, loaded on first draw. null: loading, or none to show.
163  const pictures = new Map<string, Picture | null>()
164  let loaded = 0
165
166  async function loadPicture(key: string, ref: Ref, part: Attachment, element: string): Promise<void> {
167    const slot = loaded % PICTURES
168    loaded += 1
169    const asPixels = pictureMode === 'pixels'
170    const bitmap = await pathOf(ref, part)
171      .then(path => deps.thumbnail(path, slot, asPixels))
172      .catch(() => undefined)
173    if (!bitmap || !pictures.has(key)) {
174      return
175    }
176    pictures.set(key, bitmap)
177    redraw()
178    if ('file' in bitmap.source && pictureMode === 'pixels') {
179      void probe(element, bitmap.source)
180    }
181  }
182
183  // Asks the terminal whether it drew the picture as pixels. One that drew the
184  // alt text instead (no kitty graphics after all) is counted; a few of those
185  // and every picture is remade as cells, as the browse pane does with frames.
186  async function probe(element: string, source: { file: string; generation: number }): Promise<void> {
187    for (let attempt = 0; attempt < DENIES_BEFORE_CELLS; attempt += 1) {
188      if (pictureMode !== 'pixels') {
189        return
190      }
191      const isDrawn = await deps.probe(element, source).catch(() => true)
192      if (isDrawn) {
193        imageDenies = 0
194
195        return
196      }
197      imageDenies += 1
198      if (imageDenies >= DENIES_BEFORE_CELLS) {
199        pictureMode = 'cells'
200        pictures.clear()
201        deps.toast('This terminal cannot show pictures as pixels; drawing them in colored blocks instead.')
202        redraw()
203
204        return
205      }
206    }
207  }
208
209  function done(note: string): void {
210    view.sent += 1
211    view.note = note
212    view.problem = null
213  }
214
215  // A dragged or pasted path arrives quoted or with its spaces escaped.
216  function unquote(path: string): string {
217    const trimmed = path.trim()
218    const inner = /^(['"])(.*)\1$/.exec(trimmed)?.[2]
219
220    return inner ?? trimmed.replace(/\\(.)/g, '$1')
221  }
222
223  async function pathOf(ref: Ref, part: Attachment): Promise<string> {
224    const path = part.path ?? (await providerOf(ref)?.attachments?.fetch(part.handle))?.path
225    if (!path) {
226      throw new Error('that file is not on this machine')
227    }
228
229    return path
230  }
231
232  // Runs one act on a received file and reports how it went.
233  async function withFile(ref: Ref, part: Attachment, verb: string, what: (path: string) => Promise<string>): Promise<void> {
234    try {
235      done(await what(await pathOf(ref, part)))
236    } catch (error) {
237      view.problem = `Not ${verb}: ${reasonOf(error)}`
238    }
239    redraw()
240  }
241
242  // Runs one outgoing act and reports how it went.
243  async function act(what: (provider: Provider, conversation: string) => Promise<void>): Promise<void> {
244    const ref = view.selected
245    const provider = providerOf(ref)
246    if (!ref || !provider) {
247      return
248    }
249    try {
250      await what(provider, ref.conversation)
251      done('Sent.')
252      view.isAttaching = false
253      view.draft = ''
254    } catch (error) {
255      view.problem = `Not sent: ${reasonOf(error)}`
256    }
257    redraw()
258    accounts.find(one => one.spec.id === ref.service)?.poke()
259  }
260
261  return {
262    view,
263    inbox,
264    notifier,
265    accounts,
266    providerOf,
267    labelOf,
268    open,
269
270    async start(): Promise<void> {
271      await Promise.all(accounts.map(one => one.start()))
272    },
273
274    async stop(): Promise<void> {
275      for (const one of accounts) {
276        one.stop()
277      }
278      await notifier.release()
279    },
280
281    // The person opened the pane here: this session toasts from now on.
282    async attend(): Promise<void> {
283      await notifier.claim()
284      if (view.selected) {
285        inbox.markSeen(view.selected)
286      }
287      redraw()
288      for (const one of accounts) {
289        one.poke()
290      }
291    },
292
293    // One tab per visible service, behind an All tab when there are several.
294    tabs(): Tab[] {
295      const services = shown()
296      const ids = services.map(one => one.spec.id)
297      if (services.length < 2 ? view.tab !== ids[0] : view.tab !== ALL && !ids.includes(view.tab)) {
298        view.tab = services.length < 2 ? (ids[0] ?? ALL) : ALL
299      }
300      const tabs: Tab[] = services.map(one => ({
301        id: one.spec.id,
302        label: one.spec.label,
303        unread: inbox.unreadOf(one.spec.id),
304        isSelected: view.tab === one.spec.id,
305        health: one.health,
306      }))
307
308      return services.length < 2
309        ? tabs
310        : [{ id: ALL, label: 'All', unread: inbox.unreadOf(), isSelected: view.tab === ALL, health: { state: 'ready' } }, ...tabs]
311    },
312
313    selectTab(id: string): void {
314      view.tab = id
315      redraw()
316    },
317
318    // The conversations of the selected tab.
319    rows(): Entry[] {
320      return inbox.list(view.tab === ALL ? undefined : view.tab).slice(0, ROWS)
321    },
322
323    // One line for a conversation's row: its newest message held here, else
324    // what the service said when it listed it.
325    previewOf(entry: Entry): string {
326      const last = inbox.lastOf(entry.ref)
327      if (last) {
328        const text = textOf(last)
329
330        return last.sender.isMe ? `You: ${text}` : text
331      }
332
333      return entry.preview ?? ''
334    },
335
336    // Further back in the open conversation: more of what is held, and when
337    // that runs out, another page from the service.
338    async older(bodyRows: number): Promise<void> {
339      const ref = view.selected
340      const provider = providerOf(ref)
341      if (!ref || !provider) {
342        return
343      }
344      view.depth += Math.max(4, bodyRows - 2)
345      redraw()
346      const oldest = inbox.messages(ref)[0]
347      if (view.isAtStart || !oldest) {
348        return
349      }
350      try {
351        const page = await provider.history(ref.conversation, { before: oldest.id, limit: OLDER_PAGE })
352        const added = inbox.prependHistory(ref, page)
353        if (added === 0) {
354          view.isAtStart = true
355        }
356      } catch (error) {
357        view.problem = `${labelOf(ref.service)}: ${reasonOf(error)}`
358      }
359      redraw()
360    },
361
362    latest(): void {
363      view.depth = 0
364      redraw()
365    },
366
367    problems(): string[] {
368      return [...accounts.map(one => one.problem), view.problem].filter((one): one is string => one !== null)
369    },
370
371    back(): void {
372      view.selected = undefined
373      view.isAttaching = false
374      redraw()
375    },
376
377    // Attach mode offers the files this Claude session touched.
378    async toggleAttach(): Promise<void> {
379      view.isAttaching = !view.isAttaching
380      redraw()
381      if (view.isAttaching) {
382        view.offers = await deps.offers().catch(() => ({ files: [], lastReply: '' }))
383        redraw()
384      }
385    },
386
387    // Claude's last reply, into the reply box for the person to edit and send.
388    async draftLastReply(): Promise<void> {
389      const { lastReply } = await deps.offers().catch(() => ({ lastReply: '' }))
390      if (lastReply) {
391        view.draft = lastReply
392        view.isAttaching = false
393        view.problem = null
394      } else {
395        view.problem = 'Claude has not replied yet in this session'
396      }
397      redraw()
398    },
399
400    async send(text: string): Promise<void> {
401      const body = text.trim()
402      if (body) {
403        await act((provider, conversation) => provider.send(conversation, { text: body }))
404      }
405    },
406
407    async sendFile(path: string): Promise<void> {
408      const file = unquote(path)
409      if (file) {
410        await act(async (provider, conversation) => {
411          if (!provider.attachments) {
412            throw new Error('this service cannot send files')
413          }
414          await provider.attachments.send(conversation, file)
415        })
416      }
417    },
418
419    // Whether pictures draw at all, and how.
420    pictureMode: () => pictureMode,
421
422    // A picture's thumbnail once it is ready; asking starts it loading.
423    // `element` is the key the view draws it under, for the probe.
424    picture(ref: Ref, part: Attachment, element: string): Picture | undefined {
425      if (pictureMode === 'off') {
426        return undefined
427      }
428      const key = `${ref.service}\u0000${part.handle}`
429      if (!pictures.has(key)) {
430        if (pictures.size >= PICTURES) {
431          const [oldest] = pictures.keys()
432          if (oldest !== undefined) {
433            pictures.delete(oldest)
434          }
435        }
436        pictures.set(key, null)
437        void loadPicture(key, ref, part, element)
438      }
439
440      return pictures.get(key) ?? undefined
441    },
442
443    openFile(ref: Ref, part: Attachment): Promise<void> {
444      return withFile(ref, part, 'opened', async path => {
445        await deps.openPath(path)
446
447        return 'Opened.'
448      })
449    },
450
451    // The file's path, into the person's prompt box as a draft. The person
452    // sends it; only the path goes, never what the chat said.
453    fileToClaude(ref: Ref, part: Attachment): Promise<void> {
454      return withFile(ref, part, 'added', async path => {
455        if (!(await deps.toPrompt(`${/\s/.test(path) ? `"${path}"` : path} `))) {
456          throw new Error('the prompt box is not open')
457        }
458
459        return 'Added to your prompt.'
460      })
461    },
462
463    copyFile(ref: Ref, part: Attachment): Promise<void> {
464      return withFile(ref, part, 'copied', async path => {
465        if (!(await deps.copy(path))) {
466          throw new Error('the clipboard is not available here')
467        }
468
469        return 'Path copied.'
470      })
471    },
472
473    // Follows the latest notification, and comes back from it.
474    async jump(): Promise<void> {
475      const to = notifier.jump(view.selected)
476      if (to) {
477        await open(to)
478      }
479    },
480
481    async jumpBack(): Promise<void> {
482      const to = notifier.jumpBack()
483      if (to) {
484        await open(to)
485      }
486    },
487  }
488}
489
hooks/core/registry.ts 7 lines
1import type { ProviderSpec } from './contract'
2import { imessage } from '../providers/imessage'
3
4// Every chat service channel knows. Adding one is a folder under providers/
5// and a line here.
6export const services: readonly ProviderSpec[] = [imessage]
7
hooks/ui/pane.tsx 16 lines
1import type { Hub } from '../core/hub'
2import { ConversationView } from './conversation'
3import type { Elements } from './format'
4import { ListView } from './list'
5
6type Props = { bodyColumns: number; bodyRows: number; surface: string; now: number }
7
8// The pane is one of two views: the list, or the conversation picked from it.
9export function PaneView(hub: Hub, elements: Elements, props: Props) {
10  const ref = hub.view.selected
11
12  return ref
13    ? ConversationView(hub, elements, ref, props)
14    : ListView(hub, elements, { columns: Math.max(20, props.bodyColumns), now: props.now })
15}
16
hooks/ui/picture.ts 138 lines
1// Pictures in the pane. A thumbnail arrives as an uncompressed BMP (what
2// `sips` writes); it is drawn as real pixels where the terminal can, and as
3// half-block cells anywhere else. The same ideas as the browse pane's frames:
4// the terminal reads a PNG file itself where it draws pixels, cells are
5// averaged from the pixels they cover rather than sampled, and the encoded
6// cells of one picture at one size are kept, since a pane redraws often and
7// a picture seldom changes.
8
9export type Bitmap = { width: number; height: number; rgba: Uint8Array }
10
11// A picture ready to draw. `width` and `height` are the original's, for its
12// shape. Where the terminal draws pixels it is a PNG file the terminal reads
13// itself, so its pixels never sit in this mod's memory; elsewhere it is a
14// small bitmap to pack into cells.
15export type Picture = {
16  width: number
17  height: number
18  source: { file: string; generation: number } | { bitmap: Bitmap }
19}
20
21// Decodes an uncompressed 24- or 32-bit BMP, stored top-down or bottom-up.
22export function decodeBmp(bytes: Uint8Array): Bitmap | undefined {
23  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
24  if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) {
25    return undefined
26  }
27  const start = view.getUint32(10, true)
28  const width = view.getInt32(18, true)
29  const signed = view.getInt32(22, true)
30  const height = Math.abs(signed)
31  const depth = view.getUint16(28, true) / 8
32  const stride = Math.ceil((width * depth) / 4) * 4
33  if (width <= 0 || height <= 0 || (depth !== 3 && depth !== 4) || start + stride * height > bytes.length) {
34    return undefined
35  }
36
37  const rgba = new Uint8Array(width * height * 4)
38  for (let y = 0; y < height; y += 1) {
39    // A positive height stores the bottom row first.
40    const row = start + (signed > 0 ? height - 1 - y : y) * stride
41    for (let x = 0; x < width; x += 1) {
42      const from = row + x * depth
43      const to = (y * width + x) * 4
44      rgba[to] = bytes[from + 2] ?? 0
45      rgba[to + 1] = bytes[from + 1] ?? 0
46      rgba[to + 2] = bytes[from] ?? 0
47      rgba[to + 3] = 0xff
48    }
49  }
50
51  return { width, height, rgba }
52}
53
54// The box of character cells a picture fills, keeping its shape: a cell is
55// about twice as tall as it is wide.
56export function fit(bitmap: { width: number; height: number }, maxColumns: number, maxRows: number): { columns: number; rows: number } {
57  const rows = Math.max(1, Math.min(maxRows, Math.round((maxColumns * bitmap.height) / bitmap.width / 2)))
58  const columns = Math.max(1, Math.min(maxColumns, Math.round((rows * 2 * bitmap.width) / bitmap.height)))
59
60  return { columns, rows }
61}
62
63// The average color of the pixels a half-cell covers: a box filter, so a
64// photo shrunk to a few dozen cells keeps its tones instead of flickering
65// between the pixels that happen to land on a sample point.
66function average(bitmap: Bitmap, x0: number, x1: number, y0: number, y1: number): number {
67  const left = Math.min(bitmap.width - 1, Math.floor(x0))
68  const right = Math.max(left + 1, Math.min(bitmap.width, Math.ceil(x1)))
69  const top = Math.min(bitmap.height - 1, Math.floor(y0))
70  const bottom = Math.max(top + 1, Math.min(bitmap.height, Math.ceil(y1)))
71  let r = 0
72  let g = 0
73  let b = 0
74  let n = 0
75  for (let y = top; y < bottom; y += 1) {
76    let i = (y * bitmap.width + left) * 4
77    for (let x = left; x < right; x += 1) {
78      r += bitmap.rgba[i] ?? 0
79      g += bitmap.rgba[i + 1] ?? 0
80      b += bitmap.rgba[i + 2] ?? 0
81      n += 1
82      i += 4
83    }
84  }
85  if (n === 0) {
86    return 0
87  }
88
89  return (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
90}
91
92// Packs a picture into Raster cells: one upper-half block per cell, the top
93// half's average as its foreground and the bottom half's as its background.
94export function toCells(bitmap: Bitmap, columns: number, rows: number): string {
95  const words = new Uint32Array(columns * rows * 3)
96  const cellWidth = bitmap.width / columns
97  const halfHeight = bitmap.height / (rows * 2)
98  for (let row = 0; row < rows; row += 1) {
99    for (let col = 0; col < columns; col += 1) {
100      const i = (row * columns + col) * 3
101      const x0 = col * cellWidth
102      const x1 = x0 + cellWidth
103      words[i] = 0x2580
104      words[i + 1] = average(bitmap, x0, x1, row * 2 * halfHeight, (row * 2 + 1) * halfHeight)
105      words[i + 2] = average(bitmap, x0, x1, (row * 2 + 1) * halfHeight, (row * 2 + 2) * halfHeight)
106    }
107  }
108
109  return new Uint8Array(words.buffer).toBase64()
110}
111
112// The cells of a bitmap at one size, encoded once: a redraw at the same size
113// (every message, every poll) reuses them; a resize makes the new size.
114const encoded = new WeakMap<Bitmap, Map<string, string>>()
115
116export function cellsOf(bitmap: Bitmap, columns: number, rows: number): string {
117  let sizes = encoded.get(bitmap)
118  if (sizes === undefined) {
119    sizes = new Map()
120    encoded.set(bitmap, sizes)
121  }
122  const size = `${columns}x${rows}`
123  let cells = sizes.get(size)
124  if (cells === undefined) {
125    cells = toCells(bitmap, columns, rows)
126    // A pane that changed width a few times keeps a few; not every size ever drawn.
127    if (sizes.size >= 4) {
128      const [oldest] = sizes.keys()
129      if (oldest !== undefined) {
130        sizes.delete(oldest)
131      }
132    }
133    sizes.set(size, cells)
134  }
135
136  return cells
137}
138
hooks/core/contract.ts 161 lines
1import type { ProcessRunResult } from 'claude-code'
2
3// What every chat service plugs in as, and what the pane draws from. A
4// provider turns one service into conversations, messages and updates; the
5// core and the UI never know which service it is.
6
7export type Ref = { service: string; conversation: string }
8
9// `at` is the conversation's latest activity, milliseconds since the epoch.
10// `preview` is its latest message as one line, when the service has it cheaply;
11// `members` how many people are in it, when known.
12export type Conversation = { id: string; name: string; at: number; preview?: string; members?: number }
13
14export type Person = { id: string; name: string; isMe: boolean }
15
16// `path` is set when the bytes are already a file on this machine. `handle` is
17// opaque to the core: only the provider that issued it can resolve it.
18export type Attachment = { name: string; mime: string; bytes?: number; handle: string; path?: string }
19
20export type Part =
21  | { kind: 'text'; text: string }
22  | ({ kind: 'file' } & Attachment)
23  | ({ kind: 'image' } & Attachment)
24  | { kind: 'link'; url: string; title?: string }
25
26export type Reaction = { emoji: string; count: number; isMine: boolean }
27
28// `id` orders messages within one service. `isAlert` says it deserves a toast
29// when it arrives unseen.
30export type Message = {
31  id: string
32  conversation: string
33  at: number
34  sender: Person
35  parts: Part[]
36  replyTo?: string
37  thread?: string
38  editedAt?: number
39  isDeleted?: boolean
40  reactions?: Reaction[]
41  mentionsMe?: boolean
42  isAlert: boolean
43}
44
45export type Update =
46  | { kind: 'message'; message: Message }
47  | { kind: 'edit'; message: Message }
48  | { kind: 'delete'; conversation: string; id: string }
49  | { kind: 'reaction'; conversation: string; id: string; reactions: Reaction[] }
50  | { kind: 'read'; conversation: string; upTo: string }
51  | { kind: 'typing'; conversation: string; who: Person; isTyping: boolean }
52  | { kind: 'presence'; who: Person; isOnline: boolean }
53
54export type Draft = { text: string; replyTo?: string }
55
56// News by asking: with no cursor yet, only the cursor, so nothing already
57// there counts as new.
58export type Polled = {
59  kind: 'polled'
60  openMs: number
61  closedMs: number
62  since: (cursor: string | undefined) => Promise<{ updates: Update[]; cursor: string }>
63}
64
65// News as it happens, from a streamed helper.
66export type Live = { kind: 'live'; updates: () => AsyncIterable<Update> }
67
68export type Provider = {
69  // Most recent first.
70  conversations: () => Promise<Conversation[]>
71  // One page of a conversation, oldest first.
72  history: (conversation: string, page?: { before?: string; limit?: number }) => Promise<Message[]>
73  feed: Polled | Live
74  send: (conversation: string, draft: Draft) => Promise<void>
75
76  // Optional capabilities: present means supported.
77  attachments?: {
78    send: (conversation: string, path: string) => Promise<void>
79    fetch: (handle: string) => Promise<{ path: string }>
80  }
81  threads?: { replies: (conversation: string, thread: string) => Promise<Message[]> }
82  reactions?: { set: (conversation: string, id: string, emoji: string, isOn: boolean) => Promise<void> }
83  edits?: {
84    edit: (conversation: string, id: string, text: string) => Promise<void>
85    remove: (conversation: string, id: string) => Promise<void>
86  }
87  readState?: { markRead: (conversation: string, upTo: string) => Promise<void> }
88  typing?: { set: (conversation: string, isTyping: boolean) => Promise<void> }
89  members?: { of: (conversation: string) => Promise<Person[]> }
90  search?: { find: (text: string) => Promise<Message[]> }
91}
92
93export type Setup = { state: 'setup'; summary: string; steps: string[] }
94
95export type Health =
96  | { state: 'ready' }
97  // Not turned on: shown nowhere.
98  | { state: 'off' }
99  // Turned on, and waiting on the person.
100  | Setup
101  // Cannot work here.
102  | { state: 'unavailable'; reason: string }
103
104// Thrown by a provider that finds, mid-flight, it needs the person again.
105export class NeedsSetup extends Error {
106  constructor(readonly health: Setup) {
107    super(health.summary)
108  }
109}
110
111export type Run = (argv: readonly string[]) => Promise<ProcessRunResult>
112export type Fetch = (
113  url: string,
114  init?: { method?: string; headers?: Record<string, string>; body?: string },
115) => Promise<{ status: number; ok: boolean; headers: Record<string, string>; text: string }>
116export type Spawn = (argv: readonly string[]) => AsyncIterable<{ stream: 'stdout' | 'stderr'; text: string }>
117
118// What a provider may reach, already fenced to its `reach`.
119export type Tools = { run: Run; fetch: Fetch; spawn: Spawn }
120
121// `app` is the name macOS lists the terminal under, or '' when unknown.
122// `options` are the plugin's userConfig values; a provider reads only its own.
123export type Settings = {
124  home: string
125  app: string
126  surface: string
127  options: Readonly<Record<string, unknown>>
128}
129
130export type Reach = {
131  // Executables `run` may start, by absolute path.
132  commands?: readonly string[]
133  // Hosts `fetch` may call, over https only.
134  hosts?: readonly string[]
135  // The argv of the one streamed process `spawn` may start.
136  helper?: readonly string[]
137}
138
139export type ProviderSpec = {
140  id: string
141  label: string
142  reach: Reach
143  // Whether the service is set up, and if not, what the person should do.
144  check: (tools: Tools, settings: Settings) => Promise<Health>
145  connect: (tools: Tools, settings: Settings) => Provider
146}
147
148export function reasonOf(error: unknown): string {
149  return error instanceof Error ? error.message : String(error)
150}
151
152// A message as one line of plain text.
153export function textOf(message: Message): string {
154  const text = message.parts
155    .map(part => (part.kind === 'text' ? part.text : part.kind === 'link' ? (part.title ?? part.url) : `[${part.name}]`))
156    .join(' ')
157    .trim()
158
159  return text || '[message]'
160}
161
hooks/core/account.ts 150 lines
1import { NeedsSetup, reasonOf } from './contract'
2import type { Conversation, Health, Provider, ProviderSpec, Settings, Tools, Update } from './contract'
3import { fence } from './fence'
4
5// One running service: checked, connected, and fed. Each account keeps its own
6// loop and its own backoff, so a service in trouble never slows another.
7
8const MAX_BACKOFF_MS = 300_000
9const LIVE_RETRY_MS = 5_000
10
11export type Clock = { now: () => Promise<number>; after: (ms: number, fn: () => void) => { cancel: () => void } }
12
13export type AccountDeps = {
14  // Unfenced: the account fences them to the spec's reach.
15  tools: Tools
16  settings: Settings
17  clock: Clock
18  isShown: () => Promise<boolean>
19}
20
21export type Sink = {
22  conversations: (list: Conversation[]) => void
23  updates: (list: Update[]) => Promise<void>
24  changed: () => void
25}
26
27export type Account = {
28  spec: ProviderSpec
29  health: Health
30  problem: string | null
31  provider?: Provider
32  start: () => Promise<void>
33  // Look for news now: the person is watching.
34  poke: () => void
35  stop: () => void
36}
37
38export function createAccount(spec: ProviderSpec, deps: AccountDeps, sink: Sink): Account {
39  const tools = fence(spec.reach, deps.tools)
40  let timer: { cancel: () => void } | undefined
41  let cursor: string | undefined
42  let isBusy = false
43  let isStopped = false
44  let isListed = false
45  let backoffMs = 0
46
47  function recovered(): void {
48    backoffMs = 0
49    account.problem = null
50    if (account.health.state === 'setup') {
51      account.health = { state: 'ready' }
52    }
53  }
54
55  async function deliver(provider: Provider, updates: Update[]): Promise<void> {
56    await sink.updates(updates)
57    if (updates.some(one => one.kind === 'message')) {
58      // A new message moves its conversation to the top, or brings a new one.
59      sink.conversations(await provider.conversations())
60    }
61  }
62
63  // One pass at a time, each scheduling the next: fast while the pane shows,
64  // slow behind it, backing off on errors.
65  async function pass(): Promise<void> {
66    const provider = account.provider
67    if (isBusy || isStopped || !provider) {
68      return
69    }
70    isBusy = true
71    timer?.cancel()
72    const feed = provider.feed
73    let nextMs: number
74    try {
75      if (!isListed) {
76        sink.conversations(await provider.conversations())
77        isListed = true
78      }
79      if (feed.kind === 'polled') {
80        const news = await feed.since(cursor)
81        cursor = news.cursor
82        await deliver(provider, news.updates)
83        recovered()
84        nextMs = (await deps.isShown()) ? feed.openMs : feed.closedMs
85      } else {
86        for await (const update of feed.updates()) {
87          if (isStopped) {
88            break
89          }
90          recovered()
91          await deliver(provider, [update])
92          sink.changed()
93        }
94        throw new Error('the connection closed')
95      }
96    } catch (error) {
97      const floor = feed.kind === 'polled' ? feed.closedMs : LIVE_RETRY_MS
98      backoffMs = Math.min(Math.max(backoffMs * 2, floor), MAX_BACKOFF_MS)
99      nextMs = backoffMs
100      if (error instanceof NeedsSetup) {
101        account.health = error.health
102        account.problem = null
103      } else {
104        account.problem = `${spec.label}: ${reasonOf(error)}`
105      }
106    } finally {
107      isBusy = false
108    }
109    sink.changed()
110    if (!isStopped) {
111      timer = deps.clock.after(nextMs, () => void pass())
112    }
113  }
114
115  const account: Account = {
116    spec,
117    health: { state: 'off' },
118    problem: null,
119
120    async start() {
121      try {
122        account.health = await spec.check(tools, deps.settings)
123      } catch (error) {
124        account.health = { state: 'unavailable', reason: reasonOf(error) }
125      }
126      sink.changed()
127      if (account.health.state === 'ready' && !isStopped) {
128        account.provider = spec.connect(tools, deps.settings)
129        await pass()
130      }
131    },
132
133    poke() {
134      if (account.provider) {
135        void pass()
136      } else if (account.health.state === 'setup') {
137        // The person may have just done what was asked.
138        void account.start()
139      }
140    },
141
142    stop() {
143      isStopped = true
144      timer?.cancel()
145    },
146  }
147
148  return account
149}
150
hooks/core/inbox.ts 175 lines
1import type { Conversation, Message, Part, Ref, Update } from './contract'
2
3// Conversations, messages and unread counts across every service: the one
4// store the pane reads. Messages live here, in module memory, and nowhere else.
5
6const KEEP = 200
7
8export type Entry = Conversation & { ref: Ref }
9
10export type Inbox = ReturnType<typeof createInbox>
11
12export function keyOf(ref: Ref): string {
13  return `${ref.service}\u0000${ref.conversation}`
14}
15
16// Text from other people reaches the screen with its control characters out,
17// so a message cannot move the cursor or restyle the terminal.
18export function strip(text: string): string {
19  return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
20}
21
22function cleanPart(part: Part): Part {
23  switch (part.kind) {
24    case 'text':
25      return { ...part, text: strip(part.text) }
26    case 'link':
27      return { ...part, url: strip(part.url), title: part.title === undefined ? undefined : strip(part.title) }
28    default:
29      return { ...part, name: strip(part.name) }
30  }
31}
32
33function clean(message: Message): Message {
34  return {
35    ...message,
36    sender: { ...message.sender, name: strip(message.sender.name) },
37    parts: message.parts.map(cleanPart),
38  }
39}
40
41export function createInbox() {
42  const conversations = new Map<string, Conversation[]>()
43  const messages = new Map<string, Message[]>()
44  const unread = new Map<string, number>()
45
46  function change(ref: Ref, id: string, edit: (old: Message) => Message): void {
47    const list = messages.get(keyOf(ref))
48    if (list) {
49      messages.set(keyOf(ref), list.map(one => (one.id === id ? edit(one) : one)))
50    }
51  }
52
53  return {
54    setConversations(service: string, list: Conversation[]): void {
55      conversations.set(
56        service,
57        list.map(one => ({ ...one, name: strip(one.name), preview: one.preview === undefined ? undefined : strip(one.preview) })),
58      )
59    },
60
61    // The conversation's own record, when the service listed it.
62    entryOf(ref: Ref): Conversation | undefined {
63      return conversations.get(ref.service)?.find(one => one.id === ref.conversation)
64    },
65
66    // One service's conversations, or with none named every service's, most
67    // recent first.
68    list(service?: string): Entry[] {
69      const services = service === undefined ? [...conversations.keys()] : [service]
70
71      return services
72        .flatMap(id => (conversations.get(id) ?? []).map(one => ({ ...one, ref: { service: id, conversation: one.id } })))
73        .sort((a, b) => b.at - a.at)
74    },
75
76    nameOf(ref: Ref): string {
77      return conversations.get(ref.service)?.find(one => one.id === ref.conversation)?.name ?? ref.conversation
78    },
79
80    isLoaded(ref: Ref): boolean {
81      return messages.has(keyOf(ref))
82    },
83
84    setHistory(ref: Ref, list: Message[]): void {
85      messages.set(keyOf(ref), list.map(clean).slice(-KEEP))
86    },
87
88    // Older messages, fetched on request, in front of what is held; the
89    // KEEP limit gives way, since the person asked to see further back.
90    prependHistory(ref: Ref, list: Message[]): number {
91      const held = messages.get(keyOf(ref)) ?? []
92      const fresh = list.map(clean).filter(one => !held.some(other => other.id === one.id))
93      messages.set(keyOf(ref), [...fresh, ...held])
94
95      return fresh.length
96    },
97
98    messages(ref: Ref): Message[] {
99      return messages.get(keyOf(ref)) ?? []
100    },
101
102    // The newest message held for a conversation, for its row's preview.
103    lastOf(ref: Ref): Message | undefined {
104      return messages.get(keyOf(ref))?.at(-1)
105    },
106
107    // Takes one update in. Answers the message when it is one not seen before,
108    // so the caller can count and announce it.
109    apply(service: string, update: Update): Message | undefined {
110      switch (update.kind) {
111        case 'message': {
112          const message = clean(update.message)
113          const key = keyOf({ service, conversation: message.conversation })
114          const list = messages.get(key)
115          if (list?.some(one => one.id === message.id)) {
116            return undefined
117          }
118          if (list) {
119            messages.set(key, [...list, message].slice(-KEEP))
120          }
121
122          return message
123        }
124        case 'edit': {
125          const message = clean(update.message)
126          change({ service, conversation: message.conversation }, message.id, old => ({
127            ...message,
128            reactions: message.reactions ?? old.reactions,
129          }))
130
131          return undefined
132        }
133        case 'delete':
134          change({ service, conversation: update.conversation }, update.id, old => ({ ...old, isDeleted: true, parts: [] }))
135
136          return undefined
137        case 'reaction':
138          change({ service, conversation: update.conversation }, update.id, old => ({ ...old, reactions: update.reactions }))
139
140          return undefined
141        case 'read':
142          // Read somewhere else: nothing left to announce here.
143          unread.delete(keyOf({ service, conversation: update.conversation }))
144
145          return undefined
146        default:
147          return undefined
148      }
149    },
150
151    bump(ref: Ref): void {
152      unread.set(keyOf(ref), (unread.get(keyOf(ref)) ?? 0) + 1)
153    },
154
155    markSeen(ref: Ref): void {
156      unread.delete(keyOf(ref))
157    },
158
159    unread(ref: Ref): number {
160      return unread.get(keyOf(ref)) ?? 0
161    },
162
163    unreadOf(service?: string): number {
164      let total = 0
165      for (const [key, count] of unread) {
166        if (service === undefined || key.startsWith(`${service}\u0000`)) {
167          total += count
168        }
169      }
170
171      return total
172    },
173  }
174}
175
hooks/core/notify.ts 153 lines
1import { textOf } from './contract'
2import type { Message, Ref } from './contract'
3import { keyOf } from './inbox'
4
5// What deserves the person's attention, without flooding them. Every Claude
6// session on the machine runs this mod, so the sessions share one lease in the
7// store and only its holder toasts; a burst folds into one toast; and a
8// conversation toasts at most once in a while.
9
10const LEASE = 'notifier'
11const STALE_MS = 30_000
12const QUIET_MS = 10_000
13const MAX_TOASTS = 3
14
15type Lease = { session: string; at: number }
16
17export type NotifierDeps = {
18  toast: (text: string) => void
19  status: (text: string | undefined) => void
20  store: {
21    get: (key: string) => Promise<unknown>
22    set: (key: string, value: unknown) => Promise<void>
23    delete: (key: string) => Promise<void>
24  }
25  session: string
26  now: () => Promise<number>
27  // The services that are ready, as { id, label }.
28  services: () => { id: string; label: string }[]
29  nameOf: (ref: Ref) => string
30  unreadOf: (service: string) => number
31}
32
33export type Notifier = ReturnType<typeof createNotifier>
34
35export function createNotifier(deps: NotifierDeps) {
36  const lastToast = new Map<string, number>()
37  let target: Ref | undefined
38  let previous: Ref | undefined
39
40  async function held(): Promise<Lease | undefined> {
41    const lease = (await deps.store.get(LEASE)) as Lease | undefined
42
43    return lease && typeof lease.session === 'string' ? lease : undefined
44  }
45
46  // Whether this session is the one that toasts, taking or renewing the lease
47  // when it may. The store has no compare-and-set, so it reads back what it
48  // wrote: two claimants settle on whoever wrote last.
49  async function holds(): Promise<boolean> {
50    const lease = await held()
51    const now = await deps.now()
52    if (lease && lease.session !== deps.session && now - lease.at < STALE_MS) {
53      return false
54    }
55    await deps.store.set(LEASE, { session: deps.session, at: now })
56
57    return (await held())?.session === deps.session
58  }
59
60  function showStatus(): void {
61    const counts = deps
62      .services()
63      .map(one => ({ label: one.label, count: deps.unreadOf(one.id) }))
64      .filter(one => one.count > 0)
65    deps.status(counts.length > 0 ? `${counts.map(one => `${one.label} ${one.count}`).join(' · ')} new` : undefined)
66  }
67
68  return {
69    showStatus,
70
71    // The person turned to this session: it takes the lease outright.
72    async claim(): Promise<void> {
73      await deps.store.set(LEASE, { session: deps.session, at: await deps.now() })
74    },
75
76    async release(): Promise<void> {
77      if ((await held())?.session === deps.session) {
78        await deps.store.delete(LEASE)
79      }
80    },
81
82    // One feed pass of one service: `fresh` are its new messages from other
83    // people that the person is not looking at.
84    async pass(service: string, fresh: Message[]): Promise<void> {
85      showStatus()
86      const isHolder = await holds()
87      const now = await deps.now()
88      const groups = new Map<string, Message[]>()
89      for (const one of fresh) {
90        const key = keyOf({ service, conversation: one.conversation })
91        if (one.isAlert && now - (lastToast.get(key) ?? -QUIET_MS) >= QUIET_MS) {
92          groups.set(key, [...(groups.get(key) ?? []), one])
93        }
94      }
95      if (groups.size === 0) {
96        return
97      }
98      for (const key of groups.keys()) {
99        lastToast.set(key, now)
100      }
101      const last = [...groups.values()].at(-1)?.at(-1)
102      if (last) {
103        target = { service, conversation: last.conversation }
104      }
105      if (!isHolder) {
106        return
107      }
108
109      const services = deps.services()
110      const prefix = services.length > 1 ? `${services.find(one => one.id === service)?.label ?? service} · ` : ''
111      if (groups.size > MAX_TOASTS) {
112        const total = [...groups.values()].reduce((sum, list) => sum + list.length, 0)
113        deps.toast(`${prefix}${total} new messages in ${groups.size} chats`)
114
115        return
116      }
117      for (const list of groups.values()) {
118        const [first] = list
119        if (!first) {
120          continue
121        }
122        const name = deps.nameOf({ service, conversation: first.conversation })
123        if (list.length > 1) {
124          deps.toast(`${prefix}${name}: ${list.length} new messages`)
125        } else {
126          const who = name === first.sender.name ? name : `${name} · ${first.sender.name}`
127          deps.toast(`${prefix}${who}: ${textOf(first).slice(0, 80)}`)
128        }
129      }
130    },
131
132    // Where the latest notification leads, and where the person was before
133    // they followed one.
134    target: () => target,
135    previous: () => previous,
136    jump(from: Ref | undefined): Ref | undefined {
137      const to = target
138      if (to) {
139        previous = from
140        target = undefined
141      }
142
143      return to
144    },
145    jumpBack(): Ref | undefined {
146      const to = previous
147      previous = undefined
148
149      return to
150    },
151  }
152}
153
hooks/providers/imessage/index.ts 394 lines
1import { NeedsSetup } from '../../core/contract'
2import type { Conversation, Message, Part, Person, ProviderSpec, Reaction, Run, Setup, Update } from '../../core/contract'
3import { Q } from './queries'
4
5// iMessage on macOS 13 or later: reads ~/Library/Messages/chat.db through
6// sqlite3, read-only, and sends through Messages with osascript. Needs Full
7// Disk Access for the app running Claude Code, and asks once to control
8// Messages on the first send. The terminal only: a mod cannot start a process
9// in the desktop app. Messages offers no way to send a tapback, edit, or mark
10// read, so those are read here and never written.
11
12const SQLITE = '/usr/bin/sqlite3'
13const OSASCRIPT = '/usr/bin/osascript'
14// Apple's epoch, 2001-01-01, in Unix milliseconds.
15const APPLE_EPOCH_MS = 978_307_200_000
16const PAGE = 50
17const SINCE_LIMIT = 200
18const CONVERSATIONS = 20
19// Tapback kinds 0 to 5, as Messages numbers them.
20const TAPBACKS = ['❤️', '👍', '👎', '😂', '‼️', '❓']
21
22type Row = {
23  id: number
24  guid: string
25  conversation: number
26  date: number
27  fromMe: number
28  handle: string | null
29  text: string | null
30  body: string | null
31  replyTo: string | null
32  edited: string | null
33  retracted: string | null
34  files: string | null
35}
36
37type FileRow = { id: number; name: string | null; mime: string | null; bytes: number | null; path: string | null }
38
39export type TapRow = { id: number; conversation: number; target: string; type: number; fromMe: number; who: number | null }
40
41function needsAccess(app: string): Setup {
42  return {
43    state: 'setup',
44    summary: 'iMessage cannot read your Messages yet',
45    steps: [
46      'Open System Settings › Privacy & Security › Full Disk Access',
47      `Turn on ${app || 'the terminal app you run Claude Code in'} (add it with + if it is not listed)`,
48      `Quit and reopen ${app || 'that app'}`,
49    ],
50  }
51}
52
53// Apple's dates are nanoseconds since 2001 on current macOS, seconds on old ones.
54function toMs(date: number): number {
55  return (date > 1e14 ? date / 1e6 : date * 1000) + APPLE_EPOCH_MS
56}
57
58// The later of two stamps held as digit strings.
59function later(a: string, b: string): string {
60  return a.length === b.length ? (a > b ? a : b) : a.length > b.length ? a : b
61}
62
63function isSet(stamp: string | null): stamp is string {
64  return stamp !== null && stamp !== '0'
65}
66
67// Each person holds at most one tapback on a message: a new one replaces it,
68// and taking it back clears it. Keyed by the target message's guid.
69export function foldTaps(rows: readonly TapRow[]): Map<string, Reaction[]> {
70  const votes = new Map<string, Map<string, { kind: number; isMine: boolean }>>()
71  for (const row of rows) {
72    const target = row.target.slice(-36)
73    const voter = row.fromMe ? 'me' : String(row.who)
74    const kind = row.type % 1000
75    const mine = votes.get(target) ?? new Map<string, { kind: number; isMine: boolean }>()
76    votes.set(target, mine)
77    if (row.type >= 3000) {
78      if (mine.get(voter)?.kind === kind) {
79        mine.delete(voter)
80      }
81    } else {
82      mine.set(voter, { kind, isMine: row.fromMe === 1 })
83    }
84  }
85
86  const folded = new Map<string, Reaction[]>()
87  for (const [target, mine] of votes) {
88    const reactions: Reaction[] = []
89    for (const { kind, isMine } of mine.values()) {
90      const emoji = TAPBACKS[kind]
91      if (!emoji) {
92        continue
93      }
94      const known = reactions.find(one => one.emoji === emoji)
95      if (known) {
96        known.count += 1
97        known.isMine ||= isMine
98      } else {
99        reactions.push({ emoji, count: 1, isMine })
100      }
101    }
102    folded.set(target, reactions)
103  }
104
105  return folded
106}
107
108// Most messages since macOS 13 keep their text only in attributedBody, an
109// archived NSAttributedString ("streamtyped"): the string follows the
110// NSString class name and a '+' marker, behind a length byte (0x81 and 0x82
111// say a 2- or 3-byte little-endian length follows).
112export function attributedText(hex: string): string | null {
113  const bytes = new Uint8Array(hex.length / 2)
114  for (let i = 0; i < bytes.length; i += 1) {
115    bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
116  }
117  const mark = new TextEncoder().encode('NSString')
118  let at = -1
119  search: for (let i = 0; i + mark.length <= bytes.length; i += 1) {
120    for (let j = 0; j < mark.length; j += 1) {
121      if (bytes[i + j] !== mark[j]) {
122        continue search
123      }
124    }
125    at = i
126    break
127  }
128  if (at < 0) {
129    return null
130  }
131  let i = bytes.indexOf(0x2b, at + mark.length)
132  if (i < 0) {
133    return null
134  }
135  i += 1
136  let length = bytes[i] ?? 0
137  i += 1
138  if (length === 0x81) {
139    length = (bytes[i] ?? 0) | ((bytes[i + 1] ?? 0) << 8)
140    i += 2
141  } else if (length === 0x82) {
142    length = (bytes[i] ?? 0) | ((bytes[i + 1] ?? 0) << 8) | ((bytes[i + 2] ?? 0) << 16)
143    i += 3
144  }
145  if (i + length > bytes.length) {
146    return null
147  }
148
149  return new TextDecoder().decode(bytes.subarray(i, i + length))
150}
151
152export const imessage: ProviderSpec = {
153  id: 'imessage',
154  label: 'iMessage',
155  reach: { commands: [SQLITE, OSASCRIPT] },
156
157  async check({ run }, { home, app, surface, options }) {
158    if (options.imessage === false) {
159      return { state: 'off' }
160    }
161    if (surface !== 'terminal') {
162      return { state: 'unavailable', reason: 'iMessage needs Claude Code in a terminal' }
163    }
164    try {
165      await query(run, `${home}/Library/Messages/chat.db`, app, Q.top)
166    } catch (error) {
167      return error instanceof NeedsSetup ? error.health : { state: 'unavailable', reason: 'iMessage needs macOS 13 or later' }
168    }
169
170    return { state: 'ready' }
171  },
172
173  connect({ run }, { home, app }) {
174    const database = `${home}/Library/Messages/chat.db`
175    const chatGuids = new Map<string, string>()
176    // Message guid to row id, for every message seen: replies and tapbacks
177    // name their target by guid.
178    const ids = new Map<string, string>()
179    const ask = <T>(sql: string) => query<T>(run, database, app, sql)
180    const expand = (path: string) => (path.startsWith('~') ? `${home}${path.slice(1)}` : path)
181
182    function toParts(row: Row): Part[] {
183      // U+FFFC stands where an attachment sits in the text.
184      const text = (row.text ?? (row.body ? attributedText(row.body) : null) ?? '').replace(/\uFFFC/g, '').trim()
185      const files = (JSON.parse(row.files ?? '[]') as FileRow[]).map((file): Part => {
186        const path = file.path ? expand(file.path) : undefined
187        const mime = file.mime ?? 'application/octet-stream'
188
189        return {
190          kind: mime.startsWith('image/') ? 'image' : 'file',
191          name: file.name ?? path?.split('/').at(-1) ?? 'attachment',
192          mime,
193          bytes: file.bytes ?? undefined,
194          handle: String(file.id),
195          path,
196        }
197      })
198
199      return [...(text ? [{ kind: 'text' as const, text }] : []), ...files]
200    }
201
202    function toMessages(rows: readonly Row[], taps?: Map<string, Reaction[]>): Message[] {
203      for (const row of rows) {
204        ids.set(row.guid, String(row.id))
205      }
206
207      return rows.map(row => {
208        const isMe = row.fromMe === 1
209        const who = row.handle ?? 'unknown'
210
211        return {
212          id: String(row.id),
213          conversation: String(row.conversation),
214          at: toMs(row.date),
215          sender: isMe ? { id: 'me', name: 'me', isMe } : { id: who, name: who, isMe },
216          parts: isSet(row.retracted) ? [] : toParts(row),
217          replyTo: row.replyTo ? ids.get(row.replyTo) : undefined,
218          editedAt: isSet(row.edited) ? toMs(Number(row.edited)) : undefined,
219          isDeleted: isSet(row.retracted) || undefined,
220          reactions: taps?.get(row.guid),
221          isAlert: !isMe,
222        }
223      })
224    }
225
226    async function chatGuid(conversation: string): Promise<string> {
227      const guid = chatGuids.get(conversation) ?? (await ask<{ guid: string }>(Q.chatGuid(conversation)))[0]?.guid
228      if (!guid) {
229        throw new Error('that conversation is gone')
230      }
231
232      return guid
233    }
234
235    // What changed on messages already delivered: tapbacks set or taken back.
236    async function tapUpdates(taps: readonly TapRow[]): Promise<Update[]> {
237      if (taps.length === 0) {
238        return []
239      }
240      const chats = [...new Set(taps.map(one => String(one.conversation)))]
241      const folded = foldTaps(await ask<TapRow>(Q.tapsIn(chats)))
242      const targets = new Map(taps.map(one => [one.target.slice(-36), String(one.conversation)]))
243      const unknown = [...targets.keys()].filter(one => !ids.has(one))
244      if (unknown.length > 0) {
245        for (const row of await ask<{ id: number; guid: string }>(Q.ids(unknown))) {
246          ids.set(row.guid, String(row.id))
247        }
248      }
249
250      return [...targets].flatMap(([target, conversation]): Update[] => {
251        const id = ids.get(target)
252
253        return id ? [{ kind: 'reaction', conversation, id, reactions: folded.get(target) ?? [] }] : []
254      })
255    }
256
257    return {
258      async conversations() {
259        const rows = await ask<{
260          id: number; guid: string; name: string | null; people: string | null; ident: string; date: number
261          last: string | null; lastBody: string | null; lastFromMe: number | null; members: number | null
262        }>(Q.conversations(CONVERSATIONS))
263
264        return rows.map((row): Conversation => {
265          chatGuids.set(String(row.id), row.guid)
266          const text = (row.last ?? (row.lastBody ? attributedText(row.lastBody) : null) ?? '').replace(/\uFFFC/g, '').trim()
267          const preview = text ? (row.lastFromMe === 1 ? `You: ${text}` : text) : undefined
268
269          return {
270            id: String(row.id),
271            name: row.name || row.people || row.ident,
272            at: toMs(row.date),
273            preview,
274            members: row.members === null ? undefined : row.members + 1,
275          }
276        })
277      },
278
279      async history(conversation, page) {
280        const rows = await ask<Row>(Q.history(conversation, page?.before, page?.limit ?? PAGE))
281        const taps = foldTaps(await ask<TapRow>(Q.tapsIn([conversation])))
282
283        return toMessages(rows.reverse(), taps)
284      },
285
286      feed: {
287        kind: 'polled',
288        openMs: 3_000,
289        closedMs: 10_000,
290        // The cursor is "<newest row id>.<newest edit or unsend stamp>".
291        async since(cursor) {
292          if (cursor === undefined) {
293            const [top] = await ask<{ id: number | null; stamp: string | null }>(Q.top)
294
295            return { updates: [], cursor: `${top?.id ?? 0}.${top?.stamp ?? '0'}` }
296          }
297          const [rowid = '0', stamp = '0'] = cursor.split('.')
298          const fresh = await ask<Row>(Q.after(rowid, SINCE_LIMIT))
299          const changed = (await ask<Row>(Q.changed(stamp, SINCE_LIMIT))).filter(row => !fresh.some(one => one.id === row.id))
300          const taps = await ask<TapRow>(Q.tapsAfter(rowid))
301
302          const updates: Update[] = [
303            ...toMessages(fresh).map((message): Update => ({ kind: 'message', message })),
304            ...toMessages(changed).map(
305              (message): Update =>
306                message.isDeleted ? { kind: 'delete', conversation: message.conversation, id: message.id } : { kind: 'edit', message },
307            ),
308            ...(await tapUpdates(taps)),
309          ]
310          const newest = Math.max(Number(rowid), ...fresh.map(row => row.id), ...taps.map(row => row.id))
311          const stamped = [...fresh, ...changed].reduce(
312            (max, row) => later(later(max, row.edited ?? '0'), row.retracted ?? '0'),
313            stamp,
314          )
315
316          return { updates, cursor: `${newest}.${stamped}` }
317        },
318      },
319
320      async send(conversation, draft) {
321        // The text rides as an argument, never inside the script. The leading
322        // "x" keeps a message that starts with "-" from reading as an option.
323        await tell(
324          run,
325          'tell application "Messages" to send (text 2 thru -1 of item 1 of argv) to chat id (item 2 of argv)',
326          `x${draft.text}`,
327          await chatGuid(conversation),
328        )
329      },
330
331      attachments: {
332        async send(conversation, path) {
333          const file = expand(path)
334          if (!file.startsWith('/')) {
335            throw new Error('give the full path of the file')
336          }
337          await tell(
338            run,
339            'tell application "Messages" to send (POSIX file (text 2 thru -1 of item 1 of argv)) to chat id (item 2 of argv)',
340            `x${file}`,
341            await chatGuid(conversation),
342          )
343        },
344
345        async fetch(handle) {
346          const [row] = await ask<{ path: string | null }>(Q.attachment(handle))
347          if (!row?.path) {
348            throw new Error('that file is not on this Mac')
349          }
350
351          return { path: expand(row.path) }
352        },
353      },
354
355      members: {
356        async of(conversation) {
357          const rows = await ask<{ id: string }>(Q.members(conversation))
358
359          return rows.map((row): Person => ({ id: row.id, name: row.id, isMe: false }))
360        },
361      },
362    }
363  },
364}
365
366async function query<T>(run: Run, database: string, app: string, sql: string): Promise<T[]> {
367  const res = await run([SQLITE, '-readonly', '-json', database, sql])
368  if (res.exitCode !== 0) {
369    if (/unable to open|authorization denied|not authorized/i.test(res.stderr)) {
370      throw new NeedsSetup(needsAccess(app))
371    }
372    throw new Error(res.stderr.trim() || `sqlite3 exited ${res.exitCode}`)
373  }
374  if (res.isStdoutTruncated) {
375    throw new Error('the Messages query answered more than 4 MiB')
376  }
377
378  // sqlite3 prints nothing at all for no rows.
379  return res.stdout.trim() ? (JSON.parse(res.stdout) as T[]) : []
380}
381
382// Runs one line of AppleScript with its two values as arguments.
383async function tell(run: Run, line: string, first: string, second: string): Promise<void> {
384  const res = await run([OSASCRIPT, '-e', 'on run argv', '-e', line, '-e', 'end run', first, second])
385  if (res.exitCode !== 0) {
386    const reason = res.stderr.trim()
387    throw new Error(
388      /-1743|not allowed/i.test(reason)
389        ? 'allow Claude Code to control Messages (System Settings › Privacy & Security › Automation)'
390        : reason || `osascript exited ${res.exitCode}`,
391    )
392  }
393}
394
hooks/ui/conversation.tsx 238 lines
1import { textOf } from '../core/contract'
2import type { Attachment, Message, Ref } from '../core/contract'
3import type { Hub } from '../core/hub'
4import { clip, clockTime, colorOf, dayLabel, fileLabel, isSameDay, reactionsOf } from './format'
5import type { Elements } from './format'
6import { cellsOf, fit } from './picture'
7import type { Picture as Thumbnail } from './picture'
8
9// Below this width the pane is cramped: labels shorten.
10const NARROW = 48
11const ROW_BG = '#1c2730'
12
13// The most room a picture may take: it grows and shrinks with the pane, up
14// to 80 columns.
15function pictureRoom(columns: number, bodyRows: number): { columns: number; rows: number } {
16  return { columns: Math.min(columns - 2, 80), rows: Math.max(4, Math.min(24, Math.floor(bodyRows * 0.4))) }
17}
18const RUN_MS = 5 * 60_000
19
20type Props = { bodyColumns: number; bodyRows: number; surface: string; now: number }
21
22function isFile(part: Message['parts'][number]): part is Extract<Message['parts'][number], Attachment> {
23  return part.kind === 'file' || part.kind === 'image'
24}
25
26// What a thumbnail can be made of: a picture, or a PDF's first page.
27function isPicturable(part: Attachment): boolean {
28  return part.mime.startsWith('image/') || part.mime === 'application/pdf'
29}
30
31const pictureKey = (one: Message, index: number) => `picture-${one.id}-${index}`
32
33// A picture draws inline in the terminal once its thumbnail is ready.
34function pictureOf(hub: Hub, ref: Ref, one: Message, index: number, part: Attachment, surface: string): Thumbnail | undefined {
35  return surface === 'terminal' && isPicturable(part) ? hub.picture(ref, part, pictureKey(one, index)) : undefined
36}
37
38// Messages a person sends in a row read as one block: the name shows once.
39function continues(one: Message, before: Message | undefined): boolean {
40  return before !== undefined && before.sender.id === one.sender.id && one.at - before.at < RUN_MS && !one.replyTo
41}
42
43// The rows one message takes, near enough to fit the newest ones in the pane.
44function rowsOf(hub: Hub, ref: Ref, one: Message, before: Message | undefined, columns: number, props: Props): number {
45  const { surface } = props
46  const room = pictureRoom(columns, props.bodyRows)
47  const files = one.parts.filter(isFile)
48  const pictures = files
49    .map((part, index) => pictureOf(hub, ref, one, index, part, surface))
50    .filter(bitmap => bitmap !== undefined)
51
52  return (
53    (continues(one, before) ? 0 : 1) +
54    (before === undefined || !isSameDay(before.at, one.at) ? 1 : 0) +
55    Math.max(1, Math.ceil(textOf(one).length / Math.max(1, columns - 2))) +
56    (one.replyTo ? 1 : 0) +
57    files.length +
58    pictures.reduce((sum, bitmap) => sum + fit(bitmap, room.columns, room.rows).rows, 0)
59  )
60}
61
62// One conversation: its newest messages, then the reply box.
63export function ConversationView(hub: Hub, elements: Elements, ref: Ref, props: Props) {
64  const { Box, Button, Text } = elements
65  const Reply = 'Input' in elements ? elements.Input : undefined
66  const Picture = 'Image' in elements ? elements.Image : undefined
67  const Cells = 'Raster' in elements ? elements.Raster : undefined
68  const { view, inbox, notifier } = hub
69  const provider = hub.providerOf(ref)
70  const list = inbox.messages(ref)
71  const problems = hub.problems()
72  const columns = Math.max(20, props.bodyColumns)
73  const name = inbox.nameOf(ref)
74  const entry = inbox.entryOf(ref)
75  const isNarrow = columns < NARROW
76  const pictures = pictureRoom(columns, props.bodyRows)
77  const target = notifier.target()
78  const previous = notifier.previous()
79  const { now } = props
80
81  const offered = view.isAttaching ? view.offers.files.length + 1 : 0
82  let room = Math.max(1, props.bodyRows - (Reply ? 5 : 2) - problems.length - offered) + view.depth
83  const tail: Message[] = []
84  for (let at = list.length - 1; at >= 0; at -= 1) {
85    const one = list[at]
86    if (!one || room <= 0) {
87      break
88    }
89    room -= rowsOf(hub, ref, one, list[at - 1], columns, props)
90    tail.unshift(one)
91  }
92  const hasOlder = tail.length < list.length || !view.isAtStart
93  const people = entry?.members !== undefined && entry.members > 2 ? `${entry.members} people` : ''
94
95  return (
96    <Box flexDirection="column">
97      <Box key="head" gap={1} flexWrap="wrap">
98        <Button key="back" plain label="‹ chats" onPress={() => hub.back()} />
99        <Text bold color="cyan">{clip(name, Math.max(8, columns - 12))}</Text>
100        <Text dimColor>{[hub.labelOf(ref.service), people].filter(Boolean).join(' · ')}</Text>
101        {target && <Button key="jump" plain label={clip(`↪ new in ${inbox.nameOf(target)}`, columns)} onPress={() => void hub.jump()} />}
102        {!target && previous && (
103          <Button key="jump-back" plain label={clip(`↩ back to ${inbox.nameOf(previous)}`, columns)} onPress={() => void hub.jumpBack()} />
104        )}
105      </Box>
106      <Text dimColor>{'─'.repeat(columns)}</Text>
107      {list.length === 0 && problems.length === 0 && <Text dimColor>Loading…</Text>}
108      {list.length > 0 && (
109        <Box key="paging" gap={2}>
110          {hasOlder && <Button key="older" plain dimColor label="↑ older" onPress={() => void hub.older(props.bodyRows)} />}
111          {!hasOlder && <Text dimColor>{isNarrow ? '· start ·' : '· the start of this conversation ·'}</Text>}
112          {view.depth > 0 && <Button key="latest" plain dimColor label="↓ latest" onPress={() => hub.latest()} />}
113        </Box>
114      )}
115      {tail.map((one, at) => {
116        const before = tail[at - 1]
117        const isRun = continues(one, before)
118        const isNewDay = before === undefined || !isSameDay(before.at, one.at)
119        const hasText = one.isDeleted || one.parts.some(part => !isFile(part)) || one.parts.length === 0
120        const quoted = one.replyTo ? list.find(other => other.id === one.replyTo) : undefined
121        const reactions = reactionsOf(one.reactions)
122        const text = one.parts
123          .filter(part => !isFile(part))
124          .map(part => (part.kind === 'text' ? part.text : part.kind === 'link' ? (part.title ?? part.url) : ''))
125          .join(' ')
126        const color = one.sender.isMe ? 'cyan' : colorOf(one.sender.id)
127        const who = one.sender.isMe ? 'You' : one.sender.name
128
129        return (
130          <Box key={`message-${one.id}`} flexDirection="column" hover={{ backgroundColor: ROW_BG }}>
131            {isNewDay && (
132              <Text dimColor>{`── ${dayLabel(one.at, now)} ──`}</Text>
133            )}
134            {quoted && (
135              <Text dimColor wrap="truncate-end">{`  ↳ ${quoted.sender.isMe ? 'You' : quoted.sender.name}: ${textOf(quoted)}`}</Text>
136            )}
137            {!isRun && (
138              <Text>
139                <Text bold color={color}>{who}</Text>
140                <Text dimColor>{` ${clockTime(one.at)}`}</Text>
141              </Text>
142            )}
143            {(hasText || reactions) && (
144              <Box flexDirection="row">
145                <Text color={color}>{'▎'}</Text>
146                <Box flexGrow={1} flexShrink={1}>
147                  <Text wrap="wrap">
148                    {one.isDeleted ? <Text dimColor italic>message unsent</Text> : <Text>{text}</Text>}
149                    {one.editedAt !== undefined && !one.isDeleted && <Text dimColor> (edited)</Text>}
150                    {reactions && <Text> {reactions}</Text>}
151                  </Text>
152                </Box>
153              </Box>
154            )}
155            {one.parts.filter(isFile).map((part, index) => {
156              const bitmap = pictureOf(hub, ref, one, index, part, props.surface)
157              const box = bitmap && fit(bitmap, pictures.columns, pictures.rows)
158
159              return (
160                <Box key={`file-${one.id}-${index}`} flexDirection="column">
161                  <Box gap={1} flexWrap="wrap">
162                    <Text color={color}>{'▎'}</Text>
163                    {props.surface === 'terminal' ? (
164                      // The file itself is the button: pressing it opens it.
165                      <Button key={`open-file-${one.id}-${index}`} plain label={clip(fileLabel(part), columns - 2)} onPress={() => void hub.openFile(ref, part)} />
166                    ) : (
167                      <Text>{clip(fileLabel(part), columns - 2)}</Text>
168                    )}
169                    <Button key={`claude-file-${one.id}-${index}`} plain dimColor label="→ Claude" onPress={() => void hub.fileToClaude(ref, part)} />
170                    <Button key={`copy-file-${one.id}-${index}`} plain dimColor label={isNarrow ? 'copy' : 'copy path'} onPress={() => void hub.copyFile(ref, part)} />
171                  </Box>
172                  {bitmap && box && 'file' in bitmap.source && Picture && (
173                    <Picture
174                      key={pictureKey(one, index)}
175                      source={{ file: bitmap.source.file, format: 'png', generation: bitmap.source.generation }}
176                      columns={box.columns}
177                      rows={box.rows}
178                      alt={part.name}
179                    />
180                  )}
181                  {bitmap && box && 'bitmap' in bitmap.source && Cells && (
182                    <Cells key={pictureKey(one, index)} columns={box.columns} rows={box.rows} cells={cellsOf(bitmap.source.bitmap, box.columns, box.rows)} />
183                  )}
184                </Box>
185              )
186            })}
187          </Box>
188        )
189      })}
190      {problems.map((problem, index) => (
191        <Text key={`problem-${index}`} color="red" wrap="wrap">{problem}</Text>
192      ))}
193      {Reply && <Text dimColor>{'─'.repeat(columns)}</Text>}
194      {Reply && !view.isAttaching && (
195        <Reply
196          key="reply"
197          label="› "
198          placeholder={clip(`Message ${name}`, columns - 10)}
199          value={view.draft}
200          submitLabel="send"
201          autoFocus
202          onSubmit={text => void hub.send(text)}
203        />
204      )}
205      {Reply && view.isAttaching && (
206        <Reply
207          key="attach-path"
208          label="📎 "
209          placeholder={isNarrow ? 'Path of a file' : 'Path of a file to send (type it, or drag the file here)'}
210          value=""
211          submitLabel="send file"
212          autoFocus
213          onSubmit={path => void hub.sendFile(path)}
214        />
215      )}
216      {Reply && view.isAttaching && view.offers.files.length > 0 && (
217        <Box key="offers" flexDirection="column">
218          <Text dimColor>From this Claude session:</Text>
219          {view.offers.files.map((file, index) => (
220            <Button key={`offer-${index}`} plain label={clip(`📎 ${file.split('/').at(-1) ?? file}`, columns)} onPress={() => void hub.sendFile(file)} />
221          ))}
222        </Box>
223      )}
224      {Reply && (
225        <Box key="foot" gap={1} flexWrap="wrap">
226          {provider?.attachments && (
227            <Button key="attach" plain dimColor label={view.isAttaching ? 'cancel' : 'attach'} onPress={() => void hub.toggleAttach()} />
228          )}
229          <Button key="draft-reply" plain dimColor label={isNarrow ? 'reply' : "Claude's reply"} onPress={() => void hub.draftLastReply()} />
230          <Text key={`sent-${view.sent}`} color="green">
231            {view.sent > 0 ? view.note : ' '}
232          </Text>
233        </Box>
234      )}
235    </Box>
236  )
237}
238
hooks/ui/format.ts 134 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Attachment, Reaction } from '../core/contract'
4
5export type Elements = ReturnType<EngineInterface['ui']['resolve']>
6
7// Text cut to fit `width` cells, with an ellipsis where it was cut.
8export function clip(text: string, width: number): string {
9  return text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
10}
11
12export function clockTime(at: number): string {
13  const time = new Date(at)
14
15  return `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`
16}
17
18const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
19const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
20
21function dayStart(at: number): number {
22  const d = new Date(at)
23
24  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
25}
26
27// The day a message belongs to, as a separator names it against `now`.
28export function dayLabel(at: number, now: number): string {
29  const days = Math.round((dayStart(now) - dayStart(at)) / 86_400_000)
30  if (days <= 0) {
31    return 'Today'
32  }
33  if (days === 1) {
34    return 'Yesterday'
35  }
36  const d = new Date(at)
37  if (days < 7) {
38    return DAYS[d.getDay()] ?? ''
39  }
40  const date = `${d.getDate()} ${MONTHS[d.getMonth()]}`
41
42  return d.getFullYear() === new Date(now).getFullYear() ? date : `${date} ${d.getFullYear()}`
43}
44
45export const isSameDay = (a: number, b: number) => dayStart(a) === dayStart(b)
46
47// When a conversation last moved, as its row shows it: the time today, the
48// day this week, else the date.
49export function whenLabel(at: number, now: number): string {
50  const label = dayLabel(at, now)
51
52  return label === 'Today' ? clockTime(at) : label === 'Yesterday' ? 'Yest.' : label
53}
54
55export function sizeOf(bytes: number | undefined): string {
56  if (bytes === undefined) {
57    return ''
58  }
59  if (bytes < 1024) {
60    return `${bytes} B`
61  }
62
63  return bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
64}
65
66export function reactionsOf(reactions: readonly Reaction[] | undefined): string {
67  return (reactions ?? []).map(one => (one.count > 1 ? `${one.emoji} ${one.count}` : one.emoji)).join(' ')
68}
69
70// A glyph for a file by what it holds, so a row reads at a glance.
71export function glyphOf(part: Pick<Attachment, 'mime' | 'name'>): string {
72  const mime = part.mime.toLowerCase()
73  const ext = part.name.toLowerCase().split('.').at(-1) ?? ''
74  if (mime.startsWith('image/')) {
75    return '🖼'
76  }
77  if (mime.startsWith('video/') || ['mov', 'mp4', 'webm', 'm4v'].includes(ext)) {
78    return '🎬'
79  }
80  if (mime.startsWith('audio/') || ['m4a', 'mp3', 'caf', 'wav', 'aac'].includes(ext)) {
81    return '🎵'
82  }
83  if (mime === 'application/pdf' || ext === 'pdf') {
84    return '📄'
85  }
86  if (/zip|tar|gzip|compressed|archive/.test(mime) || ['zip', 'tgz', 'gz', 'dmg', '7z', 'rar'].includes(ext)) {
87    return '📦'
88  }
89  if (mime.startsWith('text/') || /json|xml|markdown/.test(mime) || ['md', 'txt', 'csv', 'json', 'log'].includes(ext)) {
90    return '📝'
91  }
92  if (/vcard|contact/.test(mime) || ext === 'vcf') {
93    return '👤'
94  }
95  if (/calendar/.test(mime) || ext === 'ics') {
96    return '📅'
97  }
98
99  return '📎'
100}
101
102export function fileLabel(part: Attachment): string {
103  return `${glyphOf(part)} ${part.name}${part.bytes === undefined ? '' : ` · ${sizeOf(part.bytes)}`}`
104}
105
106// A color for a person, the same every time their name comes up: soft tones
107// that read on dark and light terminals alike.
108const PALETTE = ['#5fb3f5', '#f58fb8', '#f5c35f', '#6fd6a8', '#b49cf5', '#f5a56f', '#6fd3e0', '#d9b36f']
109
110export function colorOf(id: string): string {
111  let hash = 0
112  for (const ch of id) {
113    hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) >>> 0
114  }
115
116  return PALETTE[hash % PALETTE.length] ?? '#5fb3f5'
117}
118
119// The letters an avatar chip shows for a name: the initials of a person, the
120// first letter of a phone number's digits spelled out as "#".
121export function initialsOf(name: string): string {
122  const words = name.replace(/[^\p{L}\p{N}+ ]/gu, ' ').trim().split(/\s+/).filter(Boolean)
123  if (words.length === 0) {
124    return '?'
125  }
126  if (/^\+?\d/.test(words[0] ?? '')) {
127    return '#'
128  }
129  const first = [...(words[0] ?? '')][0] ?? ''
130  const second = words.length > 1 ? ([...(words[words.length - 1] ?? '')][0] ?? '') : ''
131
132  return `${first}${second}`.toUpperCase()
133}
134