SLOPSHOPPER

chatroom

One public, unmoderated chat room for everyone with this mod. It shows on the right of the screen while Claude works and hides when Claude waits for you…

newpanebandcommandnetworktimer
v0.1.0MITupdated 2026-10-06adamnroman/claude-chatroom/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chatroom
│ ┃ chatroom ✕ › fix the failing auth test and add an audit log call │ ┃ 0 here · Chat server error (0). │ ┃ No messages yet. Say hi. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /chat │ ┃ ⎿ chatroom: You are anon-e486. The room shows while Claude works: │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · chatroom
0 here · Chat server error (0). No messages yet. Say hi. say something ⏎ send
README

claude-chatroom

One public chat room inside Claude Code. It shows on the right of the screen while Claude works, like a stream chat, and hides when Claude waits for you.

  • Everyone with the mod is in the same room.
  • It is public and unmoderated. Anyone can read and post. Do not paste secrets, code you cannot share, or anything personal.
  • Nothing from the room reaches Claude. Messages are drawn on screen only.

Needs Claude Code 2.1.287 or newer. Mods are early access, so a Claude Code update can break this until the mod is updated.

Install

/plugin marketplace add adamnroman/claude-chatroom
/plugin install chatroom@claude-chatroom

Restart Claude Code. Give Claude something that takes a while and the room appears.

To leave: /chat off hides it for the session, /plugin uninstall chatroom@claude-chatroom removes it.

Use

  • While Claude works, the room opens. When Claude waits for you, it closes.
  • Terminal 144 columns or wider: docked on the right, full height, newest message at the bottom.
  • 110 to 143 columns: above the prompt at first. Run /chat once and it docks on the right.
  • Narrower: a framed block above the prompt.
  • ctrl+x tab puts the cursor in the room. Type, Enter sends, Esc goes back to the prompt without interrupting Claude.
  • A long message wraps onto more lines as you type. The left and right arrows move within the last line; backspace walks back through earlier ones.
  • ctrl+x x (or the ✕) closes the room for the session. /chat brings it back.
CommandWhat it does
/chat <message>Send from the prompt, at any time
/chat name <handle>Set your name: letters, digits, _ . -, up to 20
/chat offHide the room for this session
/chatShow the room again, and your name

Your own /chat <message> line is part of the transcript, so Claude can read what you typed there. Messages typed in the room itself are not.

Names

  • Names are not accounts. Anyone can use any name, including yours.
  • Each session picks a name once: CHATROOM_NAME, else the last name used on this machine, else a new anon-xxxx.
  • Two people on one machine: start each with CHATROOM_NAME=<name> claude.

Privacy

While Claude is working and the room is showing, the mod talks to the chat server:

  • Sent every few seconds: your handle and a random id made on your machine, so the room can count who is here. The id is not tied to your Claude account.
  • Sent when you post: your handle and the message.
  • Seen by the server: your IP address, used for rate limits and held in memory only. A small sample of requests is logged.
  • Kept: the last 200 messages, readable by everyone with the mod.

The mod sends nothing else: no prompts, no code, no file names, nothing about your session. It sends nothing while Claude is idle, when the room is hidden, or in headless runs (claude -p, CI).

How it works

  • turn.start opens the room's pane and the main loop's turn.complete closes it. Where no pane can be placed, the same room draws in a band above the prompt.
  • While the room shows, the mod asks the server for new messages. The server sets the pace (2 seconds by default) and the mod waits longer after each failure.
  • The server can close the room or retire an old version of the mod. Either one stops the mod for the session.
  • Text from the room is cleaned twice, on the server and again in the mod: control characters, bidi overrides and zero-width characters are removed, so a message cannot send escape sequences to your terminal.
  • The server is not part of this repository.

Develop

claude --plugin-dir mod                        # load it from disk
cd mod && claude plugin validate . && claude plugin test .
npx -p typescript tsc -p mod                   # after the mod has loaded once
CHATROOM_URL=http://127.0.0.1:8787 claude --plugin-dir mod   # against another server
  • Every $ call lives in mod/hooks/register.tsx. The validator refuses a module that passes $ across an import, so mod/hooks/chat.ts holds only pure helpers.
  • Claude Code's Input is one line. The mod wraps a long draft itself: earlier lines are drawn as text, the field holds the last one.
  • CLIENT_VERSION in mod/hooks/chat.ts must match version in mod/.claude-plugin/plugin.json.

