SLOPSHOPPER

crosstalk

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…

newpaneguardcommandstatusprocess
v0.1.0MITupdated 2026-09-19kbrdn1/claude-crosstalk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crosstalk
│ ┃ crosstalk ✕ › fix the failing auth test and add an audit log call │ ┃ crosstalk │ ┃ ─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ─ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No other session reachable yet. ⎿ Added 2 lines, removed 1 line │ ┃ Start one: it shows up here. ⏺ Bash(bun test) │ ┃ ─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ─ │ ┃ ctrl+x tab to choose ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crosstalk │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · crosstalk
crosstalk ─────────────────────────────────────────────────── ─ No other session reachable yet. Start one: it shows up here. ─────────────────────────────────────────────────── ─ ctrl+x tab to choose
README

<img alt="crosstalk — read and join what your Claude Code sessions tell each other" src="docs/_assets/banner.png" width="100%">

crosstalk

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=1 and may change between releases without notice. crosstalk is written against the declarations in types/claude-code.d.ts (their first line names the Claude Code build).

What it does

Claude Code already moves the messages: SendMessage and ListAgents let sessions on one machine talk. crosstalk adds the part you see:

  • Incoming: every peer delivery (session.receive, origins peer and peer-send-message) is recorded under the sender's name, unchanged.
  • Outgoing: every SendMessage this session makes (tool.call) is recorded under its recipient, unchanged.
  • The pane (/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.
  • Unread: while the pane is closed, the status line counts the messages nobody looked at.

Keys, vim-style, drawn at the bottom of the pane as they apply:

InboxConversation, normal modeConversation, insert mode
j / knext / previous rowscroll a message down / uptyped
d / u—half a page down / uptyped
gtop rowback to the latesttyped
l, ⏎open the conversation—⏎ sends
i—reply (insert mode)typed
h—back to the inboxtyped
escclose the paneback to the inboxnormal mode
qclose the paneclose the panetyped

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.

History

  • Kept: every exchange is saved in the plugin's store under the session's id, so a reload, a restart or a --resume finds the conversation as it was, replies typed in the pane included. The 50 most recent sessions are kept.
  • Retroactive: on a session's first start with crosstalk, the history is rebuilt from the session's journal (~/.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.
  • Renames: a session renamed keeps its conversation once it writes again from the same socket, and a reply to a peer 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.

Install

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.

  • From a checkout, the plugin loads in place: an edit takes effect at the next session start or /reload-plugins, no reinstall.
  • From GitHub, it is a versioned copy: make update pulls a newer release.
  • The flag turns function hooks on for every session, so Claude Code's built-in mods load too (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.

Develop

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.

License

MIT © Kylian Bardini

Source 3 files
hooks/register.ts 549 lines
1import 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}
549
hooks/thread.ts 417 lines
1/**
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}
417
hooks/view.tsx 589 lines
1/* @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