A Claude Mod: /crosstalk opens a pane with this session's conversations with your other Claude Code sessions (peer deliveries in, SendMessage out) and lets you…

<img alt="crosstalk — read and join what your Claude Code sessions tell each other" src="docs/_assets/banner.png" width="100%">
A Claude Mod that turns Claude Code's cross-session messaging into a conversation you can read and join: /crosstalk opens a pane with this session's exchanges with your other Claude Code sessions, and a field to answer them yourself.
│ crosstalk ● claude-crosstalk-33 ✕
│ ─────────────────────────────────────────────────────────────
│ ○ claude-98 02:04
│ you: via le socket
│
│ ● claude-flow
│ no message yet
│
│ ● crimson-desert-start
│ no message yet
│ ─────────────────────────────────────────────────────────────
│ j: ↓ k: ↑ l: open g: top q: close
│ ‹ claude-98 ✕
│ ─────────────────────────────────────────────────────────────
│ ──────────────────────── sat 19 sep ─────────────────────────
│ claude-98 · 01:28
│ ┃ Démo crosstalk depuis claude-98 : réponds-moi
│ ┃ une phrase courte via SendMessage à claude-98
│ ┃ (rien d'autre, pas d'outil en plus).
│
│ you · 02:01
│ réponse persistée ┃
│
│ via le socket ┃
│ ─────────────────────────────────────────────────────────────
│ ❯ message claude-98…
│ ⏎ send · esc normal mode
⚠️ Early access. Mods run on Claude Code's function hooks, which are behind
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1and may change between releases without notice. crosstalk is written against the declarations intypes/claude-code.d.ts(their first line names the Claude Code build).
Claude Code already moves the messages: SendMessage and ListAgents let sessions on one machine talk. crosstalk adds the part you see:
session.receive, origins peer and peer-send-message) is recorded under the sender's name, unchanged.SendMessage this session makes (tool.call) is recorded under its recipient, unchanged./crosstalk, again to close) opens on an inbox: one row a conversation (and each idle local session), most recent first, with its status, its name whole, its unread count, its time and its last message. ⏎ opens the conversation: the thread anchored on its newest message, grouped by side and day with Markdown bodies, and a reply field that sends through SendMessage; ✉ N in its header counts what arrived elsewhere. It lays out for its width: your messages on the right from 56 columns, both sides stacked left below, a line never past 72 columns.Keys, vim-style, drawn at the bottom of the pane as they apply:
| Inbox | Conversation, normal mode | Conversation, insert mode | |
|---|---|---|---|
j / k | next / previous row | scroll a message down / up | typed |
d / u | — | half a page down / up | typed |
g | top row | back to the latest | typed |
l, ⏎ | open the conversation | — | ⏎ sends |
i | — | reply (insert mode) | typed |
h | — | back to the inbox | typed |
esc | close the pane | back to the inbox | normal mode |
q | close the pane | close the pane | typed |
A conversation opens in insert mode. Arrows, ⏎, the wheel and PageUp/PageDown work as well, and ctrl+x tab goes back and forth with the prompt. Hotkeys are one lowercase letter or digit: shift is ignored (no G apart from g), and there is no ctrl or count. Each conversation keeps its own draft. The pane docks beside the transcript in the fullscreen layout from 110 columns (ctrl+x ←/→ resizes it), inline above the prompt otherwise.
A message held for approval (the two sessions run in different permission modes) already shows in the pane: crosstalk sees deliveries as they arrive, before the engine queues them.
--resume finds the conversation as it was, replies typed in the pane included. The 50 most recent sessions are kept.~/.claude/projects/…/<id>.jsonl): the messages peers sent it and the model's SendMessage calls, with their times, including those from before the mod was installed. The plugin API does not expose incoming peer messages ($.session.messages() hides them), so this reads Claude Code's own journal format, which is undocumented: a change there empties the rebuilt history and breaks nothing else. Replies typed in a pane before crosstalk kept them are in no journal.ListAgents no longer lists by that name goes to the socket it last wrote from.Not there yet: threads with subagents or teammates, remote (Remote Control / cloud) peers beyond what ListAgents reports.
git clone https://github.com/kbrdn1/claude-crosstalk && cd claude-crosstalk
make install # from this checkout
make install SOURCE=kbrdn1/claude-crosstalk # or from GitHub
make install adds the repo as a plugin marketplace, installs crosstalk, and merges CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 into the env of ~/.claude/settings.json (nothing else in that file changes). Then, in any new session: /crosstalk.
/reload-plugins, no reinstall.make update pulls a newer release.diff, agents-md, telemetry, and sec-default under managed settings). agents-md's default reads a project's AGENTS.md where it has no CLAUDE.md.make update # pull the latest into the installed copy (GitHub source)
make uninstall # remove the plugin and its marketplace (the flag stays)
Without installing anything: make dev starts Claude Code with this checkout loaded as a mod.
Requires Claude Code (a build that carries function hooks), bun (for tsc) and jq.
make test # claude plugin test .
make typecheck # tsc against types/
make validate # claude plugin validate
make ci # all three
make types # regenerate types/ after a Claude Code update
See CONTRIBUTING.md and CLAUDE.md.
MIT © Kylian Bardini
hooks/register.ts 549 lines1import type { EngineInterface, Register, Timer, ToolCallArgs, ToolCallResult, UiPane } from 'claude-code'
2
3import * as Thread from './thread'
4import * as View from './view'
5
6export const COMMAND = 'crosstalk'
7export const PANE_ID = 'crosstalk'
8
9// How often the open pane asks ListAgents who is around.
10export const REFRESH_MS = 15_000
11
12// How long after `/crosstalk` the pane asks for the keyboard again.
13export const FOCUS_RETRY_MS = 150
14
15// How long after a view switch the pane asks for the keyboard back: the keys
16// have to have fallen back to the prompt, or the ask is refused.
17export const REFOCUS_MS = 400
18
19const PEER_ORIGINS = { kind: ['peer', 'peer-send-message'] } as const
20
21// An inbox row's key: the prefix, then the peer's name.
22const ROW_PREFIX = 'open:'
23
24// One store key a session: `thread:<session id>`.
25const STORE_PREFIX = 'thread:'
26
27// ponytail: the 50 most recent sessions' threads stay in the store, the
28// rest go at a start; a size cap if a session's thread grows past use.
29export const MAX_SESSIONS = 50
30
31/**
32 * The engine calls crosstalk needs after `session.start`, bound once there
33 * so the pane's closures (a pick, a submit, a timer) can reach them.
34 */
35type Host = {
36 now: () => Promise<number>
37 every: (ms: number, fn: () => void) => Timer
38 after: (ms: number, fn: () => void) => Timer
39 invalidate: () => void
40 status: (text: string | undefined) => void
41 open: () => Promise<void>
42 close: () => Promise<void>
43 focus: (key: string) => Promise<boolean>
44 panes: () => Promise<readonly UiPane[]>
45 call: (args: ToolCallArgs) => Promise<ToolCallResult>
46 save: (value: Thread.Saved) => Promise<void>
47}
48
49/**
50 * Why a SendMessage call did not reach anyone, or undefined when it did. A
51 * recipient no session answers to is `{ success: false }`, not an error.
52 */
53function refusalOf(result: ToolCallResult): string | undefined {
54 const record: unknown = result.result
55 const field = (key: string) =>
56 typeof record === 'object' && record !== null ? Reflect.get(record, key) : undefined
57
58 if (result.deny !== undefined) {
59 return result.deny
60 }
61
62 if (result.isError === true || field('success') === false) {
63 const reason = field('display') ?? field('message') ?? result.text
64
65 return typeof reason === 'string' ? reason : 'refused'
66 }
67
68 return undefined
69}
70
71/**
72 * The lines of this session's journal worth parsing (Thread.fromJournal):
73 * found by id under the config directory's projects/, filtered by grep so a
74 * long journal is never read whole into the plugin.
75 */
76async function journalOf($: EngineInterface, id: string): Promise<string[]> {
77 const config =
78 (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
79 const found = await $.process.run([
80 'find',
81 `${config}/projects`,
82 '-maxdepth',
83 '2',
84 '-name',
85 `${id}.jsonl`,
86 ])
87 const file = found.stdout.split('\n').find(Boolean)
88
89 if (file === undefined) {
90 return []
91 }
92
93 const { stdout } = await $.process.run(['grep', '-E', Thread.JOURNAL_PATTERN, file])
94
95 return stdout.split('\n').filter(Boolean)
96}
97
98/**
99 * Drops the stored threads of all but the MAX_SESSIONS most recent sessions.
100 */
101async function prune($: EngineInterface): Promise<void> {
102 const keys = (await $.store.keys()).filter(key => key.startsWith(STORE_PREFIX))
103
104 if (keys.length <= MAX_SESSIONS) {
105 return
106 }
107
108 const dated = await Promise.all(
109 keys.map(async key => ({ key, at: Thread.savedAtOf(await $.store.get(key)) })),
110 )
111 const oldest = dated.sort((a, b) => a.at - b.at).slice(0, keys.length - MAX_SESSIONS)
112
113 await Promise.all(oldest.map(({ key }) => $.store.delete(key)))
114}
115
116/**
117 * Registers crosstalk: `/crosstalk` toggles a pane holding this session's
118 * conversations with its peers. Incoming peer deliveries (`session.receive`)
119 * and outgoing SendMessage calls (`tool.call`) are recorded as they pass;
120 * neither is changed. A reply typed in the pane goes out as SendMessage.
121 */
122export const register: Register = on => {
123 let thread: Thread.Thread = Thread.EMPTY
124 let host: Host | undefined
125 let isOpen = false
126 let refresh: Timer | undefined
127 // The reply the pane is sending: recorded by send() itself, since the
128 // engine may or may not run this plugin's tool.call hook for its own call.
129 let replying: string | undefined
130 // What the person has typed in each conversation's reply field so far.
131 const drafts = new Map<string, string>()
132 // How many messages the thread is scrolled back from its newest one.
133 let back = 0
134 // Why the last reply did not go, shown in the pane until the next one.
135 let notice: string | undefined
136 // The inbox, or the conversation opened from it.
137 let view: 'inbox' | 'thread' = 'inbox'
138 // In a conversation, vim's modes: typing a reply, or moving with keys.
139 let mode: 'insert' | 'normal' = 'insert'
140 // The key of our element holding the focus ring, as ui.focus last said.
141 let ring: string | undefined
142
143 /**
144 * Where the ring is decides the mode: on the reply field, typing; on any
145 * other element of a conversation, moving.
146 */
147 function onRing(key: string | undefined): void {
148 ring = key
149
150 const now = key === 'reply' ? 'insert' : 'normal'
151
152 if (view === 'thread' && key !== undefined && now !== mode) {
153 mode = now
154 host?.invalidate()
155 }
156 }
157
158 /**
159 * Moves the ring to `key`. Seen live: the engine raises no ui.focus to the
160 * plugin for its own move, so the ring is noted here.
161 */
162 function focus(key: string): void {
163 void host?.focus(key).then(isMoved => {
164 if (isMoved) onRing(key)
165 })
166 }
167
168 /**
169 * Whether `peer`'s conversation is on screen: its messages arrive read.
170 */
171 function isReading(peer: string): boolean {
172 return isOpen && view === 'thread' && thread.selected === peer
173 }
174
175 /**
176 * Gives `key` the keyboard once the view that draws it is drawn. Seen live:
177 * the element that held it (a row, ‹) is gone with the old view, and the
178 * keys fall back to the prompt; the pane asks for them again, as on open.
179 */
180 function refocus(key: string): void {
181 host?.after(REFOCUS_MS, () => {
182 void host
183 ?.open()
184 .then(() => focus(key))
185 .catch(() => undefined)
186 })
187 }
188
189 /**
190 * The inbox rows' keys, top to bottom.
191 */
192 function rowsOf(): string[] {
193 return Thread.conversationsOf(thread).map(c => `${ROW_PREFIX}${c.peer}`)
194 }
195
196 /**
197 * The element the ring starts on when the pane takes the keyboard: the
198 * inbox row last opened, the reply field, or ‹ in normal mode.
199 */
200 function homeOf(): string {
201 if (view === 'thread') {
202 return mode === 'insert' ? 'reply' : 'back'
203 }
204
205 const peers = Thread.conversationsOf(thread).map(c => c.peer)
206 const peer = peers.includes(thread.selected ?? '') ? thread.selected : peers[0]
207
208 return `open:${peer ?? ''}`
209 }
210
211 function openThread(peer: string): void {
212 thread = Thread.select(thread, peer)
213 view = 'thread'
214 mode = 'insert'
215 back = 0
216 notice = undefined
217 redraw()
218 refocus('reply')
219 }
220
221 function openInbox(): void {
222 const from = thread.selected
223
224 view = 'inbox'
225 back = 0
226 redraw()
227 refocus(from === undefined ? 'reply' : `open:${from}`)
228 }
229
230 /**
231 * The thread with a new message in it: drawn, and saved for the next start.
232 */
233 async function commit(next: Thread.Thread): Promise<void> {
234 thread = next
235 redraw()
236 await host
237 ?.now()
238 .then(now => host?.save(Thread.toSaved(thread, now)))
239 .catch(() => undefined)
240 }
241
242 /**
243 * Scrolls the selected thread `by` messages back (negative: forward),
244 * within its messages.
245 */
246 function scrollBack(by: number): void {
247 const count = Thread.messagesWith(thread, thread.selected).length
248
249 back = Math.min(Math.max(0, back + by), Math.max(0, count - 1))
250 host?.invalidate()
251 }
252
253 /**
254 * Keeps the reader where they are when a message lands in the conversation
255 * they have scrolled back in.
256 */
257 function holdPlace(peer: string): void {
258 if (back > 0 && peer === thread.selected) {
259 back += 1
260 }
261 }
262
263 function redraw(): void {
264 const unread = Thread.unreadCount(thread)
265
266 host?.invalidate()
267 host?.status(unread > 0 && !isOpen ? `crosstalk · ${unread} unread` : undefined)
268 }
269
270 async function refreshPeers(): Promise<void> {
271 const result = await host?.call({ tool: 'ListAgents' }).catch(() => undefined)
272 const record: unknown = result?.result
273 const listing = typeof record === 'object' && record ? Reflect.get(record, 'listing') : undefined
274
275 if (typeof listing === 'string') {
276 thread = Thread.withListing(thread, Thread.listingOf(listing))
277 redraw()
278 }
279 }
280
281 async function send(text: string): Promise<void> {
282 const to = thread.selected
283 const message = text.trim()
284
285 if (!host || to === undefined || message === '') {
286 return
287 }
288
289 replying = message
290
291 const result = await host
292 .call({ tool: 'SendMessage', to: Thread.addressOf(thread, to), message, summary: 'crosstalk reply' })
293 .catch((error: unknown) => ({ deny: String(error), isError: undefined }))
294 .finally(() => {
295 replying = undefined
296 })
297 const refusal = refusalOf(result)
298
299 if (refusal !== undefined) {
300 // The open pane holds toasts back: the reason goes in the pane.
301 notice = `not sent: ${refusal}`
302 host.invalidate()
303
304 return
305 }
306
307 back = 0
308 await commit(Thread.record(thread, { dir: 'out', peer: to, text: message, at: await host.now() }))
309 }
310
311 on('session.start', async ($, e, next) => {
312 const id = await $.session.id()
313 const key = `${STORE_PREFIX}${id}`
314
315 host = {
316 now: () => $.clock.now(),
317 every: (ms, fn) => $.clock.every(ms, fn),
318 after: (ms, fn) => $.clock.after(ms, fn),
319 invalidate: () => $.ui.invalidate('ui.render'),
320 status: text => $.ui.status(text),
321 open: () =>
322 $.ui.open({
323 id: PANE_ID,
324 title: 'crosstalk',
325 focus: true,
326 closeOnEscape: true,
327 holdToasts: true,
328 rows: 20,
329 columns: 64,
330 }),
331 close: () => $.ui.close({ id: PANE_ID }),
332 focus: key =>
333 $.ui.focus({ requestId: PANE_ID, key }).then(
334 result => result.deny === undefined,
335 () => false,
336 ),
337 panes: () => $.ui.panes(),
338 call: args => $.tool.call(args),
339 save: value => $.store.set(key, value),
340 }
341
342 await $.command.register({
343 name: COMMAND,
344 description: 'Chat with your other Claude Code sessions',
345 immediate: true,
346 })
347
348 // What this session said before this module ran: its stored thread
349 // (a reload, a restart, a --resume), else, on its first start, what its
350 // journal holds from before crosstalk was loaded.
351 if (thread.entries.length === 0) {
352 const saved = Thread.fromSaved(await $.store.get(key).catch(() => undefined))
353 const history = saved ?? Thread.fromJournal(await journalOf($, id).catch(() => []))
354
355 thread = Thread.withListing(history, thread)
356
357 if (saved === undefined && history.entries.length > 0) {
358 await commit(thread)
359 }
360
361 redraw()
362 }
363
364 await prune($).catch(() => undefined)
365
366 // A reload (a module edit, a worker respawn) finds its pane still up.
367 if ((await host.panes()).some(pane => pane.id === PANE_ID)) {
368 isOpen = true
369 await refreshPeers()
370 refresh ??= host.every(REFRESH_MS, () => void refreshPeers())
371 }
372
373 return next(e)
374 })
375
376 on('session.receive', { origin: PEER_ORIGINS }, async ($, e, next) => {
377 const { peer, text, address } = Thread.inboundOf(e.text)
378 const known = Thread.withAlias(thread, address, peer)
379
380 holdPlace(peer)
381 await commit(Thread.record(known, { dir: 'in', peer, text, at: await $.clock.now() }, isReading(peer)))
382
383 return next(e)
384 })
385
386 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
387 const result = await next(e)
388 const isSent = refusalOf(result) === undefined && e.message !== replying
389
390 if (isSent && e.tool === 'SendMessage' && typeof e.to === 'string' && typeof e.message === 'string') {
391 holdPlace(Thread.peerOf(thread, e.to))
392 await commit(
393 Thread.record(thread, {
394 dir: 'out',
395 peer: Thread.peerOf(thread, e.to),
396 text: e.message,
397 at: await $.clock.now(),
398 }),
399 )
400 }
401
402 return result
403 })
404
405 on('command.run', { command: COMMAND }, async ($, e, next) => {
406 if (!host) {
407 return next(e)
408 }
409
410 if (isOpen) {
411 await host.close()
412
413 return { text: 'crosstalk closed' }
414 }
415
416 await host.open()
417 isOpen = true
418 view = 'inbox'
419 back = 0
420
421 // Focus is granted over an empty composer only, and while this command
422 // runs the composer still holds `/crosstalk`: ask again once it cleared.
423 // Seen live: without this second open, the pane never takes the keys.
424 host.after(FOCUS_RETRY_MS, () => void host?.open().catch(() => undefined))
425
426 await refreshPeers()
427 refresh ??= host.every(REFRESH_MS, () => void refreshPeers())
428 redraw()
429
430 const pane = (await host.panes()).find(p => p.id === PANE_ID)
431
432 return pane?.isPlaced === false
433 ? { text: 'crosstalk is open: widen the terminal to 110 columns to see it' }
434 : {}
435 })
436
437 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
438 // The person's esc in a conversation leaves the reply field for normal
439 // mode, then goes back to the inbox, as vim and a messaging app would;
440 // /crosstalk and q (the plugin's close) still close.
441 if (e.origin.kind === 'person' && view === 'thread') {
442 if (mode === 'insert') {
443 mode = 'normal'
444 host?.invalidate()
445 refocus('back')
446
447 return { deny: 'normal mode' }
448 }
449
450 openInbox()
451
452 return { deny: 'back to the inbox' }
453 }
454
455 const result = await next(e)
456
457 if (result.deny === undefined) {
458 isOpen = false
459 refresh?.cancel()
460 refresh = undefined
461 redraw()
462 }
463
464 return result
465 })
466
467 // The person's moves (Tab, arrows, a click) and the engine's (autoFocus).
468 on('ui.focus', async ($, e, next) => {
469 const result = await next(e)
470
471 if (e.requestId === PANE_ID && result.deny === undefined) {
472 onRing(e.element)
473 }
474
475 return result
476 })
477
478 // The wheel and the scroll keys move the thread, not the pane: the
479 // header and the reply field stay put.
480 on('ui.scroll', { requestId: PANE_ID }, ($, e, next) => {
481 // The inbox scrolls as a pane does; a conversation moves its thread.
482 if (e.origin.kind !== 'person' || view !== 'thread') {
483 return next(e)
484 }
485
486 scrollBack(-Math.sign(e.by) * Math.max(1, Math.round(Math.abs(e.by) / 3)))
487
488 return {}
489 })
490
491 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
492 if (e.requestId !== PANE_ID || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
493 return next(e)
494 }
495
496 const { Box, Text, Button, Input, Markdown } = await $.ui.resolve(e)
497
498 return View.paneView(
499 {
500 ui: { Box, Text, Button, Input, Markdown },
501 // A column for the left margin, three for the pane's close mark.
502 columns: Math.max(16, e.props.bodyColumns - 4),
503 rows: e.props.scroll.bodyRows,
504 isFocused: e.props.isFocused,
505 back,
506 draft: drafts.get(thread.selected ?? '') ?? '',
507 view,
508 mode,
509 onOpen: peer => openThread(peer),
510 onInbox: () => openInbox(),
511 home: homeOf(),
512 onMove: delta => {
513 const row = View.rowAfter(rowsOf(), ring, delta)
514
515 if (row !== undefined) focus(row)
516 },
517 onOpenHere: () => {
518 const row = View.rowAfter(rowsOf(), ring, 0)
519
520 if (row !== undefined) openThread(row.slice(ROW_PREFIX.length))
521 },
522 onTop: () => {
523 const row = rowsOf()[0]
524
525 if (row !== undefined) focus(row)
526 },
527 onInsert: () => {
528 mode = 'insert'
529 host?.invalidate()
530 focus('reply')
531 },
532 onClose: () => void host?.close().catch(() => undefined),
533 onBack: by => scrollBack(by),
534 onInput: text => {
535 drafts.set(thread.selected ?? '', text)
536 host?.invalidate()
537 },
538 notice,
539 onSubmit: text => {
540 drafts.delete(thread.selected ?? '')
541 notice = undefined
542 void send(text)
543 },
544 },
545 thread,
546 )
547 })
548}
549hooks/thread.ts 417 lines1/**
2 * The conversation state crosstalk keeps for one session: every message it
3 * saw go out (SendMessage) or come in (a peer delivery), the peers
4 * ListAgents last reported, which conversation the pane shows, and how many
5 * messages each peer sent that nobody has looked at yet. Pure: every
6 * function returns a new Thread.
7 */
8
9export type Direction = 'in' | 'out'
10
11export type Entry = {
12 dir: Direction
13 peer: string
14 text: string
15 // When it passed; absent where the source kept no time.
16 at?: number
17}
18
19export type Peer = {
20 name: string
21 kind: string
22 status: string
23}
24
25export type Thread = {
26 self?: string
27 entries: readonly Entry[]
28 peers: readonly Peer[]
29 selected?: string
30 unread: Readonly<Record<string, number>>
31 // A peer's socket address (an envelope's `from`) to its name
32 // (`from-name`): a model answers a peer at its address, the pane files the
33 // answer under its name.
34 aliases: Readonly<Record<string, string>>
35}
36
37export type Conversation = {
38 peer: string
39 status?: string
40 unread: number
41 last?: Entry
42}
43
44/**
45 * What `$.store` keeps of a session's thread.
46 */
47export type Saved = {
48 savedAt: number
49 entries: Entry[]
50 selected?: string
51 aliases: Record<string, string>
52}
53
54export const EMPTY: Thread = { entries: [], peers: [], unread: {}, aliases: {} }
55
56// ponytail: one flat cap for the whole session, per-peer caps if a chatty
57// peer ends up pushing the others' history out.
58export const MAX_ENTRIES = 200
59
60export const UNKNOWN_PEER = 'unknown session'
61
62/**
63 * The session journal lines worth parsing: a peer delivery (its `origin`)
64 * and a SendMessage call. A grep pattern, so a long journal is never read
65 * whole.
66 */
67export const JOURNAL_PATTERN = '"origin":\\{"kind":"peer|"name":"SendMessage","input":'
68
69// Global: only ever used through matchAll, which clones it. A module-level
70// /g regex is stateful under .test() or .exec(): do not.
71const WRAPPED = /<cross-session-message\b([^>]*)>([\s\S]*?)<\/cross-session-message>/g
72
73const ATTRIBUTE = /\b([a-z-]+)="([^"]*)"/g
74
75const REF_SUFFIX = /\s*\[[0-9a-f]{4,}\]\s*$/i
76
77const SELF_LINE = /^This session is (.+?) \[[0-9a-f]+\]/m
78
79// ` name [ref] · kind · status · started 7d ago`
80const PEER_LINE = /^\s+(.+?) \[[0-9a-f]+\]\s+·\s+([^·]+?)\s+·\s+([^·]+?)\s*(?:·.*)?$/
81
82type Inbound = { peer: string; text: string; address?: string }
83
84/**
85 * A peer's address as SendMessage or a listing writes it, without the
86 * ` [ref]` suffix that only disambiguates two sessions of one name.
87 */
88export function nameOf(address: string): string {
89 return address.replace(REF_SUFFIX, '').trim()
90}
91
92/**
93 * Every peer message a text carries, in order (a delivery can batch
94 * several): `<cross-session-message from="uds:…" from-name="api">`. Named
95 * by `from-name` when there is one, since `from` is a socket address.
96 */
97function envelopesOf(text: string): Inbound[] {
98 return [...text.matchAll(WRAPPED)].map(([, head, body]) => {
99 const attributes = new Map(
100 [...(head ?? '').matchAll(ATTRIBUTE)].map(([, key, value]) => [key, value ?? '']),
101 )
102 const address = attributes.get('from')
103 const sender = attributes.get('from-name') || nameOf(address ?? '')
104 const inbound = { peer: sender || UNKNOWN_PEER, text: (body ?? '').trim() }
105
106 return address ? { ...inbound, address } : inbound
107 })
108}
109
110/**
111 * Who sent a delivery, from where, and what it says; the whole text under
112 * UNKNOWN_PEER when it has no envelope.
113 */
114export function inboundOf(text: string): Inbound {
115 return envelopesOf(text)[0] ?? { peer: UNKNOWN_PEER, text: text.trim() }
116}
117
118/**
119 * The conversation a SendMessage `to` belongs to: the name behind a socket
120 * address a peer wrote from, else the address without its ref.
121 */
122export function peerOf(thread: Thread, to: string): string {
123 return thread.aliases[to] ?? nameOf(to)
124}
125
126/**
127 * The thread knowing that `address` is `peer`. A session renamed since it
128 * last wrote from that address takes its conversation along.
129 */
130export function withAlias(thread: Thread, address: string | undefined, peer: string): Thread {
131 const before = address === undefined ? undefined : thread.aliases[address]
132
133 if (address === undefined || address === peer || before === peer) {
134 return thread
135 }
136
137 const known = { ...thread, aliases: { ...thread.aliases, [address]: peer } }
138
139 return before === undefined ? known : renamed(known, before, peer)
140}
141
142function renamed(thread: Thread, from: string, to: string): Thread {
143 const { [from]: count = 0, ...unread } = thread.unread
144
145 return {
146 ...thread,
147 entries: thread.entries.map(entry => (entry.peer === from ? { ...entry, peer: to } : entry)),
148 unread: count > 0 ? { ...unread, [to]: (unread[to] ?? 0) + count } : unread,
149 selected: thread.selected === from ? to : thread.selected,
150 aliases: Object.fromEntries(
151 Object.entries(thread.aliases).map(([address, name]) => [address, name === from ? to : name]),
152 ),
153 }
154}
155
156/**
157 * Where a reply to `peer` goes: its name while ListAgents lists it, else the
158 * socket it last wrote from (it outlives a rename, not a restart), else the
159 * name as it is.
160 */
161export function addressOf(thread: Thread, peer: string): string {
162 if (thread.peers.some(listed => listed.name === peer)) {
163 return peer
164 }
165
166 return Object.entries(thread.aliases).findLast(([, name]) => name === peer)?.[0] ?? peer
167}
168
169/**
170 * The peer sessions and this session's own name, read from ListAgents'
171 * text. Only the `Peer sessions` section: subagents are this session's own.
172 *
173 * ponytail: parses the tool's human-readable listing, the only one it has;
174 * swap for a structured source once the engine offers one to plugins.
175 */
176export function listingOf(text: string): { self?: string; peers: Peer[] } {
177 const self = SELF_LINE.exec(text)?.[1]
178 const peers: Peer[] = []
179 let isPeerSection = false
180
181 for (const line of text.split('\n')) {
182 if (/^\S/.test(line)) {
183 isPeerSection = line.startsWith('Peer sessions')
184 continue
185 }
186
187 const match = isPeerSection ? PEER_LINE.exec(line) : null
188
189 if (match) {
190 peers.push({
191 name: (match[1] ?? '').trim(),
192 kind: (match[2] ?? '').trim(),
193 status: (match[3] ?? '').trim(),
194 })
195 }
196 }
197
198 return self === undefined ? { peers } : { self, peers }
199}
200
201/**
202 * The thread with one more message. An incoming one counts as unread unless
203 * its conversation is on screen; the first message selects its conversation.
204 */
205export function record(thread: Thread, entry: Entry, isShown = false): Thread {
206 const selected = thread.selected ?? entry.peer
207 const isSeen = entry.dir === 'out' || (isShown && selected === entry.peer)
208
209 return {
210 ...thread,
211 selected,
212 entries: [...thread.entries, entry].slice(-MAX_ENTRIES),
213 unread: isSeen
214 ? thread.unread
215 : { ...thread.unread, [entry.peer]: (thread.unread[entry.peer] ?? 0) + 1 },
216 }
217}
218
219/**
220 * The thread showing `peer`'s conversation, its messages now read.
221 */
222export function select(thread: Thread, peer: string): Thread {
223 const { [peer]: _read, ...unread } = thread.unread
224
225 return { ...thread, selected: peer, unread }
226}
227
228/**
229 * The thread with the peers a fresh listing reported.
230 */
231export function withListing(
232 thread: Thread,
233 listing: { self?: string; peers: readonly Peer[] },
234): Thread {
235 const self = listing.self ?? thread.self
236
237 return self === undefined
238 ? { ...thread, peers: listing.peers }
239 : { ...thread, self, peers: listing.peers }
240}
241
242/**
243 * Every conversation worth a row: each peer a message went to or came from,
244 * then each local session ListAgents lists that nobody talked to yet. Most
245 * recent first, a message without a time counting as the oldest.
246 */
247export function conversationsOf(thread: Thread): Conversation[] {
248 const byPeer = new Map<string, Conversation>()
249 const statusOf = new Map(thread.peers.map(peer => [peer.name, peer.status]))
250
251 for (const entry of thread.entries) {
252 byPeer.set(entry.peer, {
253 peer: entry.peer,
254 status: statusOf.get(entry.peer),
255 unread: thread.unread[entry.peer] ?? 0,
256 last: entry,
257 })
258 }
259
260 const active = [...byPeer.values()].sort((a, b) => (b.last?.at ?? 0) - (a.last?.at ?? 0))
261
262 const idle = thread.peers
263 .filter(peer => peer.kind === 'interactive' && !byPeer.has(peer.name))
264 .map(peer => ({ peer: peer.name, status: peer.status, unread: 0 }))
265
266 return [...active, ...idle]
267}
268
269/**
270 * The messages exchanged with `peer`, oldest first.
271 */
272export function messagesWith(thread: Thread, peer: string | undefined): Entry[] {
273 return peer === undefined ? [] : thread.entries.filter(entry => entry.peer === peer)
274}
275
276/**
277 * How many messages wait unread, all conversations together.
278 */
279export function unreadCount(thread: Thread): number {
280 return Object.values(thread.unread).reduce((sum, count) => sum + count, 0)
281}
282
283/**
284 * What to keep in `$.store`.
285 */
286export function toSaved(thread: Thread, now: number): Saved {
287 const saved = { savedAt: now, entries: [...thread.entries], aliases: { ...thread.aliases } }
288
289 return thread.selected === undefined ? saved : { ...saved, selected: thread.selected }
290}
291
292function isRecord(value: unknown): value is Record<string, unknown> {
293 return typeof value === 'object' && value !== null && !Array.isArray(value)
294}
295
296function isEntry(value: unknown): value is Entry {
297 return (
298 isRecord(value) &&
299 (value.dir === 'in' || value.dir === 'out') &&
300 typeof value.peer === 'string' &&
301 typeof value.text === 'string' &&
302 (value.at === undefined || typeof value.at === 'number')
303 )
304}
305
306/**
307 * The thread `$.store` kept, or undefined when the value is not one.
308 */
309export function fromSaved(value: unknown): Thread | undefined {
310 if (!isRecord(value) || !Array.isArray(value.entries) || !isRecord(value.aliases)) {
311 return undefined
312 }
313
314 const aliases = Object.fromEntries(
315 Object.entries(value.aliases).filter(
316 (pair): pair is [string, string] => typeof pair[1] === 'string',
317 ),
318 )
319 const thread = { ...EMPTY, entries: value.entries.filter(isEntry), aliases }
320
321 return typeof value.selected === 'string' ? { ...thread, selected: value.selected } : thread
322}
323
324/**
325 * When a stored thread was saved, 0 when the value is not one.
326 */
327export function savedAtOf(value: unknown): number {
328 return isRecord(value) && typeof value.savedAt === 'number' ? value.savedAt : 0
329}
330
331/**
332 * The thread a session journal already holds, every message read: peer
333 * deliveries (a `user` row or a `queued_command` attachment carrying an
334 * `origin` of kind peer*, one per `msg_id`) and the model's SendMessage
335 * calls, in order, with their times.
336 *
337 * ponytail: reads Claude Code's own session journal, an undocumented format
338 * the plugin API does not expose (it hides peer rows from
339 * `$.session.messages()`); a format change empties the rebuilt history, it
340 * breaks nothing else. A refused SendMessage still shows as sent.
341 */
342export function fromJournal(lines: readonly string[]): Thread {
343 const seen = new Set<string>()
344 let thread = EMPTY
345
346 for (const line of lines) {
347 let row: unknown
348
349 try {
350 row = JSON.parse(line)
351 } catch {
352 continue
353 }
354
355 if (!isRecord(row)) {
356 continue
357 }
358
359 const at = typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : Number.NaN
360 const time = Number.isNaN(at) ? {} : { at }
361 const attachment = isRecord(row.attachment) ? row.attachment : {}
362 const origin = isRecord(row.origin)
363 ? row.origin
364 : isRecord(attachment.origin)
365 ? attachment.origin
366 : {}
367 const message = isRecord(row.message) ? row.message : {}
368
369 if (typeof origin.kind === 'string' && origin.kind.startsWith('peer')) {
370 const id = typeof origin.msg_id === 'string' ? origin.msg_id : undefined
371
372 if (id !== undefined && seen.has(id)) {
373 continue
374 }
375
376 if (id !== undefined) {
377 seen.add(id)
378 }
379
380 const framed = [message.content, attachment.prompt].find(t => typeof t === 'string')
381 const envelope = typeof framed === 'string' ? envelopesOf(framed)[0] : undefined
382 const address = typeof origin.from === 'string' ? origin.from : envelope?.address
383 const peer =
384 (typeof origin.name === 'string' && origin.name) ||
385 envelope?.peer ||
386 nameOf(address ?? '') ||
387 UNKNOWN_PEER
388 const text = (typeof origin.body === 'string' ? origin.body : (envelope?.text ?? '')).trim()
389
390 thread = record(withAlias(thread, address, peer), { dir: 'in', peer, text, ...time })
391 continue
392 }
393
394 if (row.type === 'assistant' && Array.isArray(message.content)) {
395 for (const block of message.content) {
396 const input = isRecord(block) && isRecord(block.input) ? block.input : {}
397 const isSend =
398 isRecord(block) &&
399 block.type === 'tool_use' &&
400 block.name === 'SendMessage' &&
401 typeof input.to === 'string' &&
402 typeof input.message === 'string'
403
404 if (isSend) {
405 const peer = peerOf(thread, String(input.to))
406
407 thread = record(thread, { dir: 'out', peer, text: String(input.message), ...time })
408 }
409 }
410 }
411 }
412
413 const selected = thread.entries.at(-1)?.peer
414
415 return selected === undefined ? thread : { ...thread, unread: {}, selected }
416}
417hooks/view.tsx 589 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { Elements, RenderElement } from 'claude-code'
5
6import * as Thread from './thread'
7
8/**
9 * Claude Dark: the accent for what this session says, blue for what a peer
10 * says, green/yellow for a peer's status, grey for the chrome.
11 */
12export const COLORS = {
13 accent: '#D4825D',
14 peer: '#7AB8FF',
15 idle: '#86E89A',
16 busy: '#FFDF61',
17 muted: '#999999',
18 error: '#FF7A7A',
19} as const
20
21export type Kit = {
22 ui: Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input' | 'Markdown'>
23 columns: number
24 rows: number
25 isFocused: boolean
26 back: number
27 draft: string
28 notice?: string
29 view: 'inbox' | 'thread'
30 // In a conversation: typing in the reply field, or moving (vim's modes).
31 mode: 'insert' | 'normal'
32 // The element the ring starts on when the pane takes the keyboard.
33 home: string
34 onOpen: (peer: string) => void
35 onInbox: () => void
36 // Inbox: the ring `delta` rows on, the row under it opened, the top row.
37 onMove: (delta: number) => void
38 onOpenHere: () => void
39 onTop: () => void
40 onInsert: () => void
41 onClose: () => void
42 onBack: (by: number) => void
43 onInput: (text: string) => void
44 onSubmit: (text: string) => void
45}
46
47export type Group = {
48 dir: Thread.Direction
49 peer: string
50 day?: string
51 entries: Thread.Entry[]
52}
53
54// Header, two rules, reply field, hints.
55const CHROME_ROWS = 5
56// Below this many columns both sides stack on the left.
57const SPLIT_MIN_COLUMNS = 56
58// A bubble never gets wider: a line past this reads badly.
59const MAX_BUBBLE = 72
60// A pause longer than this starts a new group.
61const GROUP_GAP_MS = 5 * 60_000
62const MAX_MARKDOWN = 10_000
63
64const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
65const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
66
67/**
68 * `HH:MM` of a timestamp, in the session's local time.
69 */
70export function clockOf(at: number): string {
71 const date = new Date(at)
72 const pad = (n: number) => String(n).padStart(2, '0')
73
74 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
75}
76
77/**
78 * `sat 19 sep` of a timestamp, in the session's local time.
79 */
80export function dayOf(at: number): string {
81 const date = new Date(at)
82
83 return `${DAYS[date.getDay()]} ${date.getDate()} ${MONTHS[date.getMonth()]}`
84}
85
86function truncate(text: string, max: number): string {
87 return text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
88}
89
90/**
91 * A session name cut in its middle to `max` cells, whole when it fits:
92 * names share their start (`claude-…`) and differ at their end, so both
93 * ends stay.
94 */
95export function fitName(text: string, max: number): string {
96 if (text.length <= max || max < 5) {
97 return truncate(text, max)
98 }
99
100 const head = Math.ceil((max - 1) / 3)
101
102 return `${text.slice(0, head)}…${text.slice(text.length - (max - 1 - head))}`
103}
104
105/**
106 * How the pane lays out at `columns`: wide enough, your messages sit on the
107 * right and a bubble takes four fifths of the row; narrower, both sides
108 * stack on the left, full width. Never past MAX_BUBBLE.
109 */
110export function layoutOf(columns: number): { isSplit: boolean; bubble: number } {
111 const isSplit = columns >= SPLIT_MIN_COLUMNS
112
113 const bubble = isSplit ? Math.floor(columns * 0.8) : Math.max(8, columns - 2)
114
115 return { isSplit, bubble: Math.min(MAX_BUBBLE, bubble) }
116}
117
118/**
119 * What an inbox row says under a conversation's name: its last message's
120 * first line, `you: ` before yours, cut to `width`.
121 */
122export function previewOf(last: Thread.Entry | undefined, width: number): string {
123 if (last === undefined) {
124 return 'no message yet'
125 }
126
127 const line = last.text.split('\n').find(part => part.trim() !== '') ?? ''
128
129 return truncate(`${last.dir === 'out' ? 'you: ' : ''}${line.trim()}`, Math.max(1, width))
130}
131
132/**
133 * The messages grouped as a chat draws them: a burst from one side under one
134 * header, a new group on a change of side, a pause over five minutes or a new
135 * day; `day` set on the first group of each day.
136 */
137export function groupsOf(entries: readonly Thread.Entry[]): Group[] {
138 const groups: Group[] = []
139 let lastDay: string | undefined
140
141 for (const entry of entries) {
142 const last = groups.at(-1)
143 const previous = last?.entries.at(-1)
144 const day = entry.at === undefined ? undefined : dayOf(entry.at)
145 const isNewDay = day !== undefined && day !== lastDay
146 const isPause =
147 entry.at !== undefined &&
148 previous?.at !== undefined &&
149 entry.at - previous.at > GROUP_GAP_MS
150 const isSameSide = last !== undefined && last.dir === entry.dir && last.peer === entry.peer
151
152 if (last !== undefined && isSameSide && !isNewDay && !isPause) {
153 last.entries.push(entry)
154 } else {
155 groups.push(
156 isNewDay
157 ? { dir: entry.dir, peer: entry.peer, day, entries: [entry] }
158 : { dir: entry.dir, peer: entry.peer, entries: [entry] },
159 )
160 }
161
162 lastDay = day ?? lastDay
163 }
164
165 return groups
166}
167
168function wrappedRowsOf(line: string, width: number): number {
169 let rows = 1
170 let used = 0
171
172 for (const word of line.split(' ')) {
173 if (used > 0 && used + 1 + word.length <= width) {
174 used += 1 + word.length
175 continue
176 }
177
178 if (used > 0) {
179 rows += 1
180 }
181
182 used = word.length
183
184 while (used > width) {
185 rows += 1
186 used -= width
187 }
188 }
189
190 return rows
191}
192
193/**
194 * The rows `text` takes word-wrapped at `width`: what the bar beside a
195 * message is as tall as, and what the thread window budgets.
196 *
197 * ponytail: wraps as plain text does; a Markdown body (a code fence, a list)
198 * can take a row more or less. The thread box clips its top, so an estimate
199 * off by a row costs a row of the oldest message.
200 */
201export function rowsOf(text: string, width: number): number {
202 const cells = Math.max(1, width)
203
204 return text.split('\n').reduce((sum, line) => sum + wrappedRowsOf(line, cells), 0)
205}
206
207/**
208 * The cells a message needs, up to `width`: a short one hugs its side.
209 */
210export function widthOf(text: string, width: number): number {
211 const longest = Math.max(1, ...text.split('\n').map(line => line.length))
212
213 return Math.min(Math.max(1, width), longest)
214}
215
216/**
217 * The messages the thread shows in `rows`: the newest ones back from `back`
218 * messages before the end, as many as fit (a header and a gap counted per
219 * message), and how many are left above and below.
220 */
221export function windowOf(
222 entries: readonly Thread.Entry[],
223 rows: number,
224 back: number,
225 width: number,
226): { entries: Thread.Entry[]; earlier: number; newer: number } {
227 const newer = Math.min(Math.max(0, back), Math.max(0, entries.length - 1))
228 const end = entries.length - newer
229 let start = end
230 let used = 0
231
232 while (start > 0) {
233 const cost = rowsOf(entries[start - 1]?.text ?? '', width) + 2
234
235 if (used + cost > rows && start < end) {
236 break
237 }
238
239 used += cost
240 start -= 1
241 }
242
243 return { entries: entries.slice(start, end), earlier: start, newer }
244}
245
246/**
247 * The row `delta` rows from the one holding the ring, held at the ends; the
248 * first row when the ring is on none.
249 */
250export function rowAfter(
251 rows: readonly string[],
252 ring: string | undefined,
253 delta: number,
254): string | undefined {
255 const at = ring === undefined ? -1 : rows.indexOf(ring)
256
257 if (at < 0) {
258 return rows[0]
259 }
260
261 return rows[Math.min(rows.length - 1, Math.max(0, at + delta))]
262}
263
264type Key = { hotkey: string; label: string; onPress: () => void }
265
266/**
267 * The keys that work now, as buttons: each is its own hotkey (a lowercase
268 * letter, while the pane holds the keyboard and no field does), drawn
269 * `j: ↓`, so the legend is the binding.
270 */
271function legend(kit: Kit, keys: readonly Key[]): RenderElement {
272 const { Box, Button } = kit.ui
273
274 return (
275 <Box flexWrap="wrap" columnGap={2}>
276 {keys.map(key => (
277 <Button
278 key={`vim:${key.hotkey}`}
279 label={key.label}
280 hotkey={key.hotkey}
281 plain
282 dimColor
283 onPress={key.onPress}
284 />
285 ))}
286 </Box>
287 )
288}
289
290function hintOf(kit: Kit, text: string): RenderElement {
291 const { Text } = kit.ui
292
293 return <Text dimColor>{truncate(text, kit.columns)}</Text>
294}
295
296function inboxKeys(kit: Kit): RenderElement {
297 if (!kit.isFocused) {
298 return hintOf(kit, 'ctrl+x tab to choose')
299 }
300
301 // The ring moves between two draws: where it is now is the plugin's to
302 // read at the press, not this drawing's.
303 return legend(kit, [
304 { hotkey: 'j', label: '↓', onPress: () => kit.onMove(1) },
305 { hotkey: 'k', label: '↑', onPress: () => kit.onMove(-1) },
306 { hotkey: 'l', label: 'open', onPress: () => kit.onOpenHere() },
307 { hotkey: 'g', label: 'top', onPress: () => kit.onTop() },
308 { hotkey: 'q', label: 'close', onPress: () => kit.onClose() },
309 ])
310}
311
312function threadKeys(kit: Kit, shown: number, total: number): RenderElement {
313 if (!kit.isFocused) {
314 return hintOf(kit, 'ctrl+x tab to reply')
315 }
316
317 if (kit.mode === 'insert') {
318 return hintOf(kit, kit.columns >= 48 ? '⏎ send · esc normal mode' : '⏎ send · esc normal')
319 }
320
321 const half = Math.max(1, Math.ceil(shown / 2))
322
323 return legend(kit, [
324 { hotkey: 'j', label: '↓', onPress: () => kit.onBack(-1) },
325 { hotkey: 'k', label: '↑', onPress: () => kit.onBack(1) },
326 { hotkey: 'd', label: '⇣', onPress: () => kit.onBack(-half) },
327 { hotkey: 'u', label: '⇡', onPress: () => kit.onBack(half) },
328 { hotkey: 'g', label: 'latest', onPress: () => kit.onBack(-total) },
329 { hotkey: 'i', label: 'reply', onPress: () => kit.onInsert() },
330 { hotkey: 'h', label: 'inbox', onPress: () => kit.onInbox() },
331 { hotkey: 'q', label: 'close', onPress: () => kit.onClose() },
332 ])
333}
334
335function statusOf(thread: Thread.Thread, peer: string): { dot: string; color: string; status?: string } {
336 const status = thread.peers.find(listed => listed.name === peer)?.status
337
338 if (status === 'idle') return { dot: '●', color: COLORS.idle, status }
339 if (status === 'busy') return { dot: '●', color: COLORS.busy, status }
340
341 return status === undefined
342 ? { dot: '○', color: COLORS.muted }
343 : { dot: '○', color: COLORS.muted, status }
344}
345
346/**
347 * A rule across `width` cells, `label` centred in it when given.
348 */
349export function ruleOf(width: number, label?: string): string {
350 const cells = Math.max(1, width)
351
352 if (label === undefined) {
353 return '─'.repeat(cells)
354 }
355
356 const side = Math.max(1, Math.floor((cells - label.length - 2) / 2))
357
358 return `${'─'.repeat(side)} ${label} ${'─'.repeat(Math.max(1, cells - side - label.length - 2))}`
359}
360
361function rule(kit: Kit, label?: string): RenderElement {
362 const { Text } = kit.ui
363
364 return <Text color={COLORS.muted}>{ruleOf(kit.columns, label)}</Text>
365}
366
367function groupView(kit: Kit, group: Group, width: number): RenderElement {
368 const { Box, Text, Markdown } = kit.ui
369 const { isSplit } = layoutOf(kit.columns)
370 const isMine = group.dir === 'out'
371 const color = isMine ? COLORS.accent : COLORS.peer
372 const first = group.entries[0]
373 const time = first?.at === undefined ? '' : ` · ${clockOf(first.at)}`
374 const onRight = isMine && isSplit
375
376 return (
377 <Box flexDirection="column" marginTop={1}>
378 {group.day === undefined ? null : <Box justifyContent="center">{rule(kit, group.day)}</Box>}
379 <Box justifyContent={onRight ? 'flex-end' : 'flex-start'}>
380 <Text>
381 <Text bold color={color}>
382 {isMine ? 'you' : group.peer}
383 </Text>
384 <Text dimColor>{time}</Text>
385 </Text>
386 </Box>
387 {group.entries.map((entry, i) => {
388 const text = truncate(entry.text, MAX_MARKDOWN)
389 const cells = onRight ? widthOf(text, width) : width
390 const bar = Array.from({ length: rowsOf(text, cells) }, () => '┃').join('\n')
391
392 return (
393 <Box justifyContent={onRight ? 'flex-end' : 'flex-start'} marginTop={i === 0 ? 0 : 1}>
394 {onRight ? null : (
395 <Box marginRight={1}>
396 <Text color={color}>{bar}</Text>
397 </Box>
398 )}
399 <Box width={cells} flexDirection="column">
400 <Markdown text={text} />
401 </Box>
402 {onRight ? (
403 <Box marginLeft={1}>
404 <Text color={color}>{bar}</Text>
405 </Box>
406 ) : null}
407 </Box>
408 )
409 })}
410 </Box>
411 )
412}
413
414function header(kit: Kit, thread: Thread.Thread): RenderElement {
415 const { Box, Text } = kit.ui
416 const self = thread.self && kit.columns >= 40 ? `● ${thread.self}` : ''
417
418 return (
419 <Box justifyContent="space-between">
420 <Text bold color={COLORS.accent}>
421 crosstalk
422 </Text>
423 <Text dimColor>{truncate(self, Math.max(0, kit.columns - 12))}</Text>
424 </Box>
425 )
426}
427
428/**
429 * The inbox: one row a conversation, most recent first, each with its
430 * status, its name whole, its unread count and time, and its last message.
431 */
432function inboxView(kit: Kit, thread: Thread.Thread): RenderElement {
433 const { Box, Text, Button } = kit.ui
434 const conversations = Thread.conversationsOf(thread)
435
436 return (
437 <Box flexDirection="column" width={kit.columns} marginLeft={1}>
438 {header(kit, thread)}
439 {rule(kit)}
440 {conversations.length === 0 ? (
441 <Box flexDirection="column" marginTop={1}>
442 <Text dimColor>No other session reachable yet.</Text>
443 <Text dimColor>Start one: it shows up here.</Text>
444 </Box>
445 ) : (
446 conversations.map((conversation, i) => {
447 const { dot, color } = statusOf(thread, conversation.peer)
448 const time = conversation.last?.at === undefined ? '' : clockOf(conversation.last.at)
449 const badge = conversation.unread > 0 ? String(conversation.unread) : ''
450 const side = (badge ? badge.length + 2 : 0) + time.length
451 const room = Math.max(4, kit.columns - 2 - side - 1)
452
453 return (
454 <Box flexDirection="column" marginTop={i === 0 ? 0 : 1}>
455 <Box justifyContent="space-between">
456 <Box>
457 <Text color={color}>{`${dot} `}</Text>
458 <Button
459 key={`open:${conversation.peer}`}
460 label={fitName(conversation.peer, room)}
461 plain
462 {...(`open:${conversation.peer}` === kit.home ? { autoFocus: true as const } : {})}
463 onPress={() => kit.onOpen(conversation.peer)}
464 />
465 </Box>
466 <Text>
467 <Text bold color={COLORS.accent}>
468 {badge ? `${badge} ` : ''}
469 </Text>
470 <Text dimColor>{time}</Text>
471 </Text>
472 </Box>
473 <Text dimColor>{` ${previewOf(conversation.last, kit.columns - 2)}`}</Text>
474 </Box>
475 )
476 })
477 )}
478 {rule(kit)}
479 {inboxKeys(kit)}
480 </Box>
481 )
482}
483
484/**
485 * One conversation, laid out for `kit.columns`: its header (‹ back, name,
486 * status, unread elsewhere), the thread anchored on its newest message, the
487 * reply field and the keys that work now.
488 */
489function threadView(kit: Kit, thread: Thread.Thread): RenderElement {
490 const { Box, Text, Button, Input } = kit.ui
491 const selected = thread.selected ?? ''
492 const { color, status } = statusOf(thread, selected)
493 const elsewhere = Object.entries(thread.unread)
494 .filter(([peer]) => peer !== selected)
495 .reduce((sum, [, count]) => sum + count, 0)
496 const { bubble } = layoutOf(kit.columns)
497 const text = Math.max(1, bubble - 2)
498 const threadRows = Math.max(3, kit.rows - CHROME_ROWS)
499 const all = Thread.messagesWith(thread, selected)
500 const window = windowOf(all, threadRows, kit.back, text)
501 const badge = elsewhere > 0 ? `✉ ${elsewhere}` : ''
502 const name = fitName(selected, Math.max(4, kit.columns - 4 - (status ? status.length + 3 : 0) - badge.length - 2))
503
504 return (
505 <Box flexDirection="column" width={kit.columns} marginLeft={1}>
506 <Box justifyContent="space-between">
507 <Box>
508 <Button
509 key="back"
510 label="‹"
511 plain
512 {...(kit.home === 'back' ? { autoFocus: true as const } : {})}
513 onPress={() => kit.onInbox()}
514 />
515 <Text>
516 <Text bold color={COLORS.peer}>{` ${name}`}</Text>
517 <Text color={color}>{status ? ` · ${status}` : ''}</Text>
518 </Text>
519 </Box>
520 <Text bold color={COLORS.accent}>
521 {badge}
522 </Text>
523 </Box>
524
525 {window.earlier > 0 ? (
526 <Button
527 key="older"
528 label={ruleOf(kit.columns, `↑ ${window.earlier} earlier`)}
529 plain
530 dimColor
531 onPress={() => kit.onBack(Math.max(1, window.entries.length))}
532 />
533 ) : (
534 rule(kit)
535 )}
536
537 <Box height={threadRows} flexDirection="column" justifyContent="flex-end" overflow="hidden">
538 {all.length === 0 ? (
539 <Box justifyContent="center">
540 <Text dimColor>{`No message with ${selected} yet. Say hi below.`}</Text>
541 </Box>
542 ) : (
543 groupsOf(window.entries).map(group => groupView(kit, group, text))
544 )}
545 </Box>
546
547 {kit.notice !== undefined ? (
548 <Text color={COLORS.error}>{truncate(`── ${kit.notice} `, kit.columns)}</Text>
549 ) : window.newer > 0 ? (
550 <Button
551 key="newer"
552 label={ruleOf(kit.columns, `↓ ${window.newer} newer`)}
553 plain
554 dimColor
555 onPress={() => kit.onBack(-window.newer)}
556 />
557 ) : (
558 rule(kit)
559 )}
560
561 <Box>
562 <Text bold color={COLORS.accent}>
563 {'❯ '}
564 </Text>
565 <Input
566 key="reply"
567 placeholder={`message ${selected}…`}
568 submitLabel="send"
569 {...(kit.home === 'reply' ? { autoFocus: true as const } : {})}
570 value={kit.draft}
571 onInput={value => kit.onInput(value)}
572 onSubmit={value => kit.onSubmit(value)}
573 />
574 </Box>
575
576 {threadKeys(kit, window.entries.length, all.length)}
577 </Box>
578 )
579}
580
581/**
582 * The crosstalk pane's body: the inbox, or the conversation opened from it.
583 */
584export function paneView(kit: Kit, thread: Thread.Thread): RenderElement {
585 return kit.view === 'thread' && thread.selected !== undefined
586 ? threadView(kit, thread)
587 : inboxView(kit, thread)
588}
589