License

MIT

Source 3 files
hooks/register.tsx 515 lines
1/**
2 * chatroom: one shared, public, unmoderated chat room for everyone with this mod.
3 *
4 * The room opens in a pane while Claude works and closes when Claude waits for
5 * you: docked on the right of the transcript in the fullscreen layout, above
6 * the prompt otherwise. ctrl+x tab focuses it to type; Esc goes back.
7 * `/chat <message>` sends from the prompt, `/chat name <handle>` sets your name,
8 * `/chat off` hides the room for the session.
9 *
10 * Nothing from the room reaches the model: messages are drawn in the pane only,
11 * never written to the transcript.
12 */
13import { atom, read, update } from 'claude-code'
14import type { Elements, EngineInterface, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
15
16import type { ChatMessage } from '../types'
17import {
18  BAND_CHROME_COLUMNS,
19  BAND_CHROME_ROWS,
20  BAND_MAX_ROWS,
21  CLIENT_HEADER,
22  CLIENT_ID,
23  cleanName,
24  cleanText,
25  colorFor,
26  CURSOR_COLUMNS,
27  DEFAULT_POLL_MS,
28  DEFAULT_SERVER,
29  DOCK_COLUMNS,
30  fitMessages,
31  HEADER_ROWS,
32  INLINE_ROWS,
33  INPUT_LABEL_COLUMNS,
34  mergeMessages,
35  noticeFor,
36  PANE_CHROME_COLUMNS,
37  PANE_ID,
38  PANE_TITLE,
39  parseMessages,
40  parseRoom,
41  pollDelay,
42  randomHex,
43  ROOM_PADDING_X,
44  SEND_NOTICE_MS,
45  STOP_STATUSES,
46  STUCK_POLL_MS,
47  SUBMIT_LABEL,
48  wrapLines,
49} from './chat'
50
51type Room = {
52  messages: readonly ChatMessage[]
53  online: number
54  notice: string | null
55  /** Rows for the whole room: the header, the messages and the input. */
56  rows: number
57  columns: number
58  /** The band draws its own frame; the engine frames a pane. */
59  hasFrame: boolean
60  /** Says it in the room; resolves the problem to show, or null once it is sent. */
61  onSubmit: (text: string) => Promise<string | null>
62  /** The input needs more or fewer rows than it has: draw again. */
63  onResize: () => void
64}
65
66const COMMAND = 'chat'
67const INPUT_KEY = 'say'
68const STORE_NAME = 'name'
69const STORE_CLIENT = 'client'
70const UNREACHABLE = 'Chat server unreachable.'
71const NAME_RULES = 'Names use letters, digits, _ . and - (up to 20).'
72const TYPE_HINT = 'ctrl+x tab to type'
73// Field keys never repeat, also across a reload of this module.
74const FIELD_SEED = randomHex(3)
75
76const messagesAtom = atom({ plugin: 'chatroom', key: 'messages' } as const, [])
77const onlineAtom = atom({ plugin: 'chatroom', key: 'online' } as const, 0)
78const nameAtom = atom({ plugin: 'chatroom', key: 'name' } as const, null)
79const noticeAtom = atom({ plugin: 'chatroom', key: 'notice' } as const, null)
80
81// A turn is running on a surface someone can see.
82let isTurnRunning = false
83// The room is on screen for that turn, and polls.
84let isShowing = false
85// The person hid the room: it stays away until they run /chat.
86let isDismissed = false
87// The server ended the room for this session: closed, gone, or this copy is too old.
88let isRoomOff = false
89// The pace the server asked for, and how many requests in a row have failed.
90let pollMs = DEFAULT_POLL_MS
91let failures = 0
92// When the request in flight began; 0 with none in flight.
93let pollStartedAt = 0
94let pollTimer: Timer | null = null
95// The server's history marker. A change means messages were removed.
96let roomEpoch: number | null = null
97// A refused send keeps its reason on screen until then.
98let noticeHeldUntil = 0
99// What the person has typed so far. A redraw for a new message must not wipe it.
100let draft = ''
101// Names the input field. The engine remembers a field's own text by its key, so
102// each time the room must put other text in the field it draws a new one.
103let fieldEpoch = 0
104// How much of the draft sat in front of the field when it was last drawn.
105let fieldSplit = 0
106
107/** Runs work nobody awaits. A session that ends mid-request is not an error. */
108function detached(work: Promise<unknown>): void {
109  work.catch(() => {})
110}
111
112async function serverUrl($: EngineInterface): Promise<string> {
113  return ((await $.env.get('CHATROOM_URL')) || DEFAULT_SERVER).replace(/\/+$/, '')
114}
115
116/**
117 * Who this session is in the room. The handle is the session's own, so two
118 * sessions on one machine can be two people: CHATROOM_NAME or `/chat name`
119 * sets it, else the machine's last handle, else a new `anon-xxxx`.
120 */
121async function identity($: EngineInterface): Promise<{ name: string; client: string }> {
122  let name = await read($, nameAtom)
123
124  if (!name) {
125    const stored = cleanName(await $.store.get(STORE_NAME))
126    const chosen = cleanName(await $.env.get('CHATROOM_NAME')) || stored || `anon-${randomHex(2)}`
127    name = chosen
128    await update($, nameAtom, () => chosen)
129
130    if (!stored) {
131      await $.store.set(STORE_NAME, chosen)
132    }
133  }
134
135  let machine = await $.store.get(STORE_CLIENT)
136
137  if (typeof machine !== 'string' || !machine) {
138    machine = randomHex(8)
139    await $.store.set(STORE_CLIENT, machine)
140  }
141
142  // One person with several sessions counts once in "N here"; two handles count twice.
143  return { name, client: `${String(machine)}-${name}` }
144}
145
146/** Shows `notice` in the room. A held one stays until its time is up, whatever the next poll says. */
147async function showNotice($: EngineInterface, notice: string | null, isHeld = false): Promise<void> {
148  const now = await $.clock.now()
149
150  if (isHeld) {
151    noticeHeldUntil = now + SEND_NOTICE_MS
152  } else if (notice === null && now < noticeHeldUntil) {
153    return
154  }
155
156  if ((await read($, noticeAtom)) !== notice) {
157    await update($, noticeAtom, () => notice)
158  }
159}
160
161/** Asks the room for news once. Resolves true when it should be asked again at once. */
162async function poll($: EngineInterface, server: string): Promise<boolean> {
163  const now = await $.clock.now()
164
165  if (pollStartedAt !== 0 && now - pollStartedAt < STUCK_POLL_MS) {
166    return false
167  }
168
169  pollStartedAt = now
170
171  try {
172    const { client } = await identity($)
173    const after = (await read($, messagesAtom)).at(-1)?.id ?? 0
174    const response = await $.http.fetch(`${server}/messages?after=${after}&client=${client}`, {
175      headers: { [CLIENT_HEADER]: CLIENT_ID },
176    })
177
178    if (!response.ok) {
179      failures++
180
181      if (STOP_STATUSES.includes(response.status)) {
182        isRoomOff = true
183      }
184
185      await showNotice($, noticeFor(response.status, response.text))
186
187      return false
188    }
189
190    const room = parseRoom(response.text)
191    const isHistoryChanged = (roomEpoch !== null && room.epoch !== roomEpoch) || room.latest < after
192    failures = 0
193    pollMs = room.pollMs
194    roomEpoch = room.epoch
195
196    if (isHistoryChanged) {
197      // The server removed messages: drop what is held and ask for the room afresh.
198      await update($, messagesAtom, () => [])
199    } else if (room.messages.length > 0) {
200      await update($, messagesAtom, current => mergeMessages(current, room.messages))
201    }
202
203    if (room.online !== (await read($, onlineAtom))) {
204      await update($, onlineAtom, () => room.online)
205    }
206
207    await showNotice($, null)
208
209    return isHistoryChanged
210  } catch {
211    failures++
212    await showNotice($, UNREACHABLE)
213
214    return false
215  } finally {
216    pollStartedAt = 0
217  }
218}
219
220/** Asks the room for news now, then again at the server's pace for as long as the room shows. */
221async function pollWhileShowing($: EngineInterface, server: string): Promise<void> {
222  pollTimer?.cancel()
223  pollTimer = null
224
225  if (!isShowing || isRoomOff) {
226    return
227  }
228
229  const isAskingAgainNow = await poll($, server)
230
231  if (isShowing && !isRoomOff) {
232    pollTimer = $.clock.after(isAskingAgainNow ? 0 : pollDelay(pollMs, failures), () => detached(pollWhileShowing($, server)))
233  }
234}
235
236/** Says `raw` in the room; resolves the problem to show, or null once it is sent. */
237async function send($: EngineInterface, server: string, raw: string): Promise<string | null> {
238  const text = cleanText(raw)
239
240  if (!text) {
241    return 'Nothing to send.'
242  }
243
244  if (isRoomOff) {
245    return 'The chat room is closed.'
246  }
247
248  const { name } = await identity($)
249  let notice: string | null = null
250
251  try {
252    const response = await $.http.fetch(`${server}/messages`, {
253      method: 'POST',
254      headers: { [CLIENT_HEADER]: CLIENT_ID, 'content-type': 'application/json' },
255      body: JSON.stringify({ name, text }),
256    })
257
258    if (response.ok) {
259      const sent = parseMessages([(JSON.parse(response.text) as { message?: unknown }).message])
260      await update($, messagesAtom, current => mergeMessages(current, sent))
261    } else {
262      if (STOP_STATUSES.includes(response.status)) {
263        isRoomOff = true
264      }
265
266      notice = noticeFor(response.status, response.text)
267    }
268  } catch {
269    notice = UNREACHABLE
270  }
271
272  await showNotice($, notice, notice !== null)
273
274  return notice
275}
276
277/** Shows the room for the running turn: opens its pane and starts asking for news. */
278async function showRoom($: EngineInterface): Promise<void> {
279  isShowing = true
280
281  try {
282    await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: DOCK_COLUMNS, rows: INLINE_ROWS })
283  } catch {
284    // No pane here: the band above the prompt draws the room.
285  }
286
287  $.ui.invalidate('ui.render')
288  detached(pollWhileShowing($, await serverUrl($)))
289}
290
291async function hideRoom($: EngineInterface): Promise<void> {
292  isShowing = false
293  pollTimer?.cancel()
294  pollTimer = null
295  await $.ui.close({ id: PANE_ID })
296}
297
298/** The room: who is here, the newest messages bottom-up, and the line to type in. */
299function drawRoom(kit: Elements[RenderSurface], room: Room): RenderElement {
300  const { Box, Text } = kit
301  // The engine's field is one line and cuts what does not fit. So the draft is
302  // wrapped here: its earlier lines are drawn as text, the field holds the last
303  // one, and the messages give up a row for each line.
304  const fieldColumns = room.columns - INPUT_LABEL_COLUMNS - CURSOR_COLUMNS
305  const lines = wrapLines(draft, fieldColumns)
306  const inputRows = Math.min(lines.length, Math.max(1, room.rows - HEADER_ROWS - 1))
307  const messageRows = Math.max(1, room.rows - HEADER_ROWS - inputRows)
308  const fieldText = lines.at(-1) ?? ''
309  // What the field does not hold: this drawing's handlers put it back in front of what is typed.
310  const typedBefore = draft.slice(0, draft.length - fieldText.length)
311  const earlierLines = inputRows > 1 ? lines.slice(0, -1).slice(1 - inputRows) : []
312
313  // The break moved (more typing, or the room changed width): the field's text is another, so the field is too.
314  if (typedBefore.length !== fieldSplit) {
315    fieldSplit = typedBefore.length
316    fieldEpoch++
317  }
318
319  const epochDrawn = fieldEpoch
320  const shown = fitMessages(room.messages, messageRows, room.columns)
321  const frame = room.hasFrame ? ({ borderStyle: 'round', borderDimColor: true } as const) : {}
322
323  return (
324    <Box flexDirection="column" height={room.rows + (room.hasFrame ? BAND_CHROME_ROWS : 0)} paddingX={ROOM_PADDING_X} {...frame}>
325      <Box>
326        <Text dimColor>{String(room.online)} here · </Text>
327        {room.notice ? <Text color="red">{room.notice}</Text> : <Text dimColor>{TYPE_HINT}</Text>}
328      </Box>
329      <Box flexDirection="column" height={messageRows} justifyContent="flex-end" overflow="hidden">
330        {shown.length === 0 && <Text dimColor>No messages yet. Say hi.</Text>}
331        {shown.map(message => (
332          <Text wrap="wrap">
333            <Text color={colorFor(message.name)} bold>
334              {message.name}
335            </Text>{' '}
336            {message.text}
337          </Text>
338        ))}
339      </Box>
340      {earlierLines.map(line => (
341        <Text wrap="truncate-end">{line}</Text>
342      ))}
343      {'Input' in kit && (
344        <kit.Input
345          key={`${INPUT_KEY}-${FIELD_SEED}-${epochDrawn}`}
346          autoFocus
347          placeholder="say something"
348          value={fieldText}
349          submitLabel={SUBMIT_LABEL}
350          onInput={value => {
351            draft = typedBefore + value
352            const isStaleField = epochDrawn !== fieldEpoch
353
354            // A key typed into a field that has since been replaced needs a fresh field to show it.
355            if (isStaleField) {
356              fieldEpoch++
357            }
358
359            if (isStaleField || wrapLines(draft, fieldColumns).length !== lines.length) {
360              room.onResize()
361            }
362          }}
363          onSubmit={value => {
364            const text = typedBefore + value
365            draft = ''
366            fieldEpoch++
367            room.onResize()
368            detached(
369              room.onSubmit(text).then(problem => {
370                // A refused message goes back in the field, unless the person has typed since.
371                if (problem !== null && draft === '') {
372                  draft = text
373                  fieldEpoch++
374                  room.onResize()
375                }
376              }),
377            )
378          }}
379        />
380      )}
381    </Box>
382  )
383}
384
385export const register: Register = on => {
386  on('session.start', async ($, e, next) => {
387    const started = await next(e)
388
389    try {
390      await $.command.register({
391        name: COMMAND,
392        description: 'Chat room: /chat <message> to say something, /chat name <handle>, /chat off',
393        argumentHint: '<message> | name <handle> | off',
394        immediate: true,
395      })
396    } catch {
397      // Another /chat is taken: the room still works from its pane.
398    }
399
400    return started
401  })
402
403  on('turn.start', async ($, e, next) => {
404    // A headless run (claude -p, CI) draws nowhere: no room, no requests, nobody counted as here.
405    isTurnRunning = (await $.session.surfaces()).length > 0
406
407    if (isTurnRunning && !isDismissed && !isRoomOff) {
408      await showRoom($)
409    }
410
411    return next(e)
412  })
413
414  on('turn.complete', async ($, e, next) => {
415    if (!e.agentId) {
416      isTurnRunning = false
417      await hideRoom($)
418    }
419
420    return next(e)
421  })
422
423  on('ui.close', { id: PANE_ID }, ($, e, next) => {
424    if (e.origin.kind === 'person') {
425      isDismissed = true
426      isShowing = false
427      pollTimer?.cancel()
428    }
429
430    return next(e)
431  })
432
433  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
434    const server = await serverUrl($)
435
436    return drawRoom($.ui.resolve(e), {
437      messages: await read($, messagesAtom),
438      online: await read($, onlineAtom),
439      notice: await read($, noticeAtom),
440      rows: e.props.scroll.bodyRows,
441      columns: Math.max(10, e.props.bodyColumns - PANE_CHROME_COLUMNS),
442      hasFrame: false,
443      onSubmit: text => send($, server, text),
444      onResize: () => $.ui.invalidate('ui.render'),
445    })
446  })
447
448  // Where the surface places no pane, the room draws in the band above the prompt.
449  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
450    if (!isShowing || !e.props.isWorking || e.props.hasSurvey) {
451      return next(e)
452    }
453
454    if ((await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)) {
455      return next(e)
456    }
457
458    const server = await serverUrl($)
459
460    return drawRoom($.ui.resolve(e), {
461      messages: await read($, messagesAtom),
462      online: await read($, onlineAtom),
463      notice: await read($, noticeAtom),
464      rows: Math.min(e.props.maxRows, BAND_MAX_ROWS) - BAND_CHROME_ROWS,
465      columns: Math.max(10, e.props.bodyColumns - BAND_CHROME_COLUMNS),
466      hasFrame: true,
467      onSubmit: text => send($, server, text),
468      onResize: () => $.ui.invalidate('ui.render'),
469    })
470  })
471
472  on('command.run', { command: COMMAND }, async ($, e) => {
473    const args = e.args.trim()
474    const words = args.split(/\s+/)
475    const [first = '', second = ''] = words
476
477    if (!args || args === 'on') {
478      const { name } = await identity($)
479      isDismissed = false
480
481      if (isTurnRunning && !isRoomOff) {
482        await showRoom($)
483      }
484
485      return {
486        text: `You are ${name}. The room shows while Claude works: ${TYPE_HINT}, or /chat <message>. /chat name <handle> changes your name, /chat off hides the room.`,
487      }
488    }
489
490    if (args === 'off') {
491      isDismissed = true
492      await hideRoom($)
493
494      return { text: 'Chat hidden for this session. /chat brings it back.' }
495    }
496
497    // Only "/chat name <one word>" renames: a longer line starting with "name" is a message.
498    if (first === 'name' && words.length === 2) {
499      const name = cleanName(second)
500
501      if (!name) {
502        return { text: NAME_RULES }
503      }
504
505      await update($, nameAtom, () => name)
506      await $.store.set(STORE_NAME, name)
507
508      return { text: `You are ${name} in the chat.` }
509    }
510
511    // The reply never echoes the room: whatever the command prints, the model reads.
512    return { text: (await send($, await serverUrl($), args)) ?? 'Sent.' }
513  })
514}
515
hooks/chat.ts 258 lines
1import type { ChatMessage } from '../types'
2
3// Where the room lives. CHATROOM_URL overrides it for development.
4export const DEFAULT_SERVER = 'https://claude-chatroom.adamnroman.workers.dev'
5export const CLIENT_HEADER = 'x-chatroom-client'
6// Keep in step with plugin.json's version: the server reads it to retire old copies.
7export const CLIENT_VERSION = '0.1.0'
8export const CLIENT_ID = `claude-code-chatroom/${CLIENT_VERSION}`
9
10// The server says how often to ask (poll_ms); the mod obeys within these bounds.
11export const DEFAULT_POLL_MS = 2000
12export const MIN_POLL_MS = 2000
13export const MAX_POLL_MS = 300_000
14// After a failure each wait doubles, up to this.
15export const MAX_BACKOFF_MS = 60_000
16// A request that has not come back by now no longer blocks the next one.
17export const STUCK_POLL_MS = 15_000
18// A refused send keeps its reason on screen this long.
19export const SEND_NOTICE_MS = 6000
20// The server's answers that end the room for this session: gone, this copy is too old, closed.
21export const STOP_STATUSES: readonly number[] = [410, 426, 503]
22export const KEEP_MESSAGES = 100
23export const MAX_ONLINE = 1_000_000
24
25// The room is a pane: docked on the right in the fullscreen layout, else above the prompt.
26export const PANE_ID = 'chatroom'
27export const PANE_TITLE = 'chat'
28export const DOCK_COLUMNS = 44
29export const INLINE_ROWS = 10
30export const ROOM_PADDING_X = 1
31export const HEADER_ROWS = 1
32export const SUBMIT_LABEL = 'send'
33// The field draws " ⏎ send" beside the text while it has the keyboard.
34export const INPUT_LABEL_COLUMNS = ` ⏎ ${SUBMIT_LABEL}`.length
35export const CURSOR_COLUMNS = 1
36// Padding on each side.
37export const PANE_CHROME_COLUMNS = ROOM_PADDING_X * 2
38// Where no pane can be placed, the room falls back to a band above the prompt.
39export const BAND_MAX_ROWS = 10
40// The band's own border, top and bottom.
41export const BAND_CHROME_ROWS = 2
42// Border and padding on each side.
43export const BAND_CHROME_COLUMNS = 4
44export const MAX_TEXT_CHARS = 280
45export const MAX_NAME_CHARS = 20
46
47export type RoomUpdate = {
48  messages: ChatMessage[]
49  online: number
50  pollMs: number
51  /** Changes when the server removed history. */
52  epoch: number
53  /** The newest id the server has given out. */
54  latest: number
55}
56
57const NAME_COLORS = ['cyan', 'green', 'yellow', 'magenta', 'blue', 'red'] as const
58
59// Control characters would reach the terminal as escape sequences, bidi marks
60// can make a line read differently than it is, zero-width characters make
61// blank or look-alike messages. The server strips these too; a reader never
62// trusts the room to have done it.
63// Written as escapes on purpose: the characters themselves are invisible in an editor.
64const UNSAFE = /[\u0000-\u001F\u007F-\u009F\u061C\u180E\u200B\u200E\u200F\u202A-\u202E\u2060\u2066-\u2069\uFEFF]/g
65// More than two combining marks on one character is decoration stacked over other lines.
66const STACKED_MARKS = /(\p{M}{2})\p{M}+/gu
67const NAME_UNSAFE = /[^A-Za-z0-9_.-]/g
68const HIGH_SURROGATE = /[\uD800-\uDBFF]/
69
70/** One line of chat text, safe to draw in a terminal, or '' when nothing is left. */
71export function cleanText(raw: unknown): string {
72  if (typeof raw !== 'string') {
73    return ''
74  }
75
76  const line = raw.replace(/\s+/g, ' ').replace(UNSAFE, '').replace(STACKED_MARKS, '$1').trim()
77
78  return Array.from(line).slice(0, MAX_TEXT_CHARS).join('')
79}
80
81/** A handle the server accepts, or '' when nothing usable is left. */
82export function cleanName(raw: unknown): string {
83  return typeof raw === 'string' ? raw.replace(NAME_UNSAFE, '').slice(0, MAX_NAME_CHARS) : ''
84}
85
86export function colorFor(name: string): string {
87  let hash = 0
88
89  for (const char of name) {
90    hash = (hash * 31 + char.charCodeAt(0)) >>> 0
91  }
92
93  return NAME_COLORS[hash % NAME_COLORS.length] ?? 'cyan'
94}
95
96/** `count` random bytes as hex, for a handle or a client id. */
97export function randomHex(count: number): string {
98  return Array.from(crypto.getRandomValues(new Uint8Array(count)), byte => byte.toString(16).padStart(2, '0')).join('')
99}
100
101/** Only what the server sent that looks like a message, cleaned again for the terminal. */
102export function parseMessages(raw: unknown): ChatMessage[] {
103  if (!Array.isArray(raw)) {
104    return []
105  }
106
107  return raw.slice(-KEEP_MESSAGES).flatMap(item => {
108    const { id, name, text, at } = (item ?? {}) as Record<string, unknown>
109    const clean = cleanText(text)
110
111    return Number.isSafeInteger(id) && (id as number) > 0 && clean
112      ? [{ id: id as number, name: cleanName(name) || 'anon', text: clean, at: Number(at) || 0 }]
113      : []
114  })
115}
116
117const wholeNumber = (raw: unknown, fallback: number): number => (Number.isSafeInteger(raw) && (raw as number) >= 0 ? (raw as number) : fallback)
118
119export const clampPoll = (ms: number): number => Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms))
120
121/** A poll's answer, with every field checked: nothing the server says is used as it came. */
122export function parseRoom(body: string): RoomUpdate {
123  const raw = JSON.parse(body) as Record<string, unknown> | null
124  const messages = parseMessages(raw?.messages)
125
126  return {
127    messages,
128    online: Math.min(MAX_ONLINE, wholeNumber(raw?.online, 0)),
129    pollMs: clampPoll(wholeNumber(raw?.poll_ms, DEFAULT_POLL_MS)),
130    epoch: wholeNumber(raw?.epoch, 0),
131    latest: wholeNumber(raw?.latest, messages.at(-1)?.id ?? 0),
132  }
133}
134
135/** How long to wait before asking again: the server's pace, doubled for each failure in a row. */
136export function pollDelay(pollMs: number, failures: number): number {
137  return failures === 0 ? pollMs : Math.min(MAX_BACKOFF_MS, pollMs * 2 ** Math.min(failures, 10))
138}
139
140/** Old and new together, by id, oldest first, the latest KEEP_MESSAGES. */
141export function mergeMessages(current: readonly ChatMessage[], incoming: readonly ChatMessage[]): ChatMessage[] {
142  const byId = new Map(current.map(message => [message.id, message]))
143
144  for (const message of incoming) {
145    byId.set(message.id, message)
146  }
147
148  return [...byId.values()].sort((a, b) => a.id - b.id).slice(-KEEP_MESSAGES)
149}
150
151/** How many rows `line` takes once word-wrapped at `columns`; a word longer than a row breaks across rows. */
152export function wrappedRows(line: string, columns: number): number {
153  const width = Math.max(1, columns)
154  let rows = 1
155  let used = 0
156
157  for (const word of line.split(' ')) {
158    let length = Array.from(word).length
159
160    if (used > 0 && used + 1 + length <= width) {
161      used += 1 + length
162      continue
163    }
164
165    if (used > 0) {
166      rows++
167    }
168
169    while (length > width) {
170      rows++
171      length -= width
172    }
173
174    used = length
175  }
176
177  return rows
178}
179
180/**
181 * `text` cut into lines of at most `width` characters, broken after a space
182 * where there is one. The lines joined are `text` exactly, so a prefix of
183 * lines is a prefix of the text, and only an empty text gives an empty line:
184 * taking the last line away always leaves one fewer.
185 */
186export function wrapLines(text: string, width: number): string[] {
187  const size = Math.max(1, width)
188  const lines: string[] = []
189  let rest = text
190
191  while (rest.length > size) {
192    const space = rest.slice(0, size).lastIndexOf(' ')
193    let cut = space > 0 ? space + 1 : size
194
195    // Never between the two halves of one character.
196    if (cut > 1 && HIGH_SURROGATE.test(rest.charAt(cut - 1))) {
197      cut -= 1
198    }
199
200    lines.push(rest.slice(0, cut))
201    rest = rest.slice(cut)
202  }
203
204  lines.push(rest)
205
206  return lines
207}
208
209/** How many terminal rows a message takes with its name in front, wrapped at `columns`. */
210export function rowsFor(message: ChatMessage, columns: number): number {
211  return wrappedRows(`${message.name} ${message.text}`, columns)
212}
213
214/** The newest messages that fit in `rows` rows, oldest first. */
215export function fitMessages(messages: readonly ChatMessage[], rows: number, columns: number): ChatMessage[] {
216  const shown: ChatMessage[] = []
217  let used = 0
218
219  for (let index = messages.length - 1; index >= 0; index--) {
220    const message = messages[index]
221
222    if (!message) {
223      continue
224    }
225
226    used += rowsFor(message, columns)
227
228    if (used > rows) {
229      break
230    }
231
232    shown.unshift(message)
233  }
234
235  return shown
236}
237
238/** What a failed request means for the person, in a few words. */
239export function noticeFor(status: number, body: string): string {
240  if (status === 426) {
241    return 'This copy of the chat mod is too old. Update the chatroom plugin to rejoin.'
242  }
243
244  if (status === 503 || status === 410) {
245    return 'The chat room is closed.'
246  }
247
248  if (status === 429) {
249    if (body.includes('busy')) {
250      return 'The room is busy. Try again in a moment.'
251    }
252
253    return body.includes('rate_limited') ? 'Too many requests from your network.' : 'Slow down: one message a second.'
254  }
255
256  return body.includes('empty') ? 'Nothing to send.' : `Chat server error (${status}).`
257}
258
types/index.d.ts 18 lines
1/** One line of the room, as the server keeps it. */
2export type ChatMessage = { id: number; name: string; text: string; at: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    chatroom: {
7      /** The latest messages, oldest first. */
8      messages: ChatMessage[]
9      /** How many people polled the room in the last 30 seconds. */
10      online: number
11      /** This session's handle; null until the session first needs one. */
12      name: string | null
13      /** A problem worth showing in the band (server down, too fast); null when all is well. */
14      notice: string | null
15    }
16  }
17}
18