Passes prompts from a planning session to the session that does the work and its answers back, without copy and paste

Passes prompts from a planning session to the session that does the work, and its answers back, without copy and paste.
One session plans and writes the prompts, the lead. Another one carries them out, the worker. Without this mod every prompt and every report travels through the clipboard, and text copied out of the fullscreen terminal loses characters at its line ends: a prompt arrives with "the real foed" where "the real footage" was written. Here both directions are a press, and the text arrives as it was written.
| Where | Command |
|---|---|
| The lead's folder | /relay lead <channel> |
| The worker's folder | /relay worker [channel]; without a name the folder's name, clued for ~/Projects/Clued |
Each folder remembers its role: every later session started there joins the channel by itself, /clear included. /relay off ends that.
| When | Where | What |
|---|---|---|
| The lead's answer holds a prompt in a fenced block | lead, band above the prompt | Send, Send as fresh session, Other block when there are several, View, Dismiss |
| A task arrived | worker, band | Clear & send runs /clear first, Send here goes into the running conversation; the one the lead chose is the primary button. View, Discard |
| The worker finished a turn | lead, toast, notification and band | Attach to next prompt, Forward now, View, Discard |
| A session is linked | footer under the prompt | lead ⇄ clued ⚙ 12m 📨 1, worker ⇄ clued worker 📥 |
In the footer, offline means no worker session is open, ⚙ 12m that the worker has been busy for 12 minutes, 📨 answers waiting and 📎 answers attached to the next prompt; on the worker, 📥 is a waiting task and ⏸ that another session in the folder is the worker. The pane spells the same out in words.
Attach to next prompt sends the answers along with the next prompt you type in the lead, as context the model reads beside it: you type only your feedback. Forward now sends them at once as your message.
A fenced block counts as a prompt from 300 characters on, when it has no language or is tagged text, markdown or prompt. Blocks tagged bash, json and the like are code to read and never offered.
| Command | Effect |
|---|---|
/relay | A pane with everything pending in full, and the actions; in the desktop app the prompt, the answers and the task are cards that show their Markdown, each under a bar that names it and carries its actions |
/relay send [text] | Sends the text as typed; without text the last fenced block of the last answer |
/relay off | Leaves the channel; this folder no longer joins it |
A task waits in the worker until you press Clear & send or Send here there, and an answer waits in the lead until you attach or forward it. The relay travels through files any process of yours can write; the press in the session itself is what makes the text your prompt. The worker then reads the task as your own words.
Everything lives under ~/.claude/relay/<channel>/: one JSON file per task in to-worker/, one per answer in to-lead/, and worker.json, which names the worker session, whether it is working, and a heartbeat every 30 seconds. Each linked session looks every two seconds.
So it works between the terminal and the desktop app alike, and nothing is lost while a session is closed: an answer that arrives at night waits in the lead's band the next morning, and a task sent to a worker nobody has opened yet is there when it opens.
/relay send takes the last block later./relay opens the pane there.mod-hub loaded, the desktop footer leaves the relay label to its 🧰, whose Relay row shows it and opens the pane.| Option | Default | Meaning |
|---|---|---|
minPromptChars | 300 | Shortest fenced block offered as a prompt |
notify | true | Raise a notification in the lead when an answer arrives |
Change them in /config, or in ~/.claude/settings.json under pluginConfigs["session-relay"].options.
Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. Pending tasks and answers stay in ~/.claude/relay/ until you delete it.
hooks/register.tsx 1057 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { Answer, Link, Presence, Task } from '../types'
5import type { HubCall, HubEntry } from './hub'
6import { bandCard, barCard, buttonWidthOf, headerCard, messageCards } from './look'
7import type { Card } from './look'
8import { ROOMY_WIDTH, cardWidthOf } from './svg'
9import {
10 USAGE,
11 channelOf,
12 folderNameOf,
13 footerOf,
14 formatChars,
15 forwardTextOf,
16 isAnswer,
17 isLink,
18 isOnline,
19 isPresence,
20 isTask,
21 newId,
22 parseCommand,
23 previewOf,
24 promptBlocksOf,
25 statusOf,
26} from './relay'
27
28const PANE = 'relay'
29const LINK_PREFIX = 'link:'
30const POLL_MS = 2_000
31const HEARTBEAT_MS = 30_000
32const MAX_ANSWER_CHARS = 100_000
33const MAX_ANSWERS = 10
34
35const link = atom({ plugin: 'session-relay', key: 'link' } as const, null)
36const tasks = atom({ plugin: 'session-relay', key: 'tasks' } as const, [])
37const answers = atom({ plugin: 'session-relay', key: 'answers' } as const, [])
38const offer = atom({ plugin: 'session-relay', key: 'offer' } as const, null)
39const isAttached = atom({ plugin: 'session-relay', key: 'isAttached' } as const, false)
40const presence = atom({ plugin: 'session-relay', key: 'presence' } as const, null)
41const isActive = atom({ plugin: 'session-relay', key: 'isActive' } as const, false)
42const footer = atom({ plugin: 'session-relay', key: 'footer' } as const, null)
43const isHubbed = atom({ plugin: 'session-relay', key: 'isHubbed' } as const, false)
44
45// Set from the plugin's options when the module registers.
46let settings = { minPromptChars: 300, isNotifying: true }
47let poller: Timer | undefined
48let isPolling = false
49let isFirstPoll = true
50let lastHeartbeatAt = 0
51let published: string | null = null
52
53// Without mod-hub the call throws, and the mod keeps its own footer label.
54async function publish($: EngineInterface, entry: HubEntry): Promise<void> {
55 const text = JSON.stringify(entry)
56 if (text === published) {
57 return
58 }
59 let isReached = true
60 try {
61 await ($ as unknown as HubCall).hub.set({ id: 'session-relay', ...entry })
62 } catch {
63 isReached = false
64 }
65 published = isReached ? text : null
66 if ((await read($, isHubbed)) !== isReached) {
67 await update($, isHubbed, () => isReached)
68 }
69}
70const announced = new Set<string>()
71// Answers can be long and are re-listed every poll; a file's content never
72// changes once written, so each is read once.
73const fileCache = new Map<string, unknown>()
74
75type ChannelPaths = { toWorker: string; toLead: string; presence: string }
76
77async function relayDirectory($: EngineInterface): Promise<string> {
78 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
79 const configDirectory = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
80
81 return `${configDirectory}/relay`
82}
83
84async function pathsOf($: EngineInterface, channel: string): Promise<ChannelPaths> {
85 const base = `${await relayDirectory($)}/${channel}`
86
87 return { toWorker: `${base}/to-worker`, toLead: `${base}/to-lead`, presence: `${base}/worker.json` }
88}
89
90async function readJson($: EngineInterface, path: string): Promise<unknown> {
91 try {
92 if (!(await $.fs.exists(path))) {
93 return undefined
94 }
95
96 return JSON.parse(await $.fs.read(path)) as unknown
97 } catch {
98 return undefined
99 }
100}
101
102async function readInbox<T extends { id: string }>(
103 $: EngineInterface,
104 directory: string,
105 isValid: (value: unknown) => value is T,
106): Promise<T[]> {
107 if (!(await $.fs.exists(directory))) {
108 return []
109 }
110 const entries = await $.fs.list(directory)
111 const names = entries
112 .filter(i => i.kind === 'file' && i.name.endsWith('.json'))
113 .map(i => i.name)
114 .sort()
115 const items: T[] = []
116 for (const name of names) {
117 const path = `${directory}/${name}`
118 const value = fileCache.has(path) ? fileCache.get(path) : await readJson($, path)
119 if (isValid(value) && `${value.id}.json` === name) {
120 fileCache.set(path, value)
121 items.push(value)
122 }
123 }
124
125 return items
126}
127
128async function writeItem($: EngineInterface, directory: string, item: { id: string }): Promise<void> {
129 await $.fs.write(`${directory}/${item.id}.json`, JSON.stringify(item))
130}
131
132async function removeItems($: EngineInterface, directory: string, ids: readonly string[]): Promise<void> {
133 if (ids.length === 0) {
134 return
135 }
136 const paths = ids.map(i => `${directory}/${i}.json`)
137 paths.forEach(i => fileCache.delete(i))
138 await $.process.run(['rm', '-f', ...paths])
139}
140
141async function readPresence($: EngineInterface, channel: string): Promise<Presence | null> {
142 const value = await readJson($, (await pathsOf($, channel)).presence)
143
144 return isPresence(value) ? value : null
145}
146
147async function writePresence($: EngineInterface, channel: string, busySince: number | null): Promise<void> {
148 const now = await $.clock.now()
149 const value: Presence = { sessionId: await $.session.id(), busySince, seenAt: now }
150 await $.fs.write((await pathsOf($, channel)).presence, JSON.stringify(value))
151 lastHeartbeatAt = now
152 await update($, presence, () => value)
153 await update($, isActive, () => true)
154}
155
156// The worker of a channel is the session in its folder that was used last: a
157// prompt there or an explicit /relay worker takes the slot over, a session that
158// merely starts takes it only while no other worker is alive.
159async function claim($: EngineInterface, current: Link, isForced: boolean, busySince?: number | null): Promise<void> {
160 const held = await readPresence($, current.channel)
161 const now = await $.clock.now()
162 const sessionId = await $.session.id()
163 const isMine = held?.sessionId === sessionId
164 if (!isForced && !isMine && isOnline(held, now)) {
165 await update($, presence, () => held)
166 await update($, isActive, () => false)
167 return
168 }
169 await writePresence($, current.channel, busySince !== undefined ? busySince : isMine ? held.busySince : null)
170}
171
172async function refreshFooter($: EngineInterface): Promise<void> {
173 const current = await read($, link)
174 const label =
175 current === null
176 ? null
177 : footerOf({
178 link: current,
179 presence: await read($, presence),
180 answers: await read($, answers),
181 tasks: await read($, tasks),
182 isAttached: await read($, isAttached),
183 isActive: await read($, isActive),
184 now: await $.clock.now(),
185 })
186 if (label !== (await read($, footer))) {
187 await update($, footer, () => label)
188 }
189 const waiting = current === null ? 0 : current.role === 'lead' ? (await read($, answers)).length : (await read($, tasks)).length
190 await publish($, {
191 icon: '⇄',
192 title: 'Relay',
193 status: label === null ? 'Off · /relay lead or /relay worker links a session' : label.replace(/^⇄ /, ''),
194 attention: waiting > 0 ? 'info' : 'none',
195 actions: current === null ? [] : [{ key: 'open', label: 'Open' }],
196 })
197}
198
199async function announceAnswers($: EngineInterface, current: Link, items: readonly Answer[], isQuiet: boolean): Promise<void> {
200 const fresh = items.filter(i => !announced.has(i.id))
201 const latest = fresh.at(-1)
202 if (latest === undefined) {
203 return
204 }
205 fresh.forEach(i => announced.add(i.id))
206 const count = items.length === 1 ? '' : ` (${items.length} waiting)`
207 $.ui.toast(`${current.channel} answered${count}: ${previewOf(latest.text)}`, { timeoutMs: 10_000 })
208 if (!isQuiet && settings.isNotifying) {
209 await $.ui.notify(previewOf(latest.text, 120), { title: `${current.channel} finished` }).catch(() => undefined)
210 }
211}
212
213function announceTasks($: EngineInterface, items: readonly Task[]): void {
214 const fresh = items.filter(i => !announced.has(i.id))
215 const latest = fresh.at(-1)
216 if (latest === undefined) {
217 return
218 }
219 fresh.forEach(i => announced.add(i.id))
220 $.ui.toast(`Task from the lead: ${previewOf(latest.text)}`, { timeoutMs: 10_000 })
221}
222
223async function pollLead($: EngineInterface, current: Link, paths: ChannelPaths): Promise<void> {
224 const items = await readInbox($, paths.toLead, isAnswer)
225 await update($, answers, () => items)
226 if (items.length === 0) {
227 await update($, isAttached, () => false)
228 }
229 await announceAnswers($, current, items, isFirstPoll)
230 const held = await readPresence($, current.channel)
231 await update($, presence, () => held)
232}
233
234async function pollWorker($: EngineInterface, current: Link, paths: ChannelPaths): Promise<void> {
235 const held = await readPresence($, current.channel)
236 const now = await $.clock.now()
237 const isMine = held !== null && held.sessionId === (await $.session.id())
238 if (isMine && now - lastHeartbeatAt >= HEARTBEAT_MS) {
239 await writePresence($, current.channel, held.busySince)
240 } else if (!isMine && !isOnline(held, now)) {
241 await writePresence($, current.channel, null)
242 } else if (!isMine) {
243 await update($, presence, () => held)
244 await update($, isActive, () => false)
245 }
246 const items = await readInbox($, paths.toWorker, isTask)
247 await update($, tasks, () => items)
248 if (await read($, isActive)) {
249 announceTasks($, items)
250 }
251}
252
253async function poll($: EngineInterface): Promise<void> {
254 const current = await read($, link)
255 if (current === null || isPolling) {
256 return
257 }
258 isPolling = true
259 try {
260 const paths = await pathsOf($, current.channel)
261 if (current.role === 'lead') {
262 await pollLead($, current, paths)
263 } else {
264 await pollWorker($, current, paths)
265 }
266 isFirstPoll = false
267 await refreshFooter($)
268 } finally {
269 isPolling = false
270 }
271}
272
273function startPolling($: EngineInterface): void {
274 poller?.cancel()
275 poller = $.clock.every(POLL_MS, () => {
276 void poll($)
277 })
278}
279
280async function resetView($: EngineInterface): Promise<void> {
281 await update($, tasks, () => [])
282 await update($, answers, () => [])
283 await update($, offer, () => null)
284 await update($, isAttached, () => false)
285 await update($, presence, () => null)
286 await update($, isActive, () => false)
287 announced.clear()
288 fileCache.clear()
289}
290
291async function releaseWorkerSlot($: EngineInterface, current: Link | null): Promise<void> {
292 if (current?.role === 'worker' && (await read($, isActive))) {
293 await $.process.run(['rm', '-f', (await pathsOf($, current.channel)).presence])
294 }
295}
296
297async function join($: EngineInterface, next: Link, isExplicit: boolean): Promise<void> {
298 await releaseWorkerSlot($, await read($, link))
299 await $.store.set(`${LINK_PREFIX}${await $.session.root()}`, next)
300 await update($, link, () => next)
301 await resetView($)
302 isFirstPoll = true
303 if (next.role === 'worker') {
304 await claim($, next, isExplicit)
305 }
306 startPolling($)
307 await poll($)
308}
309
310async function leave($: EngineInterface): Promise<void> {
311 const current = await read($, link)
312 poller?.cancel()
313 poller = undefined
314 await $.store.delete(`${LINK_PREFIX}${await $.session.root()}`)
315 await releaseWorkerSlot($, current)
316 await update($, link, () => null)
317 await resetView($)
318 await refreshFooter($)
319 await $.ui.close({ id: PANE })
320}
321
322// A lead named without a channel joins the only channel there is, if one is.
323async function existingChannels($: EngineInterface): Promise<string[]> {
324 const directory = await relayDirectory($)
325 if (!(await $.fs.exists(directory))) {
326 return []
327 }
328 const entries = await $.fs.list(directory)
329
330 return entries.filter(i => i.kind === 'dir' && channelOf(i.name) === i.name).map(i => i.name)
331}
332
333async function sendTask($: EngineInterface, text: string, isFresh: boolean): Promise<void> {
334 const current = await read($, link)
335 if (current?.role !== 'lead') {
336 $.ui.toast('Only a lead sends tasks: /relay lead <channel> first.')
337 return
338 }
339 const paths = await pathsOf($, current.channel)
340 const pending = await readInbox($, paths.toWorker, isTask)
341 await removeItems($, paths.toWorker, pending.map(i => i.id))
342 const now = await $.clock.now()
343 const task: Task = { id: newId(now, Math.random()), text, isFresh, sentAt: now }
344 await writeItem($, paths.toWorker, task)
345 await update($, offer, () => null)
346 const held = await readPresence($, current.channel)
347 const notes = [
348 isFresh ? 'as a fresh session' : null,
349 pending.length > 0 ? 'replaces the task not picked up yet' : null,
350 isOnline(held, now) ? null : `no ${current.channel} session is open, it waits there`,
351 ].filter(i => i !== null)
352 $.ui.toast(`Sent to ${current.channel}${notes.length > 0 ? ` · ${notes.join(' · ')}` : ''}`)
353}
354
355async function sendLatestBlock($: EngineInterface): Promise<void> {
356 const current = await read($, offer)
357 const fromOffer = current?.blocks[current.index]
358 if (fromOffer !== undefined) {
359 await sendTask($, fromOffer, false)
360 return
361 }
362 const messages = await $.session.messages()
363 const lastAnswer = [...messages].reverse().find(i => i.role === 'assistant')
364 const block = promptBlocksOf(lastAnswer?.text ?? '', 1).at(-1)
365 if (block === undefined) {
366 $.ui.toast('The last answer holds no fenced block to send; /relay send <text> sends text as typed.')
367 return
368 }
369 await sendTask($, block, false)
370}
371
372async function takeTask($: EngineInterface, task: Task, isFresh: boolean): Promise<void> {
373 const current = await read($, link)
374 if (current?.role !== 'worker') {
375 return
376 }
377 const paths = await pathsOf($, current.channel)
378 await removeItems($, paths.toWorker, [task.id])
379 await update($, tasks, items => items.filter(i => i.id !== task.id))
380 await $.ui.close({ id: PANE })
381 try {
382 if (isFresh) {
383 await $.command.run({ command: 'clear' })
384 }
385 await claim($, current, true)
386 await $.prompt.submit({ text: task.text, asUser: true })
387 } catch (error) {
388 await writeItem($, paths.toWorker, task)
389 $.ui.toast(`session-relay could not hand the task over; it is kept. ${error instanceof Error ? error.message : String(error)}`)
390 }
391 await refreshFooter($)
392}
393
394async function discardTask($: EngineInterface, task: Task): Promise<void> {
395 const current = await read($, link)
396 if (current === null) {
397 return
398 }
399 await removeItems($, (await pathsOf($, current.channel)).toWorker, [task.id])
400 await update($, tasks, items => items.filter(i => i.id !== task.id))
401 await refreshFooter($)
402}
403
404async function dropAnswers($: EngineInterface, items: readonly Answer[]): Promise<void> {
405 const current = await read($, link)
406 if (current === null) {
407 return
408 }
409 const ids = items.map(i => i.id)
410 await removeItems($, (await pathsOf($, current.channel)).toLead, ids)
411 const remaining = await update($, answers, list => list.filter(i => !ids.includes(i.id)))
412 if (remaining.length === 0) {
413 await update($, isAttached, () => false)
414 }
415 await refreshFooter($)
416}
417
418async function attachAnswers($: EngineInterface, isOn: boolean): Promise<void> {
419 await update($, isAttached, () => isOn)
420 await refreshFooter($)
421 if (isOn) {
422 $.ui.toast('The answer goes with your next prompt.')
423 }
424}
425
426async function forwardAnswers($: EngineInterface): Promise<void> {
427 const current = await read($, link)
428 const items = await read($, answers)
429 if (current === null || items.length === 0) {
430 return
431 }
432 await $.ui.close({ id: PANE })
433 await $.prompt.submit({ text: forwardTextOf(current.channel, items), asUser: true })
434 await dropAnswers($, items)
435}
436
437async function report($: EngineInterface, current: Link, text: string): Promise<void> {
438 const paths = await pathsOf($, current.channel)
439 const now = await $.clock.now()
440 const body = text.length > MAX_ANSWER_CHARS ? `${text.slice(0, MAX_ANSWER_CHARS)}\n\n[cut at ${MAX_ANSWER_CHARS} characters]` : text
441 const answer: Answer = { id: newId(now, Math.random()), text: body, finishedAt: now }
442 await writeItem($, paths.toLead, answer)
443 const items = await readInbox($, paths.toLead, isAnswer)
444 await removeItems($, paths.toLead, items.slice(0, Math.max(0, items.length - MAX_ANSWERS)).map(i => i.id))
445}
446
447async function cycleOffer($: EngineInterface): Promise<void> {
448 await update($, offer, current =>
449 current === null ? null : { ...current, index: (current.index + current.blocks.length - 1) % current.blocks.length },
450 )
451}
452
453async function openPane($: EngineInterface): Promise<void> {
454 await $.ui.open({ id: PANE, title: 'Relay', focus: true, closeOnEscape: true })
455}
456
457async function runCommand($: EngineInterface, args: string): Promise<void> {
458 const command = parseCommand(args)
459 if (command.kind === 'invalid') {
460 $.ui.toast(command.reason)
461 return
462 }
463 if (command.kind === 'open') {
464 await poll($)
465 await openPane($)
466 return
467 }
468 if (command.kind === 'off') {
469 const current = await read($, link)
470 await leave($)
471 $.ui.toast(current === null ? 'This folder was not linked.' : `Left ${current.channel}; this folder no longer joins it.`)
472 return
473 }
474 if (command.kind === 'send') {
475 if (command.text.length > 0) {
476 await sendTask($, command.text, false)
477 } else {
478 await sendLatestBlock($)
479 }
480 return
481 }
482 const root = await $.session.root()
483 let channel = command.channel
484 if (channel === null && command.role === 'worker') {
485 channel = channelOf(folderNameOf(root))
486 }
487 if (channel === null && command.role === 'lead') {
488 const channels = await existingChannels($)
489 channel = channels.length === 1 ? (channels[0] ?? null) : null
490 if (channel === null) {
491 const known = channels.length > 0 ? ` Known: ${channels.join(', ')}.` : ''
492 $.ui.toast(`Name the channel: /relay lead <channel>.${known}`)
493 return
494 }
495 }
496 if (channel === null) {
497 $.ui.toast(`This folder's name is no channel name; /relay worker <channel> names one.`)
498 return
499 }
500 const previous = await read($, link)
501 await join($, { role: command.role, channel }, true)
502 const wasOther =
503 previous !== null && (previous.role !== command.role || previous.channel !== channel)
504 ? ` This folder was the ${previous.role} of ${previous.channel} until now.`
505 : ''
506 $.ui.toast(
507 command.role === 'lead'
508 ? `Lead of ${channel}. A prompt in a fenced block can go to the worker from the band above the prompt.${wasOther}`
509 : `Worker of ${channel}. Tasks from the lead show above the prompt; every answer goes back to it.${wasOther}`,
510 )
511}
512
513export const register: Register = (on, options) => {
514 settings = {
515 minPromptChars: Number(options.minPromptChars ?? 300),
516 isNotifying: options.notify !== false,
517 }
518
519 on('session.start', async ($, e, next) => {
520 await $.command.register({
521 name: 'relay',
522 description: 'Pass prompts and answers between a planning session and the session that does the work',
523 argumentHint: 'lead <channel> | worker [channel] | send [text] | off',
524 immediate: true,
525 })
526 const result = await next(e)
527 const stored = await $.store.get(`${LINK_PREFIX}${await $.session.root()}`)
528 if (isLink(stored)) {
529 await join($, stored, false)
530 } else {
531 await refreshFooter($)
532 }
533
534 return result
535 })
536
537 // A /clear or /resume starts no session.start: the offer and an attached answer
538 // belong to the conversation that was left, and a worker goes on under a new id.
539 on('classic.SessionStart', async ($, e, next) => {
540 const result = await next(e)
541 if (e.source !== 'clear' && e.source !== 'resume') {
542 return result
543 }
544 await update($, offer, () => null)
545 await update($, isAttached, () => false)
546 const current = await read($, link)
547 if (current?.role === 'worker') {
548 await claim($, current, true)
549 }
550 await refreshFooter($)
551
552 return result
553 })
554
555 on('session.end', async ($, e, next) => {
556 const current = await read($, link)
557 if (e.reason !== 'clear' && e.reason !== 'resume' && current?.role === 'worker' && (await read($, isActive))) {
558 await $.process.run(['rm', '-f', (await pathsOf($, current.channel)).presence]).catch(() => undefined)
559 }
560
561 return next(e)
562 })
563
564 on('turn.start', async ($, e, next) => {
565 const result = await next(e)
566 const current = await read($, link)
567 if (current?.role === 'worker') {
568 await claim($, current, true, await $.clock.now())
569 await refreshFooter($)
570 }
571
572 return result
573 })
574
575 on('turn.complete', async ($, e, next) => {
576 const result = await next(e)
577 const current = await read($, link)
578 if (e.agentId !== undefined || current === null) {
579 return result
580 }
581 if (current.role === 'lead') {
582 const blocks = e.reason === 'answer' ? promptBlocksOf(e.answer, settings.minPromptChars) : []
583 await update($, offer, () => (blocks.length === 0 ? null : { blocks, index: blocks.length - 1 }))
584 return result
585 }
586 if (!(await read($, isActive))) {
587 return result
588 }
589 await writePresence($, current.channel, null)
590 if (e.reason === 'answer' && e.answer.trim().length > 0) {
591 await report($, current, e.answer)
592 }
593 await refreshFooter($)
594
595 return result
596 })
597
598 // Typing a prompt in the lead, here or through Remote Control, drops a pending
599 // offer, which belonged to the answer before it, and takes attached answers
600 // along as context.
601 on('prompt.submit', async ($, e, next) => {
602 const current = await read($, link)
603 const isTyped = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
604 if (current?.role !== 'lead' || !isTyped || e.text.trimStart().startsWith('/')) {
605 return next(e)
606 }
607 await update($, offer, () => null)
608 const items = await read($, answers)
609 if (!(await read($, isAttached)) || items.length === 0) {
610 return next(e)
611 }
612 const result = await next({ ...e, context: [...(e.context ?? []), forwardTextOf(current.channel, items)] })
613 if (result.drop === undefined) {
614 await dropAnswers($, items)
615 }
616
617 return result
618 })
619
620 on('command.run', { command: 'relay' }, async ($, e) => {
621 await runCommand($, e.args)
622
623 return {}
624 })
625
626 on('ui.press', { plugin: 'mod-hub', element: 'session-relay:open' }, async ($, e, next) => {
627 await openPane($)
628
629 return next(e)
630 })
631
632 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
633 const current = await read($, link)
634 if (current === null || e.props.hasSurvey || e.props.isWorking) {
635 return next(e)
636 }
637 const currentOffer = await read($, offer)
638 const block = currentOffer?.blocks[currentOffer.index]
639 const items = await read($, answers)
640 const attached = await read($, isAttached)
641 const task = (await read($, tasks)).at(-1)
642 const active = await read($, isActive)
643 const hasLead = current.role === 'lead' && (block !== undefined || items.length > 0)
644 const hasWorker = current.role === 'worker' && task !== undefined
645 if (!hasLead && !hasWorker) {
646 return next(e)
647 }
648 // Another mod's band (cache-watch) may be drawn beneath; it stays under this
649 // one. An `engine` element means none was, and it cannot be nested.
650 const below = await next(e)
651 const beneath = below.type === 'engine' ? null : below
652 const { Box, Button, Text } = $.ui.resolve(e)
653 const latest = items.at(-1)
654 const blockCount = currentOffer?.blocks.length ?? 0
655 if (e.surface !== 'terminal') {
656 const { Svg } = $.ui.resolve(e)
657 const width = Math.max(240, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 8))
658 const labels: Record<string, string[]> = {
659 offer: ['➤ Send', '✦ Fresh session', ...(blockCount > 1 ? ['Other block'] : []), 'View', 'Dismiss'],
660 attached: ['Detach', 'View'],
661 answers: ['📎 Attach', '➤ Forward now', 'View', 'Discard'],
662 inactive: ['Take over'],
663 task: ['✦ Clear & send', '➤ Send here', 'View', 'Discard'],
664 }
665 // The buttons lie over the card, centered on its height: the picture
666 // takes no presses. Too many for the room beside the text, and the band
667 // stays a native card.
668 const drawCard = (key: string, title: string, detail: string, buttons: RenderElement) => {
669 const reserve = buttonWidthOf(labels[key] ?? [])
670 if (reserve > width * 0.6) {
671 return (
672 <Box key={key} flexDirection="row" columnGap={2} alignItems="center" borderStyle="round" borderColor="claude" paddingX={1}>
673 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
674 <Text bold>{title}</Text>
675 <Text dimColor wrap="truncate-end">
676 {detail}
677 </Text>
678 </Box>
679 {buttons}
680 </Box>
681 )
682 }
683 const card = bandCard(width, title, detail, reserve)
684
685 return (
686 <Box key={key} flexDirection="column">
687 <Svg source={card.source} alt={card.alt} width={width} height={card.height} />
688 <Box position="absolute" top={0} bottom={0} right={2} flexDirection="row" alignItems="center">
689 {buttons}
690 </Box>
691 </Box>
692 )
693 }
694
695 return (
696 <Box flexDirection="column" rowGap={1}>
697 {current.role === 'lead' &&
698 block !== undefined &&
699 drawCard(
700 'offer',
701 `⇄ Prompt for ${current.channel}${blockCount > 1 ? ` · ${(currentOffer?.index ?? 0) + 1} of ${blockCount}` : ''}`,
702 `${formatChars(block.length)} · ${previewOf(block)}`,
703 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
704 <Button key="send" label="➤ Send" hotkey="s" variant="primary" onPress={() => sendTask($, block, false)} />
705 <Button key="send-fresh" label="✦ Fresh session" hotkey="n" onPress={() => sendTask($, block, true)} />
706 {blockCount > 1 && <Button key="other" label="Other block" hotkey="o" onPress={() => cycleOffer($)} />}
707 <Button key="view-offer" label="View" hotkey="v" onPress={() => openPane($)} />
708 <Button key="dismiss-offer" label="Dismiss" hotkey="x" role="dismiss" onPress={() => update($, offer, () => null)} />
709 </Box>,
710 )}
711 {current.role === 'lead' &&
712 latest !== undefined &&
713 attached &&
714 drawCard(
715 'attached',
716 `📎 ${items.length === 1 ? 'Answer' : `${items.length} answers`} from ${current.channel} attached`,
717 'They go with your next prompt',
718 <Box flexDirection="row" columnGap={1}>
719 <Button key="detach" label="Detach" hotkey="u" onPress={() => attachAnswers($, false)} />
720 <Button key="view-answers" label="View" hotkey="r" onPress={() => openPane($)} />
721 </Box>,
722 )}
723 {current.role === 'lead' &&
724 latest !== undefined &&
725 !attached &&
726 drawCard(
727 'answers',
728 `⇄ ${current.channel} answered${items.length > 1 ? ` ${items.length}×` : ''}`,
729 `${formatChars(latest.text.length)} · ${previewOf(latest.text)}`,
730 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
731 <Button
732 key="attach"
733 label="📎 Attach"
734 hotkey="a"
735 {...(block === undefined ? { variant: 'primary' as const } : {})}
736 onPress={() => attachAnswers($, true)}
737 />
738 <Button key="forward" label="➤ Forward now" hotkey="f" onPress={() => forwardAnswers($)} />
739 <Button key="view-answers" label="View" hotkey="r" onPress={() => openPane($)} />
740 <Button key="discard-answers" label="Discard" hotkey="d" onPress={() => dropAnswers($, items)} />
741 </Box>,
742 )}
743 {current.role === 'worker' &&
744 task !== undefined &&
745 !active &&
746 drawCard(
747 'inactive',
748 `⇄ A task for ${current.channel} waits`,
749 'Another session in this folder is the worker',
750 <Button key="take-over" label="Take over" hotkey="t" variant="primary" onPress={() => claim($, current, true)} />,
751 )}
752 {current.role === 'worker' &&
753 task !== undefined &&
754 active &&
755 drawCard(
756 'task',
757 `⇄ Task from the lead${task.isFresh ? ' · for a fresh session' : ''}`,
758 `${formatChars(task.text.length)} · ${previewOf(task.text)}`,
759 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
760 <Button
761 key="send-fresh"
762 label="✦ Clear & send"
763 hotkey="n"
764 {...(task.isFresh ? { variant: 'primary' as const } : {})}
765 onPress={() => takeTask($, task, true)}
766 />
767 <Button
768 key="send"
769 label="➤ Send here"
770 hotkey="s"
771 {...(task.isFresh ? {} : { variant: 'primary' as const })}
772 onPress={() => takeTask($, task, false)}
773 />
774 <Button key="view-task" label="View" hotkey="v" onPress={() => openPane($)} />
775 <Button key="discard-task" label="Discard" hotkey="d" onPress={() => discardTask($, task)} />
776 </Box>,
777 )}
778 {beneath}
779 </Box>
780 )
781 }
782
783 return (
784 <Box flexDirection="column">
785 {current.role === 'lead' && block !== undefined && (
786 <Box key="offer" flexDirection="column">
787 <Text wrap="truncate-end">
788 {`Prompt for ${current.channel}${blockCount > 1 ? ` (${(currentOffer?.index ?? 0) + 1}/${blockCount})` : ''} · ${formatChars(block.length)} · ${previewOf(block)}`}
789 </Text>
790 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
791 <Button key="send" label="Send" hotkey="s" variant="primary" onPress={() => sendTask($, block, false)} />
792 <Button key="send-fresh" label="Send as fresh session" hotkey="n" onPress={() => sendTask($, block, true)} />
793 {blockCount > 1 && <Button key="other" label="Other block" hotkey="o" onPress={() => cycleOffer($)} />}
794 <Button key="view-offer" label="View" hotkey="v" onPress={() => openPane($)} />
795 <Button key="dismiss-offer" label="Dismiss" hotkey="x" role="dismiss" onPress={() => update($, offer, () => null)} />
796 </Box>
797 </Box>
798 )}
799 {current.role === 'lead' && latest !== undefined && attached && (
800 <Box key="attached" flexDirection="column">
801 <Text wrap="truncate-end">{`📎 ${items.length === 1 ? 'The answer' : `${items.length} answers`} from ${current.channel} go with your next prompt`}</Text>
802 <Box flexDirection="row" columnGap={2}>
803 <Button key="detach" label="Detach" hotkey="u" onPress={() => attachAnswers($, false)} />
804 <Button key="view-answers" label="View" hotkey="r" onPress={() => openPane($)} />
805 </Box>
806 </Box>
807 )}
808 {current.role === 'lead' && latest !== undefined && !attached && (
809 <Box key="answers" flexDirection="column">
810 <Text wrap="truncate-end">
811 {`${current.channel} answered${items.length > 1 ? ` ${items.length}×` : ''} · ${formatChars(latest.text.length)} · ${previewOf(latest.text)}`}
812 </Text>
813 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
814 <Button
815 key="attach"
816 label="Attach to next prompt"
817 hotkey="a"
818 {...(block === undefined ? { variant: 'primary' as const } : {})}
819 onPress={() => attachAnswers($, true)}
820 />
821 <Button key="forward" label="Forward now" hotkey="f" onPress={() => forwardAnswers($)} />
822 <Button key="view-answers" label="View" hotkey="r" onPress={() => openPane($)} />
823 <Button key="discard-answers" label="Discard" hotkey="d" onPress={() => dropAnswers($, items)} />
824 </Box>
825 </Box>
826 )}
827 {current.role === 'worker' && task !== undefined && !active && (
828 <Box key="inactive" flexDirection="column">
829 <Text wrap="truncate-end">{`A task for ${current.channel} waits, but another session in this folder is the worker.`}</Text>
830 <Box flexDirection="row" columnGap={2}>
831 <Button key="take-over" label="Take over" hotkey="t" variant="primary" onPress={() => claim($, current, true)} />
832 </Box>
833 </Box>
834 )}
835 {current.role === 'worker' && task !== undefined && active && (
836 <Box key="task" flexDirection="column">
837 <Text wrap="truncate-end">
838 {`Task from the lead${task.isFresh ? ' · for a fresh session' : ''} · ${formatChars(task.text.length)} · ${previewOf(task.text)}`}
839 </Text>
840 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
841 <Button
842 key="send-fresh"
843 label="Clear & send"
844 hotkey="n"
845 {...(task.isFresh ? { variant: 'primary' as const } : {})}
846 onPress={() => takeTask($, task, true)}
847 />
848 <Button
849 key="send"
850 label="Send here"
851 hotkey="s"
852 {...(task.isFresh ? {} : { variant: 'primary' as const })}
853 onPress={() => takeTask($, task, false)}
854 />
855 <Button key="view-task" label="View" hotkey="v" onPress={() => openPane($)} />
856 <Button key="discard-task" label="Discard" hotkey="d" onPress={() => discardTask($, task)} />
857 </Box>
858 </Box>
859 )}
860 {beneath}
861 </Box>
862 )
863 })
864
865 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
866 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
867 const current = await read($, link)
868 if (current === null) {
869 return (
870 <Box flexDirection="column" rowGap={1}>
871 <Text bold>⇄ Relay</Text>
872 <Text>This folder is not linked to a relay channel.</Text>
873 <Text dimColor>{USAGE}</Text>
874 </Box>
875 )
876 }
877 const status = statusOf({
878 link: current,
879 presence: await read($, presence),
880 answers: await read($, answers),
881 tasks: await read($, tasks),
882 isAttached: await read($, isAttached),
883 isActive: await read($, isActive),
884 now: await $.clock.now(),
885 })
886 const currentOffer = await read($, offer)
887 const block = currentOffer?.blocks[currentOffer.index]
888 const items = await read($, answers)
889 const task = (await read($, tasks)).at(-1)
890 const isEmpty = current.role === 'lead' ? block === undefined && items.length === 0 : task === undefined
891 if (e.surface !== 'terminal') {
892 const { Svg } = $.ui.resolve(e)
893 const width = cardWidthOf(e.props.bodyColumns)
894 const now = await $.clock.now()
895 const held = await read($, presence)
896 const active = await read($, isActive)
897 const state =
898 current.role === 'worker'
899 ? active
900 ? 'this session works'
901 : 'another session works'
902 : !isOnline(held, now)
903 ? 'no worker open'
904 : held.busySince !== null
905 ? 'worker busy'
906 : 'worker ready'
907 const detail = status.split(' · ').slice(1).join(' · ')
908 const header = headerCard({
909 width,
910 eyebrow: `⇄ RELAY · ${current.role === 'lead' ? 'LEAD' : 'WORKER'}`,
911 title: current.channel,
912 counter: detail.length > 0 ? detail : state,
913 })
914 const drawCards = (list: readonly Card[]) => list.map(card => <Svg source={card.source} alt={card.alt} width={width} height={card.height} />)
915 // A bar names each message and carries its actions, laid over it and
916 // centered on its height; a narrow pane keeps them beneath the name.
917 const actionBar = (key: string, label: string, buttonLabels: readonly string[], buttons: RenderElement[]) =>
918 width >= ROOMY_WIDTH ? (
919 <Box key={key} flexDirection="column">
920 {drawCards([barCard(width, label, label, 'card', buttonWidthOf(buttonLabels))])}
921 <Box position="absolute" top={0} bottom={0} right={2} flexDirection="row" alignItems="center" columnGap={1}>
922 {buttons}
923 </Box>
924 </Box>
925 ) : (
926 <Box key={key} flexDirection="column" rowGap={1}>
927 <Text bold dimColor>
928 {label}
929 </Text>
930 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
931 {buttons}
932 </Box>
933 </Box>
934 )
935
936 return (
937 <Box flexDirection="column" rowGap={1}>
938 {drawCards([header])}
939 {current.role === 'lead' && block !== undefined && (
940 <Box key="offer" flexDirection="column" rowGap={1}>
941 {actionBar('offer-bar', `PROMPT FOR ${current.channel.toUpperCase()} · ${formatChars(block.length)}`, ['➤ Send', '✦ Fresh session'], [
942 <Button key="pane-send" label="➤ Send" variant="primary" onPress={() => sendTask($, block, false)} />,
943 <Button key="pane-send-fresh" label="✦ Fresh session" onPress={() => sendTask($, block, true)} />,
944 ])}
945 {drawCards(messageCards(width, null, block, 'card-accent'))}
946 </Box>
947 )}
948 {current.role === 'lead' && items.length > 0 && (
949 <Box key="answers" flexDirection="column" rowGap={1}>
950 {actionBar('answers-bar', `${items.length === 1 ? 'ANSWER' : `${items.length} ANSWERS`} FROM ${current.channel.toUpperCase()}`, ['📎 Attach', '➤ Forward now', '✕ Discard'], [
951 <Button key="pane-attach" label="📎 Attach" variant="primary" onPress={() => attachAnswers($, true)} />,
952 <Button key="pane-forward" label="➤ Forward now" onPress={() => forwardAnswers($)} />,
953 <Button key="pane-discard" dimColor label="✕ Discard" onPress={() => dropAnswers($, items)} />,
954 ])}
955 {items.map((answer, index) => (
956 <Box key={`answer-${answer.id}`} flexDirection="column">
957 {drawCards(messageCards(width, `ANSWER ${index + 1} OF ${items.length} · ${formatChars(answer.text.length)}`, answer.text, 'card'))}
958 </Box>
959 ))}
960 </Box>
961 )}
962 {current.role === 'worker' && task !== undefined && (
963 <Box key="task" flexDirection="column" rowGap={1}>
964 {actionBar('task-bar', `TASK FROM THE LEAD · ${formatChars(task.text.length)}${task.isFresh ? ' · FRESH SESSION' : ''}`, ['✦ Clear & send', '➤ Send here', '✕ Discard'], [
965 <Button key="pane-clear-send" label="✦ Clear & send" {...(task.isFresh ? { variant: 'primary' as const } : {})} onPress={() => takeTask($, task, true)} />,
966 <Button key="pane-send-here" label="➤ Send here" {...(task.isFresh ? {} : { variant: 'primary' as const })} onPress={() => takeTask($, task, false)} />,
967 <Button key="pane-discard-task" dimColor label="✕ Discard" onPress={() => discardTask($, task)} />,
968 ])}
969 {drawCards(messageCards(width, null, task.text, 'card-accent'))}
970 </Box>
971 )}
972 {isEmpty && (
973 <Box flexDirection="column">
974 <Text dimColor>Nothing pending.</Text>
975 <Text dimColor>{USAGE}</Text>
976 </Box>
977 )}
978 </Box>
979 )
980 }
981
982 return (
983 <Box flexDirection="column">
984 <Text bold>{status}</Text>
985 {current.role === 'lead' && block !== undefined && (
986 <Box key="offer" flexDirection="column" marginTop={1}>
987 <Text bold>{`Prompt for ${current.channel}`}</Text>
988 <Markdown text={block} />
989 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
990 <Button key="pane-send" label="Send" variant="primary" onPress={() => sendTask($, block, false)} />
991 <Button key="pane-send-fresh" label="Send as fresh session" onPress={() => sendTask($, block, true)} />
992 </Box>
993 </Box>
994 )}
995 {current.role === 'lead' &&
996 items.map((answer, index) => (
997 <Box key={`answer-${answer.id}`} flexDirection="column" marginTop={1}>
998 <Text bold>{`Answer ${index + 1} of ${items.length} from ${current.channel} · ${formatChars(answer.text.length)}`}</Text>
999 <Markdown text={answer.text} />
1000 </Box>
1001 ))}
1002 {current.role === 'lead' && items.length > 0 && (
1003 <Box key="answer-actions" flexDirection="row" columnGap={2} flexWrap="wrap" marginTop={1}>
1004 <Button key="pane-attach" label="Attach to next prompt" variant="primary" onPress={() => attachAnswers($, true)} />
1005 <Button key="pane-forward" label="Forward now" onPress={() => forwardAnswers($)} />
1006 <Button key="pane-discard" label="Discard" onPress={() => dropAnswers($, items)} />
1007 </Box>
1008 )}
1009 {current.role === 'worker' && task !== undefined && (
1010 <Box key="task" flexDirection="column" marginTop={1}>
1011 <Text bold>{`Task from the lead${task.isFresh ? ' · for a fresh session' : ''}`}</Text>
1012 <Markdown text={task.text} />
1013 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
1014 <Button key="pane-clear-send" label="Clear & send" {...(task.isFresh ? { variant: 'primary' as const } : {})} onPress={() => takeTask($, task, true)} />
1015 <Button key="pane-send-here" label="Send here" {...(task.isFresh ? {} : { variant: 'primary' as const })} onPress={() => takeTask($, task, false)} />
1016 <Button key="pane-discard-task" label="Discard" onPress={() => discardTask($, task)} />
1017 </Box>
1018 </Box>
1019 )}
1020 {isEmpty && (
1021 <Box flexDirection="column" marginTop={1}>
1022 <Text dimColor>Nothing pending.</Text>
1023 <Text dimColor>{USAGE}</Text>
1024 </Box>
1025 )}
1026 </Box>
1027 )
1028 })
1029
1030 // The terminal draws the mode labels itself, so the label joins them there.
1031 // On the desktop, status-band draws the footer from the labels it was handed,
1032 // whichever order the mods load in; only a drawn tree survives that.
1033 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1034 const label = await read($, footer)
1035 if (label === null) {
1036 return next(e)
1037 }
1038 if (e.surface === 'terminal') {
1039 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
1040 }
1041 if (await read($, isHubbed)) {
1042 return next(e)
1043 }
1044 const below = await next(e)
1045 const { Box, Text } = $.ui.resolve(e)
1046 const rest = below.type !== 'engine' ? below : e.props.modes.length === 0 ? null : <Text dimColor>{e.props.modes.join(' & ')}</Text>
1047
1048 return (
1049 <Box flexDirection="row">
1050 <Text dimColor>{label}</Text>
1051 {rest !== null && <Text dimColor> · </Text>}
1052 {rest}
1053 </Box>
1054 )
1055 })
1056}
1057hooks/hub.ts 12 lines1// mod-hub's contract, restated: listed under dependencies, it would keep this
2// mod from loading where mod-hub is not installed.
3export type HubEntry = {
4 icon: string
5 title: string
6 status: string
7 attention: 'none' | 'info' | 'warning'
8 actions: { key: string; label: string }[]
9}
10
11export type HubCall = { hub: { set: (entry: HubEntry & { id: string }) => Promise<void> } }
12hooks/look.ts 31 lines1import { PAD, blockSections, cards, escape, eyebrowSection, fitText, frame, parseBlocks, plainOf, svgDocument } from './svg'
2import type { Card, Variant } from './svg'
3
4export { barCard, buttonWidthOf, headerCard } from './svg'
5export type { Card } from './svg'
6
7// A prompt, an answer or a task as the other session wrote it, its Markdown
8// drawn in the card; without an eyebrow when a bar above already names it.
9export function messageCards(width: number, eyebrow: string | null, text: string, variant: Variant): Card[] {
10 const body = blockSections(parseBlocks(text), PAD, width - 2 * PAD)
11 const sections = eyebrow === null ? body : [eyebrowSection(eyebrow, null, 'muted', width), ...body]
12
13 return cards(width, variant, sections, `${eyebrow === null ? 'Message' : eyebrow}: ${plainOf(text.replace(/\s+/gu, ' '))}`)
14}
15
16const BAND_HEIGHT = 60
17
18// What waits in the band above the prompt; `reserve` keeps the text clear of
19// the native buttons laid over the card's right edge.
20export function bandCard(width: number, title: string, detail: string, reserve: number): Card {
21 const maxWidth = width - 2 * PAD - reserve
22 const middle = BAND_HEIGHT / 2
23 const body = [
24 frame(width, BAND_HEIGHT, 'card-accent'),
25 `<text class="t" x="${PAD}" y="${middle - 3}" font-size="14" font-weight="650">${escape(fitText(title, maxWidth, 14, true))}</text>`,
26 `<text class="muted" x="${PAD}" y="${middle + 15}" font-size="12.5">${escape(fitText(detail, maxWidth, 12.5))}</text>`,
27 ]
28
29 return { height: BAND_HEIGHT, alt: `${title}: ${detail}`, source: svgDocument(width, BAND_HEIGHT, body.join('')) }
30}
31hooks/svg.ts 603 lines1// An SVG drawn as an image cannot lay out text: no wrapping, no Markdown, no
2// measuring. This module does that by estimate and stacks the result into
3// cards. Every mod with desktop cards carries the same copy of this file.
4
5export type Run = { text: string; isBold?: boolean; isItalic?: boolean; isCode?: boolean; isLink?: boolean }
6
7export type Block =
8 | { kind: 'paragraph'; runs: Run[] }
9 | { kind: 'heading'; runs: Run[] }
10 | { kind: 'quote'; runs: Run[] }
11 | { kind: 'item'; marker: string; runs: Run[] }
12 | { kind: 'code'; lines: string[] }
13
14export const SANS = '-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif'
15export const MONO = 'ui-monospace,SFMono-Regular,Menlo,monospace'
16
17// Classes rather than colors: drawn as an image the SVG follows the light or
18// dark color scheme by itself, which a color handed in from the hook could not.
19export const STYLE = `<style>
20text{font-family:${SANS};white-space:pre}.mono{font-family:${MONO}}
21.card{fill:#f7f6f3;stroke:#e5e3de}.card-accent{fill:#fbf1ec;stroke:#e7b49c}.card-success{fill:#f1f8f3;stroke:#a9d6b8}
22.card-warning{fill:#fcf6e6;stroke:#e6cf8a}.card-error{fill:#fcefee;stroke:#e8aaa6}
23.tile{fill:#ffffff;stroke:#e5e3de}.tile-accent{fill:#ffffff;stroke:#d97757}.tile-success{fill:#ffffff;stroke:#2e9e5b}
24.t{fill:#1f1e1d}.muted{fill:#77756f}.eyebrow{fill:#c9643f}.code{fill:#a2482a}.link{fill:#c9643f}
25.success{fill:#2e9e5b}.warning{fill:#a87a06}.error{fill:#c0392b}.accent{fill:#d97757}.on-accent{fill:#ffffff}
26.badge{fill:#ecebe7}.badge-t{fill:#4a4844}.codebg{fill:#efede8}.rule{fill:#dad7d0}.pill{fill:#f6e1d7}.pill-success{fill:#dcefe2}
27.open{fill:#e4e2dc}.answered{fill:#2e9e5b}.skipped{fill:#d4a017}.current{fill:#d97757}
28.current,.pulse{animation:pulse 1.8s ease-in-out infinite}@keyframes pulse{50%{opacity:.5}}
29.ripple{transform-box:fill-box;transform-origin:center;animation:ripple 2.4s ease-out infinite}@keyframes ripple{0%{transform:scale(1);opacity:.45}100%{transform:scale(2.8);opacity:0}}
30.spin{transform-box:fill-box;transform-origin:center;animation:spin .9s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
31.dot{animation:dot 1.2s ease-in-out infinite}.dot2{animation-delay:.2s}.dot3{animation-delay:.4s}@keyframes dot{0%,80%,100%{opacity:.25}40%{opacity:1}}
32@media (prefers-color-scheme: dark){
33.card{fill:#262625;stroke:#363632}.card-accent{fill:#2e2420;stroke:#7a4a36}.card-success{fill:#1f2a23;stroke:#2f5a3d}
34.card-warning{fill:#2c2717;stroke:#6a5a24}.card-error{fill:#2e1f1e;stroke:#6e3430}
35.tile{fill:#1e1e1d;stroke:#3a3a36}.tile-accent{fill:#1e1e1d;stroke:#e08a6a}.tile-success{fill:#1e1e1d;stroke:#3fb36b}
36.t{fill:#ecebe8}.muted{fill:#a5a39d}.eyebrow{fill:#e8916f}.code{fill:#f0a283}.link{fill:#e8916f}
37.success{fill:#4cc27a}.warning{fill:#e2b33c}.error{fill:#e8746a}.accent{fill:#e08a6a}.on-accent{fill:#1f1e1d}
38.badge{fill:#34342f}.badge-t{fill:#d6d4cf}.codebg{fill:#1c1c1b}.rule{fill:#45443f}.pill{fill:#4a3328}.pill-success{fill:#24432f}
39.open{fill:#3a3a37}.answered{fill:#3fb36b}.skipped{fill:#d4a017}.current{fill:#e08a6a}}
40@media (prefers-reduced-motion: reduce){.current,.pulse,.dot,.ripple,.spin{animation:none}}
41</style>`
42
43// Widths are estimated from the font size, on the wide side: a line then ends
44// a little early rather than past the card's edge.
45export function textWidth(text: string, size: number, isBold = false, isMono = false): number {
46 if (isMono) {
47 return Array.from(text).length * size * 0.62
48 }
49 let units = 0
50 for (const char of text) {
51 if (/[\s.,:;'|!ijlI]/u.test(char)) {
52 units += 0.3
53 } else if (/[A-Z@%&MWmw]/u.test(char)) {
54 units += 0.74
55 } else if (/\p{Extended_Pictographic}/u.test(char)) {
56 units += 1.15
57 } else {
58 units += 0.53
59 }
60 }
61
62 return units * size * (isBold ? 1.04 : 1)
63}
64
65function runWidth(run: Run, size: number): number {
66 return textWidth(run.text, size, run.isBold === true, run.isCode === true)
67}
68
69export function fitText(text: string, maxWidth: number, size: number, isBold = false): string {
70 if (textWidth(text, size, isBold) <= maxWidth) {
71 return text
72 }
73 const chars = Array.from(text)
74 while (chars.length > 0 && textWidth(`${chars.join('')}…`, size, isBold) > maxWidth) {
75 chars.pop()
76 }
77
78 return `${chars.join('').trimEnd()}…`
79}
80
81export function escape(text: string): string {
82 return text.replace(/&/gu, '&').replace(/</gu, '<').replace(/>/gu, '>').replace(/"/gu, '"')
83}
84
85const INLINE = /(\*\*[^*]+?\*\*|__[^_]+?__|`[^`]+`|\[[^\]]+\]\([^)\s]+\)|(?<![\w*])\*(?!\s)[^*]+?(?<!\s)\*(?!\w))/gu
86
87export function parseInline(text: string): Run[] {
88 const runs: Run[] = []
89 let last = 0
90 for (const match of text.matchAll(INLINE)) {
91 const [token] = match
92 if (match.index > last) {
93 runs.push({ text: text.slice(last, match.index) })
94 }
95 if (token.startsWith('**') || token.startsWith('__')) {
96 runs.push(...parseInline(token.slice(2, -2)).map(i => ({ ...i, isBold: true })))
97 } else if (token.startsWith('`')) {
98 runs.push({ text: token.slice(1, -1), isCode: true })
99 } else if (token.startsWith('[')) {
100 runs.push({ text: token.slice(1, token.indexOf('](')), isLink: true })
101 } else {
102 runs.push(...parseInline(token.slice(1, -1)).map(i => ({ ...i, isItalic: true })))
103 }
104 last = match.index + token.length
105 }
106 if (last < text.length) {
107 runs.push({ text: text.slice(last) })
108 }
109
110 return runs.filter(i => i.text.length > 0)
111}
112
113export function plainOf(text: string): string {
114 return parseInline(text)
115 .map(i => i.text)
116 .join('')
117}
118
119const ITEM = /^\s*([-*•+]|\d{1,3}[.)])\s+(.*)$/u
120const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/u
121const QUOTE = /^\s*>\s?(.*)$/u
122const FENCE = /^\s*(```|~~~)/u
123
124// Single line breaks join a paragraph, as Markdown reads them; a list item,
125// heading, quote or code fence starts a block of its own.
126export function parseBlocks(markdown: string): Block[] {
127 const blocks: Block[] = []
128 let paragraph: string[] = []
129 let item: { marker: string; text: string[] } | null = null
130 let quote: string[] = []
131 const flush = () => {
132 if (paragraph.length > 0) {
133 blocks.push({ kind: 'paragraph', runs: parseInline(paragraph.join(' ')) })
134 }
135 if (item !== null) {
136 blocks.push({ kind: 'item', marker: item.marker, runs: parseInline(item.text.join(' ')) })
137 }
138 if (quote.length > 0) {
139 blocks.push({ kind: 'quote', runs: parseInline(quote.join(' ')) })
140 }
141 paragraph = []
142 item = null
143 quote = []
144 }
145 const lines = markdown.replace(/\r\n?/gu, '\n').split('\n')
146 for (let index = 0; index < lines.length; index++) {
147 const line = lines[index] ?? ''
148 if (FENCE.test(line)) {
149 flush()
150 const code: string[] = []
151 index += 1
152 while (index < lines.length && !FENCE.test(lines[index] ?? '')) {
153 code.push(lines[index] ?? '')
154 index += 1
155 }
156 blocks.push({ kind: 'code', lines: code })
157 continue
158 }
159 if (line.trim().length === 0) {
160 flush()
161 continue
162 }
163 const heading = HEADING.exec(line)
164 if (heading !== null) {
165 flush()
166 blocks.push({ kind: 'heading', runs: parseInline(heading[1] ?? '') })
167 continue
168 }
169 const listed = ITEM.exec(line)
170 if (listed !== null) {
171 flush()
172 const marker = listed[1] ?? '-'
173 item = { marker: /\d/u.test(marker) ? marker : '•', text: [listed[2] ?? ''] }
174 continue
175 }
176 const quoted = QUOTE.exec(line)
177 if (quoted !== null) {
178 if (quote.length === 0) {
179 flush()
180 }
181 quote.push(quoted[1] ?? '')
182 continue
183 }
184 if (item !== null && /^\s+\S/u.test(line)) {
185 item.text.push(line.trim())
186 continue
187 }
188 if (item !== null || quote.length > 0) {
189 flush()
190 }
191 paragraph.push(line.trim())
192 }
193 flush()
194
195 return blocks
196}
197
198type Word = Run & { hasSpace: boolean }
199
200function wordsOf(runs: readonly Run[]): Word[] {
201 const words: Word[] = []
202 for (const run of runs) {
203 for (const piece of run.text.split(/(\s+)/u)) {
204 if (piece.length === 0) {
205 continue
206 }
207 if (/^\s+$/u.test(piece)) {
208 const previous = words.at(-1)
209 if (previous !== undefined) {
210 previous.hasSpace = true
211 }
212 continue
213 }
214 words.push({ ...run, text: piece, hasSpace: false })
215 }
216 }
217
218 return words
219}
220
221function sameStyle(first: Run, second: Run): boolean {
222 return first.isBold === second.isBold && first.isItalic === second.isItalic && first.isCode === second.isCode && first.isLink === second.isLink
223}
224
225function breakWord(word: Word, maxWidth: number, size: number): Word[] {
226 const pieces: Word[] = []
227 let current = ''
228 for (const char of word.text) {
229 if (current.length > 0 && runWidth({ ...word, text: current + char }, size) > maxWidth) {
230 pieces.push({ ...word, text: current, hasSpace: false })
231 current = ''
232 }
233 current += char
234 }
235 pieces.push({ ...word, text: current })
236
237 return pieces
238}
239
240// Greedy, word by word; a word wider than the line is broken by characters.
241// `firstWidth` keeps the first line clear of a badge drawn at its end.
242export function wrapRuns(runs: readonly Run[], maxWidth: number, size: number, firstWidth = maxWidth): Run[][] {
243 const lines: Word[][] = []
244 let line: Word[] = []
245 let width = 0
246 const space = textWidth(' ', size)
247 const words = wordsOf(runs).flatMap(i => (runWidth(i, size) > maxWidth ? breakWord(i, maxWidth, size) : [i]))
248 for (const word of words) {
249 const previous = line.at(-1)
250 const gap = previous?.hasSpace === true ? space : 0
251 const wordWidth = runWidth(word, size)
252 if (line.length > 0 && width + gap + wordWidth > (lines.length === 0 ? firstWidth : maxWidth)) {
253 lines.push(line)
254 line = []
255 width = 0
256 }
257 width += (line.length > 0 ? gap : 0) + wordWidth
258 line.push(word)
259 }
260 if (line.length > 0) {
261 lines.push(line)
262 }
263
264 // The space after a code span is drawn in the text's font, not the code's
265 // wider one.
266 return lines.map(words => {
267 const merged: Run[] = []
268 const append = (run: Run) => {
269 const previous = merged.at(-1)
270 if (previous !== undefined && sameStyle(previous, run)) {
271 previous.text += run.text
272 } else {
273 merged.push({ ...run })
274 }
275 }
276 words.forEach((word, index) => {
277 const { hasSpace, ...run } = word
278 const hasGap = hasSpace && index < words.length - 1
279 if (run.isCode === true) {
280 append(run)
281 if (hasGap) {
282 append({ text: ' ' })
283 }
284 } else {
285 append({ ...run, text: run.text + (hasGap ? ' ' : '') })
286 }
287 })
288
289 return merged
290 })
291}
292
293// Cuts wrapped lines to `maxLines`, the last one ending in an ellipsis.
294export function clampLines(lines: Run[][], maxLines: number, maxWidth: number, size: number): Run[][] {
295 if (lines.length <= maxLines) {
296 return lines
297 }
298 const kept = lines.slice(0, maxLines)
299 const last = kept[maxLines - 1] ?? []
300 const text = last.map(i => i.text).join('')
301 kept[maxLines - 1] = [{ ...(last[0] ?? { text: '' }), text: fitText(`${text}…`, maxWidth, size, last[0]?.isBold === true) }]
302
303 return kept
304}
305
306export function lineSvg(runs: readonly Run[], x: number, y: number, size: number, tone = 't', weight?: number): string {
307 const spans = runs.map(run => {
308 const classes = run.isCode ? 'code' : run.isLink ? 'link' : tone
309 const attributes = [
310 `class="${classes}"`,
311 run.isBold === true || weight !== undefined ? `font-weight="${run.isBold === true ? 650 : weight}"` : '',
312 run.isItalic === true ? 'font-style="italic"' : '',
313 run.isCode === true ? `font-family="${MONO}" font-size="${(size * 0.92).toFixed(1)}"` : '',
314 run.isLink === true ? 'text-decoration="underline"' : '',
315 ].filter(i => i.length > 0)
316
317 return `<tspan ${attributes.join(' ')}>${escape(run.text)}</tspan>`
318 })
319
320 return `<text x="${x}" y="${y.toFixed(1)}" font-size="${size}">${spans.join('')}</text>`
321}
322
323// A pane reports its width in columns only; about 8 CSS pixels each, as
324// savvy-progress measures it, minus the pane's own inset.
325export function cardWidthOf(columns: number): number {
326 return Math.max(240, Math.min(900, (columns || 40) * 8 - 8))
327}
328
329// The columns a native control laid over a card is pushed in by to clear
330// `pixels` of the card, at the same 8 pixels per column.
331export function columnsOf(pixels: number): number {
332 return Math.ceil(pixels / 8)
333}
334
335// About how wide the desktop draws a row of native buttons with these labels,
336// for text that has to keep clear of buttons laid over a card.
337export function buttonWidthOf(labels: readonly string[]): number {
338 return labels.reduce((sum, label) => sum + textWidth(label, 13) + 30, 0) + Math.max(0, labels.length - 1) * 8
339}
340
341export function svgDocument(width: number, height: number, body: string): string {
342 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}${body}</svg>`
343}
344
345export type Segment = 'answered' | 'skipped' | 'current' | 'open'
346
347export type Card = { source: string; height: number; alt: string }
348
349export type Variant = 'card' | 'card-accent' | 'card-success' | 'card-warning' | 'card-error'
350
351export type Section = { height: number; draw: (y: number) => string }
352
353export const PAD = 18
354export const SIZE = 14
355export const LINE = 21
356export const EYEBROW = 11
357export const RADIUS = 14
358
359// The engine refuses an Svg taller than 4096 pixels; a longer card is cut
360// between sections into several cards of the same look.
361export const MAX_HEIGHT = 4000
362
363export function baseline(y: number, line: number, lineHeight = LINE): number {
364 return y + line * lineHeight + lineHeight * 0.72
365}
366
367export function frame(width: number, height: number, variant: Variant): string {
368 return `<rect class="${variant}" x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${RADIUS}"/>`
369}
370
371export function eyebrowSvg(text: string, x: number, y: number, tone = 'eyebrow', anchor = 'start'): string {
372 return `<text class="${tone}" x="${x}" y="${y}" font-size="${EYEBROW}" font-weight="700" letter-spacing="1.1" text-anchor="${anchor}">${escape(text)}</text>`
373}
374
375export function eyebrowWidth(text: string): number {
376 return textWidth(text, EYEBROW, true) + text.length * 1.1
377}
378
379export function textSection(
380 runs: readonly Run[],
381 x: number,
382 maxWidth: number,
383 options: { tone?: string; size?: number; maxLines?: number; weight?: number; firstWidth?: number } = {},
384): Section {
385 const size = options.size ?? SIZE
386 const lineHeight = Math.round(size * 1.5)
387 const wrapped = wrapRuns(runs, maxWidth, size, options.firstWidth)
388 const lines = options.maxLines === undefined ? wrapped : clampLines(wrapped, options.maxLines, maxWidth, size)
389
390 return {
391 height: lines.length * lineHeight,
392 draw: y => lines.map((line, index) => lineSvg(line, x, baseline(y, index, lineHeight), size, options.tone, options.weight)).join(''),
393 }
394}
395
396export function codeSection(lines: readonly string[], x: number, maxWidth: number): Section {
397 const size = 12.5
398 const lineHeight = 18
399 const perLine = Math.max(8, Math.floor((maxWidth - 24) / (size * 0.62)))
400 const rows = lines.flatMap(line => {
401 const chars = Array.from(line)
402 const pieces: string[] = []
403 for (let index = 0; index < Math.max(1, chars.length); index += perLine) {
404 pieces.push(chars.slice(index, index + perLine).join(''))
405 }
406
407 return pieces
408 })
409
410 return {
411 height: rows.length * lineHeight + 20,
412 draw: y =>
413 `<rect class="codebg" x="${x}" y="${y}" width="${maxWidth}" height="${rows.length * lineHeight + 20}" rx="8"/>` +
414 rows.map((row, index) => lineSvg([{ text: row, isCode: true }], x + 12, baseline(y + 10, index, lineHeight), size / 0.92)).join(''),
415 }
416}
417
418export function blockSection(block: Block, x: number, maxWidth: number): Section {
419 switch (block.kind) {
420 case 'code':
421 return codeSection(block.lines, x, maxWidth)
422 case 'heading':
423 return textSection(block.runs, x, maxWidth, { size: SIZE + 1.5, weight: 650 })
424 case 'quote': {
425 const text = textSection(block.runs, x + 14, maxWidth - 14, { tone: 'muted' })
426
427 return { height: text.height, draw: y => `<rect class="rule" x="${x}" y="${y + 2}" width="3" height="${text.height - 4}" rx="1.5"/>${text.draw(y)}` }
428 }
429 case 'item': {
430 const indent = block.marker === '•' ? 16 : textWidth(block.marker, SIZE) + 8
431 const text = textSection(block.runs, x + indent, maxWidth - indent)
432
433 return {
434 height: text.height,
435 draw: y => `<text class="muted" x="${x + (block.marker === '•' ? 3 : 0)}" y="${baseline(y, 0).toFixed(1)}" font-size="${SIZE}">${escape(block.marker)}</text>${text.draw(y)}`,
436 }
437 }
438 default:
439 return textSection(block.runs, x, maxWidth)
440 }
441}
442
443// Paragraphs sit further apart than the items of one list.
444export function blockSections(blocks: readonly Block[], x: number, maxWidth: number): Section[] {
445 return blocks.map((block, index) => {
446 const section = blockSection(block, x, maxWidth)
447 const previous = blocks[index - 1]
448 const spacing = index === 0 ? 0 : previous?.kind === 'item' && block.kind === 'item' ? 4 : 10
449
450 return { height: section.height + spacing, draw: y => section.draw(y + spacing) }
451 })
452}
453
454// Stacks sections inside one frame; past MAX_HEIGHT the rest go on in a next one.
455// `minHeight` keeps a short card tall enough for native controls laid over it.
456export function cards(width: number, variant: Variant, sections: readonly Section[], alt: string, decoration = '', minHeight = 0): Card[] {
457 const result: Card[] = []
458 let pending: Section[] = []
459 let used = 2 * PAD
460 const close = () => {
461 if (pending.length === 0) {
462 return
463 }
464 let y = PAD
465 const body = pending.map(section => {
466 const drawn = section.draw(y)
467 y += section.height
468
469 return drawn
470 })
471 const natural = Math.ceil(y + PAD)
472 const height = result.length === 0 ? Math.max(natural, minHeight) : natural
473 result.push({ height, alt, source: svgDocument(width, height, frame(width, height, variant) + (result.length === 0 ? decoration : '') + body.join('')) })
474 pending = []
475 used = 2 * PAD
476 }
477 for (const section of sections) {
478 if (pending.length > 0 && used + section.height > MAX_HEIGHT) {
479 close()
480 }
481 pending.push(section)
482 used += section.height
483 }
484 close()
485
486 return result
487}
488
489export const GLOW =
490 '<defs><linearGradient id="glow" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d97757" stop-opacity="0.14"/><stop offset="0.55" stop-color="#d97757" stop-opacity="0"/></linearGradient></defs>'
491
492// The card heading a pane: an eyebrow, a count at the right, a title of up
493// to two lines and, given segments, a bar with one segment per step.
494// `reserve` keeps the title clear of native buttons laid over the right side;
495// a card without a bar is too low for them beside the count at its top right,
496// so the count then goes beneath the title.
497export function headerCard(card: { width: number; eyebrow: string; title: string; counter: string; segments?: readonly Segment[]; reserve?: number }): Card {
498 const { width } = card
499 const segments = card.segments ?? []
500 const inner = width - 2 * PAD
501 const reserve = card.reserve ?? 0
502 const isCountBelow = reserve > 0 && segments.length === 0
503 const counterWidth = isCountBelow ? 0 : textWidth(card.counter, 12)
504 const eyebrow = fitText(card.eyebrow, inner - counterWidth - 16 - (isCountBelow ? reserve : 0), EYEBROW, true)
505 const title = textSection(parseInline(card.title).map(i => ({ ...i, isBold: true })), PAD, inner - reserve, { size: 17, maxLines: 2 })
506 const titleTop = PAD + EYEBROW + 8
507 const countY = titleTop + title.height + 14
508 const barY = titleTop + title.height + 12
509 const height = isCountBelow ? countY + PAD - 2 : segments.length === 0 ? titleTop + title.height + PAD - 4 : barY + 6 + PAD
510 const count = Math.max(1, segments.length)
511 const segmentWidth = (inner - 4 * (count - 1)) / count
512 const bars = segments.map(
513 (segment, index) => `<rect class="${segment}" x="${(PAD + index * (segmentWidth + 4)).toFixed(1)}" y="${barY}" width="${segmentWidth.toFixed(1)}" height="6" rx="3"/>`,
514 )
515 const body = [
516 frame(width, height, 'card'),
517 GLOW,
518 `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${RADIUS}" fill="url(#glow)"/>`,
519 eyebrowSvg(eyebrow, PAD, PAD + EYEBROW),
520 isCountBelow
521 ? `<text class="muted" x="${PAD}" y="${countY}" font-size="12.5">${escape(fitText(card.counter, inner - reserve, 12.5))}</text>`
522 : `<text class="muted" x="${width - PAD}" y="${PAD + EYEBROW}" font-size="12" text-anchor="end">${escape(card.counter)}</text>`,
523 title.draw(titleTop),
524 ...bars,
525 ]
526
527 return { height, alt: `${card.eyebrow}: ${plainOf(card.title)}, ${card.counter}`, source: svgDocument(width, height, body.join('')) }
528}
529
530export function noticeCard(width: number, variant: 'card-warning' | 'card-error', title: string, text: string, reserve = 0): Card[] {
531 const inner = width - 2 * PAD - reserve
532 const body = textSection([{ text }], PAD, inner, { tone: 'muted' })
533 const sections = [textSection([{ text: title, isBold: true }], PAD, inner, { size: 15 }), { height: body.height + 6, draw: (y: number) => body.draw(y + 6) }]
534
535 return cards(width, variant, sections, `${title} ${text}`, '', reserve > 0 ? 72 : 0)
536}
537
538const FIELD_HEIGHT = 72
539
540// A card holding a native text field: its label and a hint in a column at the
541// left, the rest left free for the field laid over it. `labelWidth` is that
542// column's width; the field starts past it.
543export function fieldCard(width: number, label: string, hint: string, labelWidth: number): Card {
544 const maxWidth = labelWidth - PAD - 12
545 const hints = wrapRuns([{ text: hint }], maxWidth, 12).slice(0, 2)
546 const top = FIELD_HEIGHT / 2 - (hints.length * 16 + EYEBROW + 6) / 2 + EYEBROW
547 const body = [
548 frame(width, FIELD_HEIGHT, 'card'),
549 eyebrowSvg(fitText(label, maxWidth, EYEBROW, true), PAD, top),
550 ...hints.map((line, index) => lineSvg(line, PAD, top + 18 + index * 16, 12, 'muted')),
551 `<rect class="rule" x="${labelWidth - 6}" y="16" width="1" height="${FIELD_HEIGHT - 32}"/>`,
552 ]
553
554 return { height: FIELD_HEIGHT, alt: `${label}: ${hint}`, source: svgDocument(width, FIELD_HEIGHT, body.join('')) }
555}
556
557// Below this width a card leaves too little room beside native controls, so
558// they go beneath it instead of over it.
559export const ROOMY_WIDTH = 420
560
561// A slim card for a row of native buttons laid over it; given a label, that
562// heads the bar at its left, as a section title with its actions beside it.
563export function barCard(width: number, alt: string, label = '', variant: Variant = 'card', reserve = 0): Card {
564 const height = 56
565 const text = label.length === 0 ? '' : eyebrowSvg(fitText(label, width - 2 * PAD - reserve - 12, EYEBROW, true), PAD, height / 2 + EYEBROW / 2 - 1)
566
567 return { height, alt, source: svgDocument(width, height, frame(width, height, variant) + text) }
568}
569
570// A diff or a list of changes in the code font: added lines green, removed
571// ones red, hunk heads in the accent color. Long lines are cut, not wrapped,
572// so the columns stay aligned.
573export function diffCards(width: number, text: string, alt: string): Card[] {
574 const size = 12
575 const lineHeight = 17
576 const maxWidth = width - 2 * PAD
577 const rows = text.replace(/\s+$/u, '').split('\n')
578 const toneOf = (row: string) =>
579 /^\+(?!\+\+)/u.test(row) ? 'success' : /^-(?!--)/u.test(row) ? 'error' : /^@@/u.test(row) ? 'accent' : /^(diff |index |\+\+\+|---)/u.test(row) ? 'muted' : 't'
580 const sections: Section[] = rows.map(row => ({
581 height: lineHeight,
582 draw: y =>
583 `<text class="${toneOf(row)} mono" x="${PAD}" y="${(y + lineHeight * 0.75).toFixed(1)}" font-size="${size}">${escape(fitMono(row, maxWidth, size))}</text>`,
584 }))
585
586 return cards(width, 'card', sections, alt)
587}
588
589function fitMono(text: string, maxWidth: number, size: number): string {
590 const chars = Array.from(text.replace(/\t/gu, ' '))
591 const fits = Math.floor(maxWidth / (size * 0.62))
592
593 return chars.length <= fits ? chars.join('') : `${chars.slice(0, Math.max(1, fits - 1)).join('')}…`
594}
595
596// An eyebrow line heading a card, with an optional second one at its right.
597export function eyebrowSection(left: string, right: string | null, rightTone: string, width: number): Section {
598 return {
599 height: EYEBROW + 10,
600 draw: y => eyebrowSvg(left, PAD, y + EYEBROW) + (right === null ? '' : eyebrowSvg(right, width - PAD, y + EYEBROW, rightTone, 'end')),
601 }
602}
603hooks/relay.ts 240 lines1import type { Answer, Link, Presence, Role, Task } from '../types'
2
3export const STALE_MS = 90_000
4
5export const USAGE =
6 '/relay lead <channel> in the planning session, /relay worker [channel] in the session that does the work, /relay send [text], /relay off, /relay alone shows what is pending.'
7
8// Prompts for the other session come as fenced blocks without a language or
9// tagged as text; a block tagged bash, json or ts is code to read, not to relay.
10const PROMPT_LANGUAGES = new Set(['', 'text', 'txt', 'plaintext', 'markdown', 'md', 'prompt'])
11
12const OPENING_FENCE = /^( {0,3})(`{3,}|~{3,})(.*)$/
13const CLOSING_FENCE = /^ {0,3}(`{3,}|~{3,})\s*$/
14
15export type RelayCommand =
16 | { kind: 'open' }
17 | { kind: 'join'; role: Role; channel: string | null }
18 | { kind: 'send'; text: string }
19 | { kind: 'off' }
20 | { kind: 'invalid'; reason: string }
21
22export function promptBlocksOf(answer: string, minChars: number): string[] {
23 const blocks: string[] = []
24 let open: { indent: number; marker: string; language: string; lines: string[] } | null = null
25 for (const line of answer.replace(/\r\n?/g, '\n').split('\n')) {
26 if (open === null) {
27 const match = OPENING_FENCE.exec(line)
28 const [, indent = '', marker = '', info = ''] = match ?? []
29 // A backtick fence whose info string holds a backtick is inline code.
30 if (match === null || (marker.startsWith('`') && info.includes('`'))) {
31 continue
32 }
33 open = { indent: indent.length, marker, language: (info.trim().split(/\s+/)[0] ?? '').toLowerCase(), lines: [] }
34 continue
35 }
36 const closing = CLOSING_FENCE.exec(line)?.[1]
37 if (closing !== undefined && closing[0] === open.marker[0] && closing.length >= open.marker.length) {
38 const body = open.lines.join('\n').trim()
39 if (PROMPT_LANGUAGES.has(open.language) && body.length >= minChars) {
40 blocks.push(body)
41 }
42 open = null
43 continue
44 }
45 const strip = Math.min(open.indent, line.length - line.trimStart().length)
46 open.lines.push(line.slice(strip))
47 }
48
49 return blocks
50}
51
52export function channelOf(text: string): string | null {
53 const channel = text.trim().toLowerCase().replace(/\s+/g, '-')
54
55 return /^[a-z0-9][a-z0-9._-]{0,39}$/.test(channel) ? channel : null
56}
57
58export function folderNameOf(path: string): string {
59 const parts = path.replace(/\\/g, '/').split('/').filter(i => i.length > 0)
60
61 return parts.at(-1) ?? ''
62}
63
64export function parseCommand(args: string): RelayCommand {
65 const trimmed = args.trim()
66 if (trimmed.length === 0) {
67 return { kind: 'open' }
68 }
69 const word = trimmed.split(/\s+/)[0] ?? ''
70 const remainder = trimmed.slice(word.length).trim()
71 const action = word.toLowerCase()
72 if (action === 'lead' || action === 'worker') {
73 if (remainder.length === 0) {
74 return { kind: 'join', role: action, channel: null }
75 }
76 const channel = channelOf(remainder)
77 if (channel === null) {
78 return {
79 kind: 'invalid',
80 reason: `"${remainder}" is not a channel name: letters, digits, dots, dashes and underscores, up to 40.`,
81 }
82 }
83
84 return { kind: 'join', role: action, channel }
85 }
86 if (action === 'send') {
87 return { kind: 'send', text: remainder }
88 }
89 if (action === 'off') {
90 return { kind: 'off' }
91 }
92
93 return { kind: 'invalid', reason: USAGE }
94}
95
96export function previewOf(text: string, maxChars = 70): string {
97 const line =
98 text
99 .split('\n')
100 .map(i => i.replace(/^\s*(#+|>|[-*+]|\d+\.)\s+/, '').replace(/[*_`]/g, '').trim())
101 .find(i => i.length > 0) ?? ''
102
103 return line.length > maxChars ? `${line.slice(0, maxChars - 1).trimEnd()}…` : line
104}
105
106export function formatChars(count: number): string {
107 return count < 1000 ? `${count} chars` : `${(count / 1000).toFixed(1)}k chars`
108}
109
110export function formatDuration(ms: number): string {
111 const seconds = Math.max(0, Math.floor(ms / 1000))
112 if (seconds < 60) {
113 return `${seconds}s`
114 }
115 const minutes = Math.floor(seconds / 60)
116 if (minutes < 60) {
117 return `${minutes}m`
118 }
119
120 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
121}
122
123export function forwardTextOf(channel: string, answers: readonly Answer[]): string {
124 if (answers.length === 1) {
125 return `The latest answer from the Claude session working on "${channel}", relayed unchanged:\n\n${answers[0]?.text ?? ''}`
126 }
127 const parts = answers.map((answer, index) => `--- Answer ${index + 1} of ${answers.length} ---\n\n${answer.text}`)
128
129 return `The latest ${answers.length} answers from the Claude session working on "${channel}", oldest first, relayed unchanged:\n\n${parts.join('\n\n')}`
130}
131
132export function isOnline(presence: Presence | null, now: number): presence is Presence {
133 return presence !== null && now - presence.seenAt <= STALE_MS
134}
135
136export type StatusInput = {
137 link: Link
138 presence: Presence | null
139 answers: readonly Answer[]
140 tasks: readonly Task[]
141 isAttached: boolean
142 isActive: boolean
143 now: number
144}
145
146export function statusOf(input: StatusInput): string {
147 const { link, presence, answers, tasks, isAttached, isActive, now } = input
148 if (link.role === 'worker') {
149 const parts = [`⇄ ${link.channel} · worker`]
150 if (!isActive) {
151 parts.push('another session is the worker')
152 } else if (tasks.length > 0) {
153 parts.push('task waiting')
154 }
155
156 return parts.join(' · ')
157 }
158 const parts = [`⇄ ${link.channel}`]
159 if (!isOnline(presence, now)) {
160 parts.push('no worker open')
161 } else if (presence.busySince !== null) {
162 parts.push(`working ${formatDuration(now - presence.busySince)}`)
163 }
164 if (answers.length > 0) {
165 const count = answers.length === 1 ? '1 answer' : `${answers.length} answers`
166 parts.push(isAttached ? `${count} attached` : `${count} waiting`)
167 }
168
169 return parts.join(' · ')
170}
171
172export function footerOf(input: StatusInput): string {
173 const { link, presence, answers, tasks, isAttached, isActive, now } = input
174 const parts = [`⇄ ${link.channel}`]
175 if (link.role === 'worker') {
176 parts.push('worker')
177 if (!isActive) {
178 parts.push('⏸')
179 } else if (tasks.length > 0) {
180 parts.push('📥')
181 }
182
183 return parts.join(' ')
184 }
185 if (!isOnline(presence, now)) {
186 parts.push('offline')
187 } else if (presence.busySince !== null) {
188 parts.push(`⚙ ${formatDuration(now - presence.busySince)}`)
189 }
190 if (answers.length > 0) {
191 parts.push(`${isAttached ? '📎' : '📨'} ${answers.length}`)
192 }
193
194 return parts.join(' ')
195}
196
197export function newId(now: number, random: number): string {
198 const suffix = Math.floor(random * 36 ** 6)
199 .toString(36)
200 .padStart(6, '0')
201
202 return `${String(now).padStart(14, '0')}-${suffix}`
203}
204
205function isRecord(value: unknown): value is Record<string, unknown> {
206 return typeof value === 'object' && value !== null && !Array.isArray(value)
207}
208
209export function isLink(value: unknown): value is Link {
210 return (
211 isRecord(value) &&
212 (value.role === 'lead' || value.role === 'worker') &&
213 typeof value.channel === 'string' &&
214 channelOf(value.channel) === value.channel
215 )
216}
217
218export function isTask(value: unknown): value is Task {
219 return (
220 isRecord(value) &&
221 typeof value.id === 'string' &&
222 typeof value.text === 'string' &&
223 typeof value.isFresh === 'boolean' &&
224 typeof value.sentAt === 'number'
225 )
226}
227
228export function isAnswer(value: unknown): value is Answer {
229 return isRecord(value) && typeof value.id === 'string' && typeof value.text === 'string' && typeof value.finishedAt === 'number'
230}
231
232export function isPresence(value: unknown): value is Presence {
233 return (
234 isRecord(value) &&
235 typeof value.sessionId === 'string' &&
236 (value.busySince === null || typeof value.busySince === 'number') &&
237 typeof value.seenAt === 'number'
238 )
239}
240types/index.d.ts 47 lines1export type Role = 'lead' | 'worker'
2
3export type Link = {
4 role: Role
5 channel: string
6}
7
8export type Task = {
9 id: string
10 text: string
11 isFresh: boolean
12 sentAt: number
13}
14
15export type Answer = {
16 id: string
17 text: string
18 finishedAt: number
19}
20
21export type Presence = {
22 sessionId: string
23 busySince: number | null
24 seenAt: number
25}
26
27export type Offer = {
28 blocks: string[]
29 index: number
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'session-relay': {
35 link: Link | null
36 tasks: Task[]
37 answers: Answer[]
38 offer: Offer | null
39 isAttached: boolean
40 presence: Presence | null
41 isActive: boolean
42 footer: string | null
43 isHubbed: boolean
44 }
45 }
46}
47