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.

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

It can all be done, inside Claude Code
Add the marketplace:
/plugin marketplace add jamubc/toolbox
Install the plugin:
/plugin install channel@toolbox
| Command | What it does |
|---|---|
/channel | Opens the chat pane: a tab per service, and your twenty most recent conversations in each. |
In the pane:
All tab shows every conversation together.1 to 9 open the first nine./config → How picture previews are drawn forces one, or turns previews off.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.
| Connection | Platform | Setup | What it can do |
|---|---|---|---|
| iMessage | macOS 13 or later, terminal | Full Disk Access | Read and send text and files; show replies, tapbacks, edits, unsends and members |
Turn a service off in /config under channel.
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.osascript. On your first send, macOS asks you to let your terminal control Messages.A service can start only the programs and call only the hosts listed here. Anything else is refused in hooks/core/fence.ts.
| Service | Programs it starts | Hosts it calls |
|---|---|---|
| iMessage | /usr/bin/sqlite3, /usr/bin/osascript | none |
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.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/hooks/register.tsx 234 lines1import 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}
234hooks/core/hub.ts 489 lines1import { 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}
489hooks/core/registry.ts 7 lines1import 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]
7hooks/ui/pane.tsx 16 lines1import 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}
16hooks/ui/picture.ts 138 lines1// 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}
138hooks/core/contract.ts 161 lines1import 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}
161hooks/core/account.ts 150 lines1import { 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}
150hooks/core/inbox.ts 175 lines1import 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}
175hooks/core/notify.ts 153 lines1import { 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}
153hooks/providers/imessage/index.ts 394 lines1import { 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}
394hooks/ui/conversation.tsx 238 lines1import { 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}
238hooks/ui/format.ts 134 lines1import 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