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…

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.
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.
/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.
/chat once and it docks on the right.ctrl+x tab puts the cursor in the room. Type, Enter sends, Esc goes back to the prompt without interrupting Claude.ctrl+x x (or the ✕) closes the room for the session. /chat brings it back.| Command | What it does |
|---|---|
/chat <message> | Send from the prompt, at any time |
/chat name <handle> | Set your name: letters, digits, _ . -, up to 20 |
/chat off | Hide the room for this session |
/chat | Show 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.
CHATROOM_NAME, else the last name used on this machine, else a new anon-xxxx.CHATROOM_NAME=<name> claude.While Claude is working and the room is showing, the mod talks to the chat server:
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).
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.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
$ 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.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.MIT
hooks/register.tsx 515 lines1/**
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}
515hooks/chat.ts 258 lines1import 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}
258types/index.d.ts 18 lines1/** 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