Shortcuts to /dev/null, a social network for devs: commit your own words without leaving Claude.

Claude Code and Codex plugin for /dev/null, a social network for devs (source).
/dev/null is made of human messages. This plugin is only a shortcut to commit your own words from Claude Code or Codex, and to read the log from Claude Code: it never writes, rephrases or picks a message for you, and the assistant cannot invoke it on its own.
/dev/null was built in two days, in the open, by a few dozen contributors. One of its authors summed it up:
Yes, we used AI, but we were still in charge. […]
Humans are longing for genuine human contact, now more than ever. That's what this platform is proving.
This plugin follows the same line:
Both marketplaces install the latest release, 0.8.0, never the code of main, which is not released yet.
/plugin marketplace add jubianchi/claude-dev-null
/plugin install dev-null@claude-dev-null
Add the marketplace from a shell:
codex plugin marketplace add jubianchi/claude-dev-null
Then install dev-null from the /dev/null marketplace (codex-dev-null) in the plugin browser.
Explicitly select this plugin's commit skill with $ and append [--reply <commit hash or link>] [--image <path>]... <your message>. The skill sends only the message supplied with that invocation; it cannot be selected implicitly by Codex.
Codex needs local shell execution, network access, and access to your SSH agent. Its normal execution approvals apply: Claude's allowed-tools declaration does not pre-approve commands in Codex. The composer and the log below are available only in Claude Code.
For Claude Code:
Fetch the latest version of the marketplace, then of the plugin, from a shell:
claude plugin marketplace update claude-dev-null
claude plugin update dev-null@claude-dev-null
Then run /reload-plugins in the sessions already open, or start a new one.
For Codex, refresh the marketplace with codex plugin marketplace upgrade codex-dev-null, then update the installed plugin through the plugin browser and start a new session.
In Claude Code:
/dev-null:commit It was DNS.
/dev-null:commit --reply 0be0130 Agreed!
/dev-null:commit --image shipped.png Shipped!
Commits are plain text, up to 4,000 characters, a link counting for 23 at most: a longer message is not sent, and the skill never shortens it for you. --image attaches the image at a path, relative to the folder Claude Code runs in or starting with ~/, up to 3 times: JPEG, PNG or WebP files of up to 10 MB each. Quote a path with spaces. The skill only attaches the images you name.
The message is sent as typed through the official script (curl -fsSL https://dev-null.sh/commit | bash), which signs it with an SSH key from your GitHub account. Claude does not ask you to approve the command: you started the commit yourself, and only the skill's own commit.sh is allowed, for that turn only. Claude runs it without a terminal, so the key must be loaded in your SSH agent (ssh-add) if it has a passphrase.
The skill runs where your SSH key is: Claude Code in a terminal or in the desktop app's Code tab, not in a chat.
/dev-null:compose
/dev-null:compose always
/dev-null:compose manual
/dev-null:compose opens a composer above the prompt: a field for your message, an optional one for the path of an image, an optional one for the commit you reply to (its hash or link), and Commit and Cancel. It counts the characters as you type and says why a commit cannot be sent yet; Commit only shows once it can. Commit, or Enter in the message or reply field, sends it through the same commit.sh, directly: your words never go through the model. While it is being sent, Committing… shows in place of Commit, and pressing again sends nothing more. The result, the commit's link or the error, shows in a toast; after a commit, the composer closes.
Enter in the image field adds the image to the commit, listed under it with a × to take it out: up to 3 JPEG, PNG or WebP files of up to 10 MB each. A path left in the field when you commit goes with the commit too. A path is relative to the folder Claude Code runs in, or starts with ~/ for your home folder; a file dropped from the Finder on the terminal, its path escaped or quoted as for a shell, works too.
/dev-null:compose closes it again when it is open, and keeps your draft for the next one.
/dev-null:compose always keeps the composer shown, from one session to the next: after a commit it is emptied for the next one, and Cancel becomes Clear. /dev-null:compose manual, or /dev-null:compose alone, hides it again, goes back to showing it on demand and keeps your draft for the next /dev-null:compose. The settings of the log (⚙) choose between the two too.
The composer is drawn in a terminal and in the desktop app's Code tab. A chat does not run the plugin's hooks module, so /dev-null:compose only says so there.
/dev-null:log
/dev-null:log opens the /dev/null log in a pane, and closes it when run again. The composer stays at the top, then come the tabs, then the log; only the log scrolls. The composer shows once only: above the prompt while it is open there (or always, with /dev-null:compose always), the pane then showing the tabs and the log alone; in the pane otherwise. Closing it above the prompt moves it into the pane, the draft with it.
Each commit shows who wrote it, in the color of their lane, when, its words (with their links, mentions, code and blocks of code; the start of a long one, as /dev/null shows it, then the whole of it in the pane once Show more is pressed), the commit it quotes, its link card and its images, its stars and replies (once you approved the plugin, ⭐️ stars the commit of someone else, or takes your star back; dimmed while you have not starred it). Where the terminal draws pictures, as Ghostty and kitty do, the images are drawn in the log, their link under them; elsewhere, in tmux, across ssh or in the desktop app, they stay links. A poll shows its options, their share once /dev/null shows the votes, and how long is left. Once you approved the plugin, you vote in it as on the site: press an option, then Vote; a vote is final. Until then, the poll links to dev-null.sh, to vote there. Its replies sit under it, newest first, each followed by its own replies, one level right, down to the fourth: 3 under it and under each reply, as /dev/null shows them by default, then Show N more, which shows as many more; the settings (⚙) choose how many, as the site does, which keeps its choice in your browser only. Its count of replies, 💬, puts the commit in the composer's reply field, and its short hash links to it on dev-null.sh. More, at the end of the log, loads its next page.
Once you approved the plugin, 🧵 opens the thread of a commit in place of the log: the commits it replies to, from the first, then the commit and its replies. ←, in place of the tabs, shows the log again, where it was scrolled to.
A tab pressed loads its log again, the one shown too, from its top. The settings (⚙) have the log shown do so every minute, from one session to the next, keeping your place and the pages More loaded: the commits that arrive above it wait there, and ↑ N new commits, a blue badge over the top of the log as on the site, shows them, as scrolling up to them does. A log loads one page and reads the replies of its commits with ten requests at most, to stay well within /dev/null's 60 requests a minute.
Following is read as a device you approve once on dev-null.sh. Sign in with dev-null.sh, in the settings (⚙), creates the key of the device the first time, an ed25519 key the plugin keeps in its store, on your machine, and hands to ssh-keygen only; only its signatures are sent, to dev-null.sh only. Revoke the device among those of your account on dev-null.sh to end it there; Forget the approval forgets it here.
devnull://authorize?code=… link dev-null.sh sends your browser to, which it cannot open (from the Location of the answer to Approve, in the network tab of its developer tools, if it shows it nowhere else), and paste it there within 5 minutes. The plugin keeps it for that sign-in only, never in its store, and sends its code to dev-null.sh only. Never paste it in the conversation: the assistant must not see it.The cookie earlier versions signed in with is no longer used, and the plugin forgets it.
The log is drawn in a terminal and in the desktop app's Code tab, like the composer.
MIT, like /dev/null itself.
hooks/register.tsx 527 lines1import type { EngineInterface, ProcessRunResult, Register, RenderSurface, Timer } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3import type { Refresh } from './application/web-logs'
4import type { ComposerProps } from './components/composer'
5import Composer, { composerRows } from './components/composer'
6import NewCommits from './components/new-commits'
7import { attached, check } from './draft'
8import { scrolled } from './log'
9import type { Route } from './router'
10import { isSameRoute } from './router'
11import { isAutoRefresh } from './state/auto-refresh'
12import { currentMode, setMode } from './state/mode'
13import { addNewCommits, forgetNewCommits, newCommitsOf } from './state/new-commits'
14import {
15 askForPictures,
16 drawsPictures,
17 forgetFailedPictures,
18 PROBE,
19 probeSource,
20 removePictures,
21} from './state/pictures'
22import { goBack, openView, pushView, scrollRoute } from './state/router'
23import { readSpinner } from './state/spinner'
24import { forgetLoadings } from './state/web-logs'
25import { availableOf, shownRoute, tabsOf, viewFor, viewsOf } from './views'
26import type { Layout, List, View } from './views/view'
27
28const COMMAND = 'dev-null:compose'
29const USAGE = `Usage: /${COMMAND} [always|manual]`
30const LOG_COMMAND = 'dev-null:log'
31const LOG_PANE = 'dev-null-log'
32
33/** How often a log refreshes on its own: one page and ten requests for replies at most, a minute. */
34const REFRESH_EVERY = 60_000
35
36const isOpen = atom({ plugin: 'dev-null', key: 'isOpen' } as const, false)
37
38/** Whether the composer is shown above the prompt: always, or once opened there, the log pane open or not. */
39async function isBandWanted($: EngineInterface): Promise<boolean> {
40 return (await currentMode($)) === 'always' || (await read($, isOpen))
41}
42
43/**
44 * The keys of the store the plugin kept the cookie it signed in with in, before the device did: forgotten, for the
45 * cookie no longer to be kept anywhere.
46 */
47const COOKIE_KEYS = ['cookie', 'username']
48
49/**
50 * Whether the host shows the log: it keeps the pane, and the module its timers, when a `/clear` starts the state
51 * over.
52 */
53async function isLogShown($: EngineInterface): Promise<boolean> {
54 return (await $.ui.panes()).some(pane => pane.id === LOG_PANE)
55}
56
57/** The timer refreshing the log on its own, while it is open and auto is on; a reload of the module drops it. */
58let refreshTimer: Timer | undefined
59/**
60 * The session the timer was last armed, or left unarmed, in: a `/clear` goes on under another, where the timer of the
61 * one before draws nothing, if it still runs at all.
62 */
63let armedIn: string | undefined
64
65function stopRefresh(): void {
66 refreshTimer?.cancel()
67 refreshTimer = undefined
68}
69
70/**
71 * Arms the timer again once the log is drawn in a session it was not armed in, after the drawing, which may not write
72 * the state.
73 */
74async function armRefreshInSession($: EngineInterface): Promise<void> {
75 const session = await $.session.id()
76
77 if (armedIn !== session) {
78 armedIn = session
79 $.clock.after(0, () => armRefresh($))
80 }
81}
82
83async function armRefresh($: EngineInterface): Promise<void> {
84 stopRefresh()
85 armedIn = await $.session.id()
86
87 if (!((await isLogShown($)) && (await isAutoRefresh($)) && viewFor($, (await shownRoute($)).view).refresh)) {
88 return
89 }
90
91 refreshTimer = $.clock.every(REFRESH_EVERY, async () => {
92 void refreshShown($, 'kept')
93 })
94}
95
96/**
97 * Loads again the view the pane shows: from its top, scrolled to it, as the person asks; or keeping what it showed, as
98 * it does on its own, scrolled to the commit or reply it showed first, the commits that arrived above it said.
99 */
100async function refreshShown($: EngineInterface, how: Refresh = 'top'): Promise<void> {
101 const route = await shownRoute($)
102 const { refresh, items } = viewFor($, route.view)
103
104 if (refresh === undefined) {
105 return
106 }
107
108 const anchor = how === 'kept' ? (await items?.(route))?.[route.offset] : undefined
109 const arrivedIds = await refresh(route, how)
110 const after = await shownRoute($)
111
112 if (!isSameRoute(after, route)) {
113 return
114 }
115
116 if (how === 'top') {
117 await forgetNewCommits($, route.view)
118 await scrollRoute($, after, 0)
119
120 return
121 }
122
123 const offset = anchor === undefined ? -1 : ((await items?.(after)) ?? []).indexOf(anchor)
124
125 if (offset !== -1) {
126 await scrollRoute($, after, offset)
127 }
128
129 await addNewCommits($, route.view, arrivedIds)
130}
131
132/** Shows the top of the view the pane shows, where the commits that arrived on top of it are, seen since. */
133async function showNewCommits($: EngineInterface): Promise<void> {
134 const route = await shownRoute($)
135 await scrollRoute($, route, 0)
136 await forgetNewCommits($, route.view)
137}
138
139/**
140 * Loads again the log the pane shows, and arms again the timer refreshing it on its own, a minute from now: it starts
141 * over with every refresh, but for the next page.
142 */
143async function refreshNow($: EngineInterface): Promise<void> {
144 await armRefresh($)
145 await refreshShown($)
146}
147
148/**
149 * Shows the view of `id`, alone, loaded again, as a press of its tab asks, even the one shown, and tries again the
150 * pictures that could not be loaded.
151 */
152async function showView($: EngineInterface, id: string): Promise<void> {
153 forgetFailedPictures()
154 await openView($, id, tabsOf(viewsOf($)))
155 await refreshNow($)
156}
157
158/** Shows the view of `id`, showing `params`, over the one shown, unless it shows already, loaded again. */
159async function openOver($: EngineInterface, id: string, params: Record<string, string>): Promise<void> {
160 const shown = await shownRoute($)
161
162 if (!isSameRoute(shown, { view: id, params, offset: 0 })) {
163 await pushView($, shown, id, params)
164 await refreshNow($)
165 }
166}
167
168/** Shows the route under the one shown again, as it was scrolled, refreshed on its own again if it refreshes. */
169async function showBack($: EngineInterface): Promise<void> {
170 await goBack($)
171 await armRefresh($)
172}
173
174/** Shows the view of `id` over the one shown, or closes it, showing that one again, when it shows already. */
175async function toggleView($: EngineInterface, id: string): Promise<void> {
176 const shown = await shownRoute($)
177
178 if (shown.view === id) {
179 await showBack($)
180 } else {
181 await pushView($, shown, id)
182 await armRefresh($)
183 }
184}
185
186/** The rows the tabs take above the list, with the row above them. */
187const TAB_ROWS = 2
188
189/**
190 * The rows above the list: the composer, as tall as the images of its draft make it, unless it shows above the prompt,
191 * and the tabs.
192 */
193async function fixedRows($: EngineInterface): Promise<number> {
194 return ((await isBandWanted($)) ? 0 : composerRows((await read($, images)).length)) + TAB_ROWS
195}
196/** The columns the log's padding takes. */
197const LOG_INSET = 2
198
199/**
200 * How the pane was last drawn, which the scroll hook is not told: its columns inside the log's padding, and whether it
201 * drew pictures, none where the surface draws none.
202 */
203let layout: Layout = { columns: 80 - LOG_INSET, drawsPictures: false }
204
205/** Whether the log draws pictures on `surface`: those loaded, where the terminal draws pictures; none elsewhere. */
206async function picturesOn($: EngineInterface, surface: RenderSurface): Promise<boolean> {
207 return surface === 'terminal' && (await drawsPictures($)) === 'drawn'
208}
209
210/** What `route` lists, and the rows its items are shown in, in a pane body `bodyRows` high. */
211async function list(
212 $: EngineInterface,
213 route: Route,
214 bodyRows: number,
215): Promise<{ heights: number[]; visible: number }> {
216 const listed: List = (await viewFor($, route.view).list?.(route, layout)) ?? { heights: [], aboveRows: 0 }
217
218 return { heights: listed.heights, visible: bodyRows - (await fixedRows($)) - listed.aboveRows }
219}
220
221const message = atom({ plugin: 'dev-null', key: 'message' } as const, '')
222const reply = atom({ plugin: 'dev-null', key: 'reply' } as const, '')
223const image = atom({ plugin: 'dev-null', key: 'image' } as const, '')
224const images = atom({ plugin: 'dev-null', key: 'images' } as const, [])
225
226/** The draft the composer shows: its message, the commit it replies to, the image field and the images it carries. */
227async function draftOf($: EngineInterface): Promise<ComposerProps['draft']> {
228 return {
229 message: await read($, message),
230 reply: await read($, reply),
231 image: await read($, image),
232 images: await read($, images),
233 isSending: await read($, isSending),
234 }
235}
236
237/** Adds the image at `path` to the draft, while it carries fewer than MAX_IMAGES, and empties the image field. */
238async function addImage($: EngineInterface, path: string): Promise<void> {
239 await update($, images, paths => attached(paths, path))
240 await update($, image, () => '')
241}
242
243/** The callbacks of the composer, each changing the draft. */
244function composerActions($: EngineInterface) {
245 return {
246 onMessage: (value: string) => update($, message, () => value),
247 onReply: (value: string) => update($, reply, () => value),
248 onImage: (value: string) => update($, image, () => value),
249 onAddImage: (path: string) => addImage($, path),
250 onRemoveImage: (index: number) => update($, images, paths => paths.filter((_, at) => at !== index)),
251 onSend: () => send($),
252 }
253}
254
255async function clear($: EngineInterface): Promise<void> {
256 await update($, message, () => '')
257 await update($, reply, () => '')
258 await update($, image, () => '')
259 await update($, images, () => [])
260}
261
262async function close($: EngineInterface): Promise<void> {
263 await clear($)
264
265 if ((await currentMode($)) === 'manual') {
266 await update($, isOpen, () => false)
267 }
268}
269
270const TOAST = { timeoutMs: 10_000 }
271
272const isSending = atom({ plugin: 'dev-null', key: 'isSending' } as const, false)
273
274/**
275 * Whether a commit is being sent: kept in the module, not in the state, which a dispatch reads at the one moment it
276 * started: a second press, quick enough, would not see the first.
277 */
278let sending = false
279
280/** Sends the draft, once at a time: a press while a commit is being sent sends nothing more. */
281async function send($: EngineInterface): Promise<void> {
282 if (sending) {
283 return
284 }
285
286 sending = true
287 await update($, isSending, () => true)
288
289 try {
290 await sendDraft($)
291 } finally {
292 sending = false
293 await update($, isSending, () => false)
294 }
295}
296
297async function sendDraft($: EngineInterface): Promise<void> {
298 const text = await read($, message)
299 const target = await read($, reply)
300 const carried = attached(await read($, images), await read($, image))
301
302 if (check(text, target, carried).error !== undefined) {
303 return
304 }
305
306 const argv = [
307 `${$.plugin.root}/skills/commit/commit.sh`,
308 ...(target === '' ? [] : ['--reply', target]),
309 ...carried.flatMap(path => ['--image', path]),
310 ]
311
312 let result: ProcessRunResult
313 try {
314 result = await $.process.run(argv, { stdin: text, timeoutMs: 60_000 })
315 } catch (error) {
316 $.ui.toast(
317 `devnull: could not run the commit script (${error instanceof Error ? error.message : String(error)}).`,
318 TOAST,
319 )
320
321 return
322 }
323
324 if (result.exitCode !== 0) {
325 $.ui.toast(result.stderr.trim() || `devnull: the commit failed (exit code ${result.exitCode}).`, TOAST)
326
327 return
328 }
329
330 $.ui.toast(result.stdout.trim(), TOAST)
331 await close($)
332}
333
334export const register: Register = on => {
335 on('command.run', { command: COMMAND }, async ($, e) => {
336 switch (e.args.trim()) {
337 case '':
338 if ((await currentMode($)) === 'always') {
339 await setMode($, 'manual')
340 await update($, isOpen, () => false)
341
342 return { text: `Composer closed, shown on demand again with /${COMMAND}.` }
343 }
344
345 if (await read($, isOpen)) {
346 await update($, isOpen, () => false)
347
348 return { text: 'Composer closed.' }
349 }
350
351 await update($, isOpen, () => true)
352
353 return { text: 'Composer open.' }
354 case 'always':
355 await setMode($, 'always')
356
357 return { text: 'Composer always shown.' }
358 case 'manual':
359 await setMode($, 'manual')
360 await update($, isOpen, () => false)
361
362 return { text: `Composer shown on demand, with /${COMMAND}.` }
363 default:
364 return { text: USAGE }
365 }
366 })
367
368 on('command.run', { command: LOG_COMMAND }, async $ => {
369 if (await isLogShown($)) {
370 await $.ui.close({ id: LOG_PANE })
371
372 return { text: 'Log closed.' }
373 }
374
375 await $.ui.open({ id: LOG_PANE, title: '/dev/null' })
376 await refreshNow($)
377
378 return { text: 'Log open.' }
379 })
380
381 on('session.start', async ($, e, next) => {
382 for (const key of COOKIE_KEYS) {
383 await $.store.delete(key)
384 }
385
386 await forgetLoadings($)
387 await armRefresh($)
388
389 return next(e)
390 })
391
392 on('session.end', async ($, e, next) => {
393 await removePictures($, e.reason)
394
395 return next(e)
396 })
397
398 on('ui.scroll', { component: 'Pane', requestId: LOG_PANE }, async ($, e, next) => {
399 const route = await shownRoute($)
400 const { heights, visible } = await list($, route, e.bodyRows)
401 const offset = scrolled(route.offset, e.by, heights, visible)
402 await scrollRoute($, route, offset)
403
404 if (offset === 0) {
405 await forgetNewCommits($, route.view)
406 }
407
408 return next({ ...e, offset: 0 })
409 })
410
411 on('ui.close', async (_$, e, next) => {
412 if (e.id === LOG_PANE) {
413 stopRefresh()
414 }
415
416 return next(e)
417 })
418
419 on('ui.render', { component: 'Pane', requestId: LOG_PANE }, async ($, e, next) => {
420 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
421 return next(e)
422 }
423
424 await armRefreshInSession($)
425 const ui = $.ui.resolve(e)
426 const { Box, Button, Text } = ui
427 const draft = await draftOf($)
428 const hasComposer = !(await isBandWanted($))
429 const route = await shownRoute($)
430 const view = viewFor($, route.view)
431 const available = await availableOf(viewsOf($))
432 const tabs = available.filter(found => found.placement === 'tab')
433 const tools = available.filter(found => found.placement === 'toolbar')
434 layout = { columns: e.props.bodyColumns - LOG_INSET, drawsPictures: await picturesOn($, e.surface) }
435 const { heights, visible } = await list($, route, e.props.scroll.bodyRows)
436 const offset = scrolled(route.offset, 0, heights, visible)
437 const newCount = (await newCommitsOf($, route.view)).length
438 await readSpinner($)
439 const now = await $.clock.now()
440 const Probe = e.surface === 'terminal' && (await drawsPictures($)) === 'unknown' && 'Image' in ui ? ui.Image : null
441 const probe = Probe === null ? undefined : await probeSource($)
442
443 if (Probe !== null) {
444 await askForPictures($, LOG_PANE)
445 }
446
447 const viewButton = (found: View, key: string, onPress: () => Promise<void>) => (
448 <Button key={key} onPress={onPress}>
449 {route.view === found.id ? (
450 <Text weight="bold" underline>
451 {found.label}
452 </Text>
453 ) : (
454 <Text dimColor>{found.label}</Text>
455 )}
456 </Button>
457 )
458
459 return (
460 <Box flexDirection="column">
461 {hasComposer && (
462 <Composer ui={ui} draft={draft} cancelLabel="Clear" {...composerActions($)} onCancel={() => clear($)} />
463 )}
464 <Box key="tabs" justifyContent="space-between" paddingX={1} marginTop={1}>
465 {view.placement === 'detail' ? (
466 <Box gap={2}>
467 <Button key="back" label="←" onPress={() => showBack($)} />
468 <Text weight="bold">{view.label}</Text>
469 </Box>
470 ) : (
471 <Box gap={2}>{tabs.map(found => viewButton(found, `tab-${found.id}`, () => showView($, found.id)))}</Box>
472 )}
473 <Box gap={2}>
474 {offset > 0 && (
475 <Box key="above">
476 <Text dimColor>↑ {offset} more</Text>
477 </Box>
478 )}
479 {tools.map(found => viewButton(found, found.id, () => toggleView($, found.id)))}
480 {Probe !== null && probe !== undefined && <Probe key={PROBE} source={probe} columns={1} rows={1} alt=" " />}
481 </Box>
482 </Box>
483 <Box key="view" flexDirection="column">
484 {
485 await view.render(route, {
486 ui,
487 ...layout,
488 offset,
489 heights,
490 visible,
491 now,
492 replyTo: entry => update($, reply, () => entry.shortHash),
493 isAvailable: id => available.some(found => found.id === id),
494 open: (id, params) => openOver($, id, params),
495 })
496 }
497 {newCount > 0 && <NewCommits ui={ui} count={newCount} onPress={() => showNewCommits($)} />}
498 </Box>
499 </Box>
500 )
501 })
502
503 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
504 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
505 return next(e)
506 }
507
508 const isAlways = (await currentMode($)) === 'always'
509
510 if (e.props.hasSurvey || !(await isBandWanted($))) {
511 return next(e)
512 }
513
514 const draft = await draftOf($)
515
516 return (
517 <Composer
518 ui={$.ui.resolve(e)}
519 draft={draft}
520 cancelLabel={isAlways ? 'Clear' : 'Cancel'}
521 {...composerActions($)}
522 onCancel={() => close($)}
523 />
524 )
525 })
526}
527hooks/application/web-logs.ts 184 lines1import type { LogEntry, LogName, WebLog } from '../../types'
2import type { Api } from '../infrastructure/api'
3import { DeviceNotApproved } from '../infrastructure/api'
4import { readApiLog } from '../infrastructure/api-log'
5import { readApiReplies } from '../infrastructure/api-replies'
6import type { WebApp } from '../infrastructure/web-app'
7import { SignInRequired } from '../infrastructure/web-app'
8import type { LogPage } from '../infrastructure/web-log'
9import { readLog } from '../infrastructure/web-log'
10import { readReplies } from '../infrastructure/web-replies'
11import { threadsOf } from '../log'
12
13export const EMPTY: WebLog = { commits: [], replies: [], next: null, error: '' }
14
15function isEntry(value: unknown): value is LogEntry {
16 const entry = value as Partial<LogEntry> | null
17
18 return (
19 typeof entry === 'object' &&
20 entry !== null &&
21 typeof entry.id === 'string' &&
22 Array.isArray(entry.segments) &&
23 entry.preview !== undefined &&
24 entry.poll !== undefined &&
25 (entry.poll === null || entry.poll.options.every(option => typeof option.id === 'string')) &&
26 entry.quote !== undefined &&
27 entry.quotedId !== undefined &&
28 entry.continuationId !== undefined &&
29 entry.deletedAt !== undefined &&
30 typeof entry.mentions === 'object' &&
31 (entry.quote === null || isEntry(entry.quote))
32 )
33}
34
35/**
36 * A log kept from an earlier load, if it has the shape this load draws: the session keeps its state across a reload
37 * of the module, whose entries may have changed since. Undefined, it is to be loaded again.
38 */
39export function current(value: unknown): WebLog | undefined {
40 const log = value as Partial<WebLog> | null | undefined
41
42 return typeof log === 'object' &&
43 log !== null &&
44 Array.isArray(log.commits) &&
45 Array.isArray(log.replies) &&
46 log.commits.every(isEntry) &&
47 log.replies.every(isEntry) &&
48 (log.next === null || typeof log.next === 'string') &&
49 typeof log.error === 'string'
50 ? (log as WebLog)
51 : undefined
52}
53
54function unique(entries: readonly LogEntry[]): LogEntry[] {
55 return [...new Map(entries.map(found => [found.id, found])).values()]
56}
57
58/** The replies of `replies` that thread under `commits`, the others left out. */
59function threaded(commits: readonly LogEntry[], replies: readonly LogEntry[]): LogEntry[] {
60 const kept = new Set(threadsOf(commits, replies).flatMap(thread => thread.replies.map(reply => reply.id)))
61
62 return unique(replies).filter(found => kept.has(found.id))
63}
64
65function bytes(log: WebLog): number {
66 return new TextEncoder().encode(JSON.stringify(log)).length
67}
68
69/**
70 * `log`, in `budget` bytes of JSON at most: its last commits left out, with their replies, until it fits, and the
71 * pages after them, which its next cursor would skip; nothing when even its first commit does not fit.
72 */
73export function within(log: WebLog, budget: number): WebLog | undefined {
74 if (bytes(log) <= budget) {
75 return log
76 }
77
78 for (let count = log.commits.length - 1; count > 0; count--) {
79 const commits = log.commits.slice(0, count)
80 const kept = { ...log, commits, replies: threaded(commits, log.replies), next: null }
81
82 if (bytes(kept) <= budget) {
83 return kept
84 }
85 }
86
87 return undefined
88}
89
90/** Why `what` could not be loaded, as the log says it. */
91export function failure(what: string, error: unknown): string {
92 if (error instanceof DeviceNotApproved || error instanceof SignInRequired) {
93 return 'Sign in again from the settings (⚙): /dev/null no longer knows this device.'
94 }
95
96 return `Could not load ${what} (${error instanceof Error ? error.message : String(error)}).`
97}
98
99/** Where a log is read from: a page of it, and the replies of its commits down their threads, `known` left out. */
100export type LogReader = {
101 page: (name: LogName, cursor?: string) => Promise<LogPage>
102 replies: (commits: readonly LogEntry[], known: readonly LogEntry[]) => Promise<LogEntry[]>
103}
104
105/** The logs as the pages of the web app serve them. */
106export function webReader(app: WebApp): LogReader {
107 return {
108 page: (name, cursor) => readLog(app, name, cursor === undefined ? {} : { cursor }),
109 replies: (commits, known) => readReplies(app, commits, known),
110 }
111}
112
113/** The logs as the API serves them to the device it knows by the key `api` signs with. */
114export function apiReader(api: Pick<Api, 'get'>): LogReader {
115 return {
116 page: (name, cursor) => readApiLog(api, name, cursor === undefined ? {} : { cursor }),
117 replies: (commits, known) => readApiReplies(api, commits, known),
118 }
119}
120
121/** Loads a page of `name` with the replies of its commits, those of `known` read again only when they grew. */
122async function load(
123 reader: LogReader,
124 name: LogName,
125 cursor: string | undefined,
126 known: readonly LogEntry[],
127): Promise<{ commits: LogEntry[]; replies: LogEntry[]; next: string | null }> {
128 const page = await reader.page(name, cursor)
129 const shown = unique([...page.replies, ...known])
130 const read = await reader.replies(page.commits, shown)
131
132 return { commits: page.commits, replies: [...shown, ...read], next: page.next }
133}
134
135/**
136 * How a log loads its first page again: from it alone (`top`), or with the commits `previous` showed after it, which
137 * goes on where `previous` went on (`kept`), for the person reading not to lose their place.
138 */
139export type Refresh = 'top' | 'kept'
140
141/**
142 * Loads the first page of `name` again, keeping the replies `previous` read for the commits still there, and the
143 * commits it showed after the first page when `how` keeps them; when it cannot, keeps what `previous` showed and says
144 * why.
145 */
146export async function refresh(
147 reader: LogReader,
148 name: LogName,
149 previous: WebLog = EMPTY,
150 how: Refresh = 'top',
151): Promise<WebLog> {
152 try {
153 const page = await load(reader, name, undefined, previous.replies)
154 const shown = new Set(page.commits.map(found => found.id))
155 const after = how === 'kept' ? previous.commits.filter(found => !shown.has(found.id)) : []
156 const commits = [...page.commits, ...after]
157
158 return {
159 commits,
160 replies: threaded(commits, page.replies),
161 next: after.length === 0 ? page.next : previous.next,
162 error: '',
163 }
164 } catch (error) {
165 return { ...previous, error: failure(`the ${name} log`, error) }
166 }
167}
168
169/** Adds the next page of `name` to what `current` shows, if the log goes on; when it cannot, says why. */
170export async function more(reader: LogReader, name: LogName, current: WebLog): Promise<WebLog> {
171 if (current.next === null) {
172 return current
173 }
174
175 try {
176 const page = await load(reader, name, current.next, [])
177 const commits = unique([...current.commits, ...page.commits])
178
179 return { commits, replies: threaded(commits, [...current.replies, ...page.replies]), next: page.next, error: '' }
180 } catch (error) {
181 return { ...current, error: failure(`the ${name} log`, error) }
182 }
183}
184hooks/components/composer.tsx 120 lines1import type { Elements } from 'claude-code'
2
3import { attached, check, MAX_IMAGES, MAX_LENGTH, MIN_LENGTH, thousands } from '../draft'
4import Logo from './logo'
5
6/** The background of the reply field once filled, for the commit to be seen as a reply: a color of the theme. */
7export const REPLYING = 'diffAddedDimmed'
8
9/**
10 * The rows the composer takes with `images` images: its frame, the logo, the message, the image field while more
11 * images fit, a row per image, and the reply.
12 */
13export function composerRows(images: number): number {
14 return 9 + (images < MAX_IMAGES ? 1 : 0) + images
15}
16
17export type ComposerProps = {
18 ui: Pick<Elements['terminal'] | Elements['desktop'], 'Box' | 'Button' | 'Input' | 'Text'>
19 /**
20 * The draft: its message, the commit it replies to, the path typed in the image field, the images it carries, and
21 * whether it is being sent.
22 */
23 draft: { message: string; reply: string; image: string; images: readonly string[]; isSending: boolean }
24 cancelLabel: string
25 onMessage: (value: string) => Promise<unknown>
26 onReply: (value: string) => Promise<unknown>
27 onImage: (value: string) => Promise<unknown>
28 /** Adds the image at a path to the commit. */
29 onAddImage: (path: string) => Promise<unknown>
30 /** Takes the image at an index of the draft's images out of the commit. */
31 onRemoveImage: (index: number) => Promise<unknown>
32 onSend: () => Promise<unknown>
33 onCancel: () => Promise<unknown>
34}
35
36export default function Composer(props: ComposerProps) {
37 const { ui, draft, cancelLabel, onMessage, onReply, onImage, onAddImage, onRemoveImage, onSend, onCancel } = props
38 const { Box, Button, Input, Text } = ui
39 const { length, error } = check(draft.message, draft.reply, attached(draft.images, draft.image))
40 const shownError = length < MIN_LENGTH ? undefined : error
41
42 return (
43 <Box flexDirection="column" borderStyle="round" paddingX={1}>
44 <Box justifyContent="space-between" marginBottom={1}>
45 <Logo ui={ui} />
46 <Box key="count">
47 {shownError !== undefined ? (
48 <Box key="error">
49 <Text color="error">{shownError}</Text>
50 </Box>
51 ) : (
52 <Text dimColor={length <= MAX_LENGTH}>
53 {thousands(length)} / {thousands(MAX_LENGTH)}
54 </Text>
55 )}
56 </Box>
57 </Box>
58 <Input
59 key="message"
60 rows={4}
61 autoFocus
62 value={draft.message}
63 placeholder="What did you ship?"
64 onInput={onMessage}
65 onSubmit={async value => {
66 await onMessage(value)
67 await onSend()
68 }}
69 submitLabel="commit"
70 />
71 {draft.images.length < MAX_IMAGES && (
72 <Input
73 key="image"
74 label="Image "
75 value={draft.image}
76 placeholder="path of a JPEG, PNG or WebP (optional)"
77 onInput={onImage}
78 onSubmit={onAddImage}
79 submitLabel="add"
80 />
81 )}
82 {draft.images.map((path, index) => (
83 <Box key={`image-${index}`} gap={1} paddingLeft={2}>
84 <Text dimColor>🖼</Text>
85 <Box key={`image-path-${index}`} flexShrink={1}>
86 <Text wrap="truncate-middle">{path}</Text>
87 </Box>
88 <Button key={`remove-image-${index}`} label="×" plain dimColor onPress={() => onRemoveImage(index)} />
89 </Box>
90 ))}
91 <Box justifyContent="space-between">
92 <Box key="reply-field" {...(draft.reply === '' ? {} : { backgroundColor: REPLYING })}>
93 <Input
94 key="reply"
95 label="Reply to "
96 value={draft.reply}
97 placeholder="commit hash or link (optional)"
98 onInput={onReply}
99 onSubmit={async value => {
100 await onReply(value)
101 await onSend()
102 }}
103 submitLabel="commit"
104 />
105 </Box>
106 <Box>
107 {draft.isSending ? (
108 <Box key="committing">
109 <Text dimColor>Committing…</Text>
110 </Box>
111 ) : (
112 error === undefined && <Button key="commit" label="Commit" variant="primary" onPress={onSend} />
113 )}
114 <Button key="cancel" label={cancelLabel} variant="secondary" onPress={onCancel} />
115 </Box>
116 </Box>
117 </Box>
118 )
119}
120hooks/components/new-commits.tsx 33 lines1import type { Elements } from 'claude-code'
2
3export type NewCommitsProps = {
4 ui: Pick<Elements['terminal'] | Elements['desktop'], 'Box' | 'Button' | 'Text'>
5 /** How many commits arrived above the log, not seen yet. */
6 count: number
7 onPress: () => Promise<unknown>
8}
9
10/** The blue of the badge of the web app, which the white of its words stands out on. */
11export const BADGE = '#2563eb'
12
13/**
14 * The commits that arrived above the log, in a badge drawn over its top, centered, as the web app draws it: it takes
15 * no row of the log.
16 */
17export default function NewCommits(props: NewCommitsProps) {
18 const { ui, count, onPress } = props
19 const { Box, Button, Text } = ui
20
21 return (
22 <Box key="new-commits-badge" position="absolute" top={0} left={0} right={0} justifyContent="center">
23 <Box key="new-commits-pill" backgroundColor={BADGE}>
24 <Button key="new-commits" plain onPress={onPress}>
25 <Text color="#ffffff" weight="bold">
26 {` ↑ ${count} new commit${count === 1 ? '' : 's'} `}
27 </Text>
28 </Button>
29 </Box>
30 </Box>
31 )
32}
33hooks/draft.ts 93 lines1import { linksIn } from './body'
2
3export const MIN_LENGTH = 1
4export const MAX_LENGTH = 4000
5
6/** The characters a link of the text counts for at most. */
7const LINK_LENGTH = 23
8
9/** A block of code, from a line of ``` or more to a line of ``` or more, or code between backticks on one line. */
10const CODE = /((?<![^\n])`{3,}[^`\r\n]*\r?\n(?:[\s\S]*?\r?\n)?`{3,}[ \t]*(?![^\r\n])|`[^`\r\n]+`)/
11
12/** The images a commit carries at most. */
13export const MAX_IMAGES = 3
14
15const REPLY = /^[A-Za-z0-9:/._@-]*$/
16const IMAGE = /\.(jpe?g|png|webp)$/i
17
18export type Check = { length: number; error?: string }
19
20export function characters(text: string): number {
21 return [...text].length
22}
23
24/** The prose of `text`, the parts outside code, in its order. */
25function prose(text: string): string[] {
26 return text.split(CODE).filter((_, index) => index % 2 === 0)
27}
28
29/** `text` as /dev/null keeps it: trimmed, its line ends `\n`, and the blank lines of its prose run into one. */
30function normalized(text: string): string {
31 return text
32 .trim()
33 .replace(/\r\n?/g, '\n')
34 .split(CODE)
35 .map((part, index) => (index % 2 === 0 ? part.replace(/(?:[ \t]*\n){3,}/g, '\n\n') : part))
36 .join('')
37}
38
39/** The characters of a commit, as /dev/null counts them: once kept, each link of its prose for 23 at most. */
40export function lengthOf(message: string): number {
41 const kept = normalized(message)
42
43 return prose(kept)
44 .flatMap(linksIn)
45 .reduce((total, link) => total - Math.max(0, characters(link) - LINK_LENGTH), characters(kept))
46}
47
48export function thousands(n: number): string {
49 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
50}
51
52/**
53 * The path of a file as typed in the image field: what a terminal pastes for a file dropped on it, escaped (`a\ b`) or
54 * quoted (`'a b'`) as for a shell, is the file it names.
55 */
56export function imagePath(typed: string): string {
57 const trimmed = typed.trim()
58 const quoted = /^(['"])(.*)\1$/s.exec(trimmed)
59
60 return quoted?.[2] ?? trimmed.replace(/\\(.)/gs, '$1')
61}
62
63/** The images a commit carries: those added, then the path left in the image field, while fewer than MAX_IMAGES. */
64export function attached(images: readonly string[], typed: string): string[] {
65 const path = imagePath(typed)
66
67 return path === '' || images.length >= MAX_IMAGES ? [...images] : [...images, path]
68}
69
70export function check(message: string, reply: string, images: readonly string[] = []): Check {
71 const length = lengthOf(message)
72
73 if (length < MIN_LENGTH) {
74 return { length, error: `At least ${MIN_LENGTH} character.` }
75 }
76
77 if (length > MAX_LENGTH) {
78 const over = length - MAX_LENGTH
79
80 return { length, error: `Too long by ${thousands(over)} character${over === 1 ? '' : 's'}: shorten it yourself.` }
81 }
82
83 if (!REPLY.test(reply)) {
84 return { length, error: 'Reply to a commit hash (0be0130) or its link.' }
85 }
86
87 if (!images.every(image => IMAGE.test(image))) {
88 return { length, error: 'Images are JPEG, PNG or WebP files.' }
89 }
90
91 return { length }
92}
93hooks/log.ts 414 lines1import type { LogAuthor, LogEntry, LogImage } from '../types'
2import type { Drawing } from './body'
3import { parts, shownText } from './body'
4import { shown } from './picture'
5import { UNVOTED_INSET, VOTED_INSET } from './poll'
6
7export type Entry = LogEntry
8
9/** The replies of the commit or reply `id` left out, `count` of them, which would show `depth` levels down. */
10export type More = { id: string; count: number; depth: number }
11
12/**
13 * A reply of a thread, `depth` levels under the commit the thread starts from, and the replies left out of those it
14 * ends, the deepest first, whose Show more it is followed by.
15 */
16export type Reply = Entry & { depth: number; more?: More[] }
17
18export type Thread = { commit: Entry; replies: Reply[] }
19
20/** How many levels replies go right, the deeper ones staying on the last. */
21export const MAX_DEPTH = 4
22
23/** The six lanes of /dev/null, in its dark theme. */
24export const LANES = ['#ff8a4c', '#51cf66', '#da77f2', '#3bc9db', '#f06595', '#fcc419'] as const
25
26/** A lane for a developer whose lane is not known, always the same. */
27export function lane(username: string): string {
28 let hash = 0
29 for (const character of username.toLowerCase()) {
30 hash = (hash * 31 + (character.codePointAt(0) ?? 0)) >>> 0
31 }
32
33 return LANES[hash % LANES.length] as string
34}
35
36/** The color of the rails of /dev/null, in its dark theme, which a deleted commit is drawn in. */
37export const RAIL = '#36363a'
38
39/**
40 * The color a developer's commits are drawn in: the lane they picked on /dev/null, or one of the lanes; that of the
41 * rails for a commit deleted, which has no author.
42 */
43export function laneColor(author: Pick<LogAuthor, 'username' | 'lane'> | null): string {
44 if (author === null) {
45 return RAIL
46 }
47
48 const { username, lane: picked } = author
49
50 if (typeof picked === 'string') {
51 return picked
52 }
53
54 return typeof picked === 'number' ? (LANES[picked] ?? lane(username)) : lane(username)
55}
56
57const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
58
59/**
60 * How long before `now` (in milliseconds) a commit was made, as /dev/null says it: `now`, `42m`, `5h`, `3d`, then the
61 * date, with its year when not this year's.
62 */
63export function ago(now: number, createdAt: string): string {
64 const time = Date.parse(createdAt)
65
66 if (Number.isNaN(time)) {
67 return ''
68 }
69
70 const minutes = Math.floor((now - time) / 60_000)
71
72 if (minutes < 1) {
73 return 'now'
74 }
75
76 if (minutes < 60) {
77 return `${minutes}m`
78 }
79
80 if (minutes < 24 * 60) {
81 return `${Math.floor(minutes / 60)}h`
82 }
83
84 if (minutes < 7 * 24 * 60) {
85 return `${Math.floor(minutes / (24 * 60))}d`
86 }
87
88 const date = new Date(time)
89 const day = `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`
90
91 return date.getUTCFullYear() === new Date(now).getUTCFullYear() ? day : `${day}, ${date.getUTCFullYear()}`
92}
93
94/**
95 * The first item a list shows once the person scrolled it `by` rows from `offset`, the items being `heights` rows
96 * tall and the list `visible` rows high: a wheel tick or an arrow moves one item, a move of rows the items those rows
97 * hold, and the list stops once its end shows.
98 */
99export function scrolled(offset: number, by: number, heights: readonly number[], visible: number): number {
100 let last = heights.length
101 let fitting = 0
102 while (last > 0 && fitting + (heights[last - 1] ?? 0) <= visible) {
103 fitting += heights[last - 1] ?? 0
104 last--
105 }
106
107 const max = Math.max(0, Math.min(last, heights.length - 1))
108 let first = Math.min(Math.max(offset, 0), max)
109 let moved = 0
110
111 while (by > 0 && moved < by && first < max) {
112 moved += heights[first] ?? 0
113 first++
114 }
115
116 while (by < 0 && moved < -by && first > 0) {
117 first--
118 moved += heights[first] ?? 0
119 }
120
121 return first
122}
123
124/** The columns a commit's lane and padding take, left of its words. */
125const COMMIT_INSET = 4
126/** The columns `∟ ` takes left of a reply, at each level. */
127export const REPLY_INSET = 2
128/** The columns a link card's frame and padding take. */
129const CARD_INSET = 4
130
131function lines(words: string, width: number): number {
132 return words
133 .split('\n')
134 .reduce((total, line) => total + Math.max(1, Math.ceil([...line].length / Math.max(1, width))), 0)
135}
136
137/** The rows a commit's words take `width` columns wide: its text as drawn, and each block of code in its frame. */
138function bodyRows(segments: Entry['segments'], width: number, drawing: Drawing = {}): number {
139 return parts(segments).reduce(
140 (total, part) =>
141 total +
142 (part.type === 'prose'
143 ? lines(shownText(part.prose, drawing), width)
144 : 2 + (part.language === null ? 0 : 1) + lines(part.code, width - CARD_INSET)),
145 0,
146 )
147}
148
149/** The rows a poll takes `width` columns wide: a row above it, its options, and where it is. */
150function pollRows(poll: Entry['poll'], width: number): number {
151 if (poll === null) {
152 return 0
153 }
154
155 return (
156 1 +
157 poll.options.reduce(
158 (total, option) =>
159 total + lines(' '.repeat(option.votes === null ? UNVOTED_INSET : VOTED_INSET) + option.label, width),
160 0,
161 ) +
162 1
163 )
164}
165
166/** Whether the picture at a URL is drawn in the log, rather than linked. */
167export type IsDrawn = (url: string) => boolean
168
169const NONE_DRAWN: IsDrawn = () => false
170
171/** The columns a commit's words take in a log `columns` wide, right of its lane and padding. */
172export function wordsWidth(columns: number): number {
173 return columns - COMMIT_INSET
174}
175
176/** The rows images take `width` columns wide: each picture drawn, its box and its link; the others, a row of links. */
177export function imageRows(images: readonly LogImage[], width: number, isDrawn: IsDrawn): number {
178 const drawn = images.filter(image => isDrawn(image.url))
179
180 return (
181 drawn.reduce((total, image) => total + shown(image, width).rows + 1, 0) + (drawn.length < images.length ? 1 : 0)
182 )
183}
184
185/** The rows the card of a link takes `width` wide: its frame, its site, its title and its description; none without a title. */
186function cardRows(card: Entry['linkPreview'], width: number): number {
187 return card === null || card.title === null
188 ? 0
189 : 2 + 1 + lines(card.title, width) + (card.description === null ? 0 : lines(card.description, width))
190}
191
192/** What /dev/null says in place of a commit quoted that was deleted. */
193export const DELETED_QUOTE = '[deleted] This commit was deleted.'
194
195/** What /dev/null says in place of a commit quoted out of sight: it comes without it. */
196export const UNAVAILABLE_QUOTE = "[unavailable] This commit isn't available."
197
198/**
199 * What a commit says in place of the one it quotes, if anything: that it was deleted, or, under a commit a thread
200 * starts from, that it is out of sight; replies come without the commits they quote, which a thread does not draw.
201 */
202export function quoteNotice(entry: Entry, isReply: boolean): string | null {
203 if (entry.quote !== null) {
204 return entry.quote.deletedAt === null ? null : DELETED_QUOTE
205 }
206
207 return entry.quotedId === null || isReply ? null : UNAVAILABLE_QUOTE
208}
209
210function commitRows(entry: Entry, columns: number, isOrphan: boolean, isDrawn: IsDrawn, isReply = false): number {
211 const width = wordsWidth(columns)
212 const quote = entry.quote
213 const notice = quoteNotice(entry, isReply)
214 const quoteRows =
215 notice !== null
216 ? 2 + lines(notice, width - CARD_INSET)
217 : quote === null
218 ? 0
219 : 2 +
220 1 +
221 bodyRows(quote.preview ?? quote.segments, width - CARD_INSET, { isStatic: true }) +
222 (quote.images.length > 0 ? 1 : 0) +
223 pollRows(quote.poll, width - CARD_INSET) +
224 cardRows(quote.linkPreview, width - CARD_INSET * 2)
225
226 return (
227 1 +
228 (isOrphan ? 1 : 0) +
229 bodyRows(entry.preview ?? entry.segments, width) +
230 pollRows(entry.poll, width) +
231 quoteRows +
232 cardRows(entry.linkPreview, width - CARD_INSET) +
233 imageRows(entry.images, width, isDrawn) +
234 1 +
235 1 +
236 1
237 )
238}
239
240export function level(reply: Reply): number {
241 return Math.min(reply.depth, MAX_DEPTH)
242}
243
244/**
245 * How many rows a thread takes in a log `columns` wide, as Thread draws it: each commit's header, lines, link card,
246 * images, the pictures `isDrawn` says are drawn, a row, its counts and the row its frame leaves, its replies narrower
247 * at each level, and a row below.
248 */
249export function rows(thread: Thread, columns: number, isDrawn: IsDrawn = NONE_DRAWN): number {
250 return entryRows(thread, columns, isDrawn).reduce((total, height) => total + height, 0)
251}
252
253/** The rows of a thread's commit, then of each of its replies. */
254function entryRows(thread: Thread, columns: number, isDrawn: IsDrawn): number[] {
255 return [
256 commitRows(thread.commit, columns, thread.commit.parentId !== null, isDrawn),
257 ...thread.replies.map(
258 reply =>
259 commitRows(reply, columns - REPLY_INSET * level(reply), false, isDrawn, true) + (reply.more?.length ?? 0),
260 ),
261 ]
262}
263
264/**
265 * The rows of each item of a list of threads `columns` wide, each commit and each reply an item of its own, and of
266 * the More button when the log goes on.
267 */
268export function heights(
269 list: readonly Thread[],
270 columns: number,
271 hasMore: boolean,
272 isDrawn: IsDrawn = NONE_DRAWN,
273): number[] {
274 return [...list.flatMap(thread => entryRows(thread, columns, isDrawn)), ...(hasMore ? [MORE_ROWS] : [])]
275}
276
277/** The commits and replies of a list of threads, in the order of its items. */
278export function entries(list: readonly Thread[]): Entry[] {
279 return list.flatMap(thread => [thread.commit, ...thread.replies])
280}
281
282/** Where a list of threads starts: the thread, and in it the commit (`from` 0) or its reply `from - 1`. */
283export type Start = { thread: number; from: number }
284
285/** The id of the commit or reply a list of threads shows first once scrolled `offset` items down, if any. */
286export function anchorAt(list: readonly Thread[], offset: number): string | undefined {
287 return entries(list)[offset]?.id
288}
289
290/** How many items down a list of threads shows the commit or reply `id`, if it shows it. */
291export function offsetOf(list: readonly Thread[], id: string): number | undefined {
292 const index = entries(list).findIndex(found => found.id === id)
293
294 return index === -1 ? undefined : index
295}
296
297/**
298 * The ids of the commits that arrived on top of a log, from `previous` to `next`: those above the first commit of
299 * `previous` that `next` still shows; none when they share none.
300 */
301export function arrived(previous: readonly Entry[], next: readonly Entry[]): string[] {
302 const ids = next.map(found => found.id)
303 const first = previous.map(found => ids.indexOf(found.id)).find(index => index !== -1)
304
305 return first === undefined ? [] : ids.slice(0, first)
306}
307
308/** Where a list of threads starts once scrolled `offset` items down, each commit and each reply an item. */
309export function located(list: readonly Thread[], offset: number): Start {
310 let left = Math.max(0, offset)
311
312 for (const [thread, { replies }] of list.entries()) {
313 if (left <= replies.length) {
314 return { thread, from: left }
315 }
316 left -= replies.length + 1
317 }
318
319 return { thread: list.length, from: 0 }
320}
321
322/** The rows the box of More, or of the loader, takes: its frame and a row. */
323const MORE_ROWS = 3
324
325const newestFirst = (a: Entry, b: Entry): number => b.createdAt.localeCompare(a.createdAt)
326
327/**
328 * Threads a commit with `replies`, each right under what it replies to or quotes, one level deeper, newest first; what `seen`
329 * holds is left out, and what is threaded added to it.
330 */
331function threading(replies: readonly Entry[]): (commit: Entry, seen: Set<string>) => Thread {
332 const children = new Map<string, Entry[]>()
333
334 for (const found of [...replies].sort(newestFirst)) {
335 for (const parent of new Set([found.parentId, found.quotedId])) {
336 if (parent !== null) {
337 children.set(parent, [...(children.get(parent) ?? []), found])
338 }
339 }
340 }
341
342 return (commit, seen) => {
343 const threaded: Reply[] = []
344 const visit = (parent: Entry, depth: number): void => {
345 for (const child of children.get(parent.id) ?? []) {
346 if (!seen.has(child.id)) {
347 seen.add(child.id)
348 threaded.push({ ...child, depth })
349 visit(child, depth + 1)
350 }
351 }
352 }
353
354 seen.add(commit.id)
355 visit(commit, 1)
356
357 return { commit, replies: threaded }
358 }
359}
360
361/**
362 * `thread` with the newest `perParent` replies of its commit and of each reply, or as many as `shown` asks for it,
363 * the replies of a reply left out with it; the last reply shown under a commit or reply says how many are left.
364 */
365export function limited(thread: Thread, perParent: number, shown: Readonly<Record<string, number>> = {}): Thread {
366 const limit = (id: string) => shown[id] ?? perParent
367 const depths = new Map([[thread.commit.id, 0]])
368 const counts = new Map<string, number>()
369 const hidden = new Set<string>()
370 const kept: { reply: Reply; ancestors: string[] }[] = []
371 const path = [thread.commit.id]
372
373 for (const reply of thread.replies) {
374 path.length = reply.depth
375 const parent = path.at(-1) ?? thread.commit.id
376 const index = counts.get(parent) ?? 0
377 counts.set(parent, index + 1)
378 depths.set(reply.id, reply.depth)
379
380 if (hidden.has(parent) || index >= limit(parent)) {
381 hidden.add(reply.id)
382 } else {
383 kept.push({ reply: { ...reply, more: [] }, ancestors: [...path] })
384 }
385
386 path.push(reply.id)
387 }
388
389 for (const [parent, count] of [...counts].sort(([a], [b]) => (depths.get(b) ?? 0) - (depths.get(a) ?? 0))) {
390 const last = kept.findLast(found => found.ancestors.includes(parent))
391
392 if (!hidden.has(parent) && count > limit(parent) && last !== undefined) {
393 last.reply.more?.push({ id: parent, count: count - limit(parent), depth: (depths.get(parent) ?? 0) + 1 })
394 }
395 }
396
397 return { commit: thread.commit, replies: kept.map(found => found.reply) }
398}
399
400/** Threads the commits of a log, in its order, each with its `replies`; a reply to none of them is left out. */
401export function threadsOf(commits: readonly Entry[], replies: readonly Entry[]): Thread[] {
402 const thread = threading(replies)
403 const seen = new Set<string>()
404 const log: Thread[] = []
405
406 for (const commit of commits) {
407 if (!seen.has(commit.id)) {
408 log.push(thread(commit, seen))
409 }
410 }
411
412 return log
413}
414hooks/router.ts 72 lines1import type { Route } from '../types'
2
3export type { Route }
4
5function top(view: string, params: Record<string, string> = {}): Route {
6 return { view, params, offset: 0 }
7}
8
9/**
10 * The route the pane shows: the one on top of `routes`, or `initial` while none is chosen; `fallback`, from its top,
11 * in place of a view that is not available.
12 */
13export function shown(
14 routes: readonly Route[],
15 initial: string,
16 isAvailable: (view: string) => boolean,
17 fallback: string,
18): Route {
19 const route = routes.at(-1) ?? top(initial)
20
21 return isAvailable(route.view) ? route : top(fallback)
22}
23
24/** The routes once `view` is opened: it alone, from its top. */
25export function opened(view: string): Route[] {
26 return [top(view)]
27}
28
29/**
30 * The routes once `view` is pushed over `shown`, the route the pane shows, which is shown again, as it was scrolled,
31 * once back: the one on top, or else the one shown in place of the routes, initial or fallback.
32 */
33export function pushed(
34 routes: readonly Route[],
35 shown: Route,
36 view: string,
37 params: Record<string, string> = {},
38): Route[] {
39 return [...under(routes, shown), top(view, params)]
40}
41
42/** The routes `shown` is on top of: the routes, when it is; else `shown` alone. */
43function under(routes: readonly Route[], shown: Route): Route[] {
44 const last = routes.at(-1)
45
46 return last !== undefined && isSameRoute(last, shown) ? [...routes] : [shown]
47}
48
49/** The routes once back from the one on top: the one under it, if any. */
50export function back(routes: readonly Route[]): Route[] {
51 return routes.length > 1 ? routes.slice(0, -1) : [...routes]
52}
53
54/**
55 * The routes once `shown` is scrolled to `offset`: the one on top, when it is `shown`; else `shown` alone, which the
56 * pane showed in place of the routes, initial or fallback.
57 */
58export function scrolledTo(routes: readonly Route[], shown: Route, offset: number): Route[] {
59 return [...under(routes, shown).slice(0, -1), { ...shown, offset }]
60}
61
62/** Whether `one` and `other` show the same view of the same things, wherever they are scrolled to. */
63export function isSameRoute(one: Route, other: Route): boolean {
64 const keys = Object.keys(one.params)
65
66 return (
67 one.view === other.view &&
68 keys.length === Object.keys(other.params).length &&
69 keys.every(key => one.params[key] === other.params[key])
70 )
71}
72hooks/state/auto-refresh.ts 18 lines1import type { EngineInterface } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4const autoRefresh = atom({ plugin: 'dev-null', key: 'autoRefresh' } as const, '')
5
6/** Whether the log refreshes on its own, every minute, from one session to the next. */
7export async function isAutoRefresh($: EngineInterface): Promise<boolean> {
8 const value = await read($, autoRefresh)
9
10 return value === '' ? (await $.store.get('autoRefresh')) === true : value === 'on'
11}
12
13/** Turns auto refresh on or off, for the next sessions too; the log arms its timers once shown again. */
14export async function setAutoRefresh($: EngineInterface, isOn: boolean): Promise<void> {
15 await $.store.set('autoRefresh', isOn)
16 await update($, autoRefresh, () => (isOn ? 'on' : 'off'))
17}
18hooks/state/mode.ts 19 lines1import type { EngineInterface } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4/** Whether the composer shows above the prompt always, or on demand. */
5export type Mode = 'always' | 'manual'
6
7const mode = atom({ plugin: 'dev-null', key: 'mode' } as const, '')
8
9export async function currentMode($: EngineInterface): Promise<Mode> {
10 const value = (await read($, mode)) || (await $.store.get('mode'))
11
12 return value === 'always' ? 'always' : 'manual'
13}
14
15export async function setMode($: EngineInterface, value: Mode): Promise<void> {
16 await $.store.set('mode', value)
17 await update($, mode, () => value)
18}
19hooks/state/new-commits.ts 26 lines1import type { EngineInterface } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4const newCommits = atom({ plugin: 'dev-null', key: 'newCommits' } as const, {})
5
6/** The ids of the commits that arrived above the view `view` while it refreshed on its own, not seen yet. */
7export async function newCommitsOf($: EngineInterface, view: string): Promise<string[]> {
8 return (await read($, newCommits))[view] ?? []
9}
10
11/** Adds `ids`, the commits that arrived above the view `view`, on top of those not seen yet. */
12export async function addNewCommits($: EngineInterface, view: string, ids: readonly string[]): Promise<void> {
13 if (ids.length > 0) {
14 await update($, newCommits, found => ({ ...found, [view]: [...new Set([...ids, ...(found[view] ?? [])])] }))
15 }
16}
17
18/** Forgets the commits that arrived above the view `view`, seen once it shows its top. */
19export async function forgetNewCommits($: EngineInterface, view: string): Promise<void> {
20 await update($, newCommits, found => {
21 const { [view]: _, ...others } = found
22
23 return others
24 })
25}
26hooks/state/pictures.ts 275 lines1import type { EngineInterface, ImageSource, Timer } from 'claude-code'
2
3import { loadPicture } from '../application/pictures'
4import { makeFolder, removeFolder, removePixels, savePixels } from '../infrastructure/picture-file'
5import type { Run } from '../infrastructure/web-picture'
6import { downloadPicture } from '../infrastructure/web-picture'
7import { LeastRecent } from '../least-recent'
8import type { KeptPicture } from '../picture'
9
10/** Whether the terminal draws pictures: not asked yet, or as it answered. */
11export type Drawing = 'unknown' | 'drawn' | 'alt'
12
13/** The key of the Image the log draws while it asks the terminal whether it draws pictures. */
14export const PROBE = 'picture-probe'
15
16/** The pixel the probe is drawn from, which no one sees: RGBA, all nought. */
17const PROBE_PIXEL = new Uint8Array(4).toBase64()
18
19/** How many times the probe is blitted before the terminal is taken for one without pictures. */
20const PROBE_TRIES = 3
21
22/**
23 * What the terminal of a session answered, and the file its probe is drawn from: a /clear or a resume goes on under
24 * another session, maybe in another terminal. Kept in the module, which the state of a session does not outlive.
25 */
26type Asked = { session: string; drawing: Drawing; tries: number; isAsking: boolean; probe?: string }
27
28let known: Asked | undefined
29
30async function knownIn($: EngineInterface): Promise<Asked> {
31 const session = await $.session.id()
32
33 if (known?.session !== session) {
34 known = { session, drawing: 'unknown', tries: 0, isAsking: false }
35 }
36
37 return known
38}
39
40function runOf($: EngineInterface): Run {
41 return (argv, init) => $.process.run(argv, init)
42}
43
44/**
45 * The folder the pictures of this process are kept in, made once, the first time one is: a /clear or a resume keeps
46 * it, as the pictures loaded; the end of the process removes it.
47 */
48let folder: Promise<string> | undefined
49
50function folderOf($: EngineInterface): Promise<string> {
51 folder ??= makeFolder(runOf($)).catch(error => {
52 folder = undefined
53 throw error
54 })
55
56 return folder
57}
58
59/**
60 * Removes the folder of the pictures, with them, once the session ends with the process: not for a /clear or a
61 * resume, which go on with the pictures loaded.
62 */
63export async function removePictures($: EngineInterface, reason: string): Promise<void> {
64 const made = folder
65
66 if (made === undefined || reason === 'clear' || reason === 'resume') {
67 return
68 }
69
70 folder = undefined
71 await removeFolder(runOf($), await made)
72}
73
74/** Whether the terminal draws pictures. */
75export async function drawsPictures($: EngineInterface): Promise<Drawing> {
76 return (await knownIn($)).drawing
77}
78
79/**
80 * The source the probe is drawn from, once its file is written: a file, as the pictures are, which a terminal does
81 * not read across ssh, where it draws the alt of an Image.
82 */
83export async function probeSource($: EngineInterface): Promise<ImageSource | undefined> {
84 const { probe } = await knownIn($)
85
86 return probe === undefined ? undefined : { file: probe, format: 'rgba', width: 1, height: 1 }
87}
88
89/**
90 * Writes the file the probe is drawn from, a period after the drawing, which would cut short what is started while
91 * it is dispatched; a terminal it cannot be written for draws none.
92 */
93function writeProbe($: EngineInterface, asked: Asked): void {
94 $.clock.after(LOAD_EVERY, async () => {
95 try {
96 asked.probe = await savePixels(runOf($), await folderOf($), PROBE_PIXEL)
97 } catch {
98 asked.drawing = 'alt'
99 } finally {
100 asked.isAsking = false
101 $.ui.invalidate('ui.render')
102 }
103 })
104}
105
106/**
107 * Asks the terminal whether it draws pictures, once the probe Image under `requestId` is drawn from its file: a blit
108 * of it, with another generation of that file, is taken where it does, refused where it draws its alt; any other
109 * refusal, or a failure, asks again on the next drawing, PROBE_TRIES times in all. Asks a period after the drawing,
110 * which gives the terminal the time to draw the probe, and draws again once answered, without writing the state: a
111 * timer started by a drawing may not.
112 */
113export async function askForPictures($: EngineInterface, requestId: string): Promise<void> {
114 const asked = await knownIn($)
115 const probe = asked.probe
116
117 if (asked.drawing !== 'unknown' || asked.isAsking) {
118 return
119 }
120
121 asked.isAsking = true
122
123 if (probe === undefined) {
124 writeProbe($, asked)
125
126 return
127 }
128
129 $.clock.after(LOAD_EVERY, async () => {
130 asked.tries++
131
132 try {
133 const source: ImageSource = { file: probe, format: 'rgba', width: 1, height: 1, generation: asked.tries }
134 const { deny } = await $.ui.blit({ requestId, key: PROBE, source })
135
136 if (deny === undefined) {
137 asked.drawing = 'drawn'
138 } else if (deny.includes('draws its alt')) {
139 asked.drawing = 'alt'
140 }
141 } catch {
142 } finally {
143 if (asked.drawing === 'unknown' && asked.tries >= PROBE_TRIES) {
144 asked.drawing = 'alt'
145 }
146
147 asked.isAsking = false
148 $.ui.invalidate('ui.render')
149 }
150 })
151}
152
153/** How many pixels a picture is kept a side at most: about as many as the 60 columns it is drawn over at most. */
154const PICTURE_SIDE = 640
155/** How many pictures are kept, the oldest drawn left out first, and its file removed: some 1.6 MiB each. */
156const KEPT = 32
157
158/**
159 * The pictures loaded, by URL, the one drawn least recently left out first, each kept in a file of its own; kept in
160 * the module, which a /clear leaves.
161 */
162const pictures = new LeastRecent<KeptPicture>(KEPT)
163/** The pictures that could not be loaded, which are not tried again until the log is refreshed on demand. */
164const failed = new Set<string>()
165/**
166 * The loading of the pictures of a session, which a /clear or a resume starts over: the pictures waiting, one after
167 * another, decoding one taking some 150 ms; the one loading, in the queue no more and not loaded yet, and since when;
168 * and the timer loading them, one a period while some wait. Each period runs on its own, once the drawing that asked
169 * for them is over, which would cut short what was started while it was dispatched.
170 */
171type Loading = {
172 session: string
173 queue: string[]
174 loading?: { url: string; since: number }
175 loader?: Timer
176}
177
178let current: Loading | undefined
179
180/** How often the loader looks for a picture to load. */
181export const LOAD_EVERY = 250
182/**
183 * How long a picture is waited for before it is taken for one that could not be loaded: longer than its download and
184 * its file, 30 seconds each at most.
185 */
186const LOAD_TIMEOUT = 75_000
187
188/** The picture loaded for `url`, if any, to draw: the one drawn last, left out last. */
189export function pictureOf(url: string): KeptPicture | undefined {
190 return pictures.get(url)
191}
192
193/** Whether the picture of `url` is loaded, which leaves the pictures kept as they were: to measure, not to draw. */
194export function isLoaded(url: string): boolean {
195 return pictures.has(url)
196}
197
198/** Tries again the pictures that could not be loaded, as the log is refreshed on demand. */
199export function forgetFailedPictures(): void {
200 failed.clear()
201}
202
203/**
204 * Loads the next picture waiting, unless one is loading, not for LOAD_TIMEOUT yet; stops the loader once none waits.
205 * Draws again once it is loaded, or could not be, without writing the state: a timer started by a drawing may not.
206 */
207async function loadNext($: EngineInterface, state: Loading): Promise<void> {
208 const now = await $.clock.now()
209
210 if (state.loading !== undefined && now - state.loading.since < LOAD_TIMEOUT) {
211 return
212 }
213
214 if (state.loading !== undefined) {
215 failed.add(state.loading.url)
216 }
217
218 const url = state.queue.shift()
219
220 if (url === undefined) {
221 delete state.loading
222 state.loader?.cancel()
223 delete state.loader
224
225 return
226 }
227
228 const loading = { url, since: now }
229 state.loading = loading
230 const run = runOf($)
231
232 try {
233 const kept = await folderOf($)
234 const picture = await loadPicture(
235 address => downloadPicture(run, kept, address),
236 rgba => savePixels(run, kept, rgba),
237 url,
238 PICTURE_SIDE,
239 )
240
241 for (const left of pictures.set(url, picture)) {
242 await removePixels(run, left.file)
243 }
244 } catch {
245 failed.add(url)
246 }
247
248 if (state.loading === loading) {
249 delete state.loading
250 }
251
252 $.ui.invalidate('ui.render')
253}
254
255/**
256 * Loads the pictures at `urls`, those near what the log shows, not loaded, loading or failed yet, one after another,
257 * from a timer of their own: none starts while the drawing that asks for them is dispatched. They replace those
258 * waiting, which the log may no longer show near.
259 */
260export async function loadPictures($: EngineInterface, urls: readonly string[]): Promise<void> {
261 const session = await $.session.id()
262
263 if (current?.session !== session) {
264 current?.loader?.cancel()
265 current = { session, queue: [] }
266 }
267
268 const state = current
269 state.queue = [...new Set(urls)].filter(url => !pictures.has(url) && !failed.has(url) && url !== state.loading?.url)
270
271 if (state.queue.length > 0 && state.loader === undefined) {
272 state.loader = $.clock.every(LOAD_EVERY, () => loadNext($, state))
273 }
274}
275hooks/state/router.ts 56 lines1import type { EngineInterface } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { Route } from '../router'
5import { back, opened, pushed, scrolledTo } from '../router'
6import type { Kept } from './stored'
7import { readKept, writeKept } from './stored'
8
9const routes = atom({ plugin: 'dev-null', key: 'routes' } as const, [])
10
11/** The view opened last, of `views`, kept in the store for a /clear, which starts the state over, to show it still. */
12function keptView(views: readonly string[]): Kept<string> {
13 return {
14 key: 'logTab',
15 version: 1,
16 migrations: {},
17 read: value => (typeof value === 'string' && views.includes(value) ? value : undefined),
18 }
19}
20
21/** The routes of the pane, the one shown on top; none until a view is chosen. */
22export async function readRoutes($: EngineInterface): Promise<Route[]> {
23 return read($, routes)
24}
25
26/** The view the store kept, if it is one of `views`. */
27export async function readKeptView($: EngineInterface, views: readonly string[]): Promise<string | undefined> {
28 return readKept($, keptView(views))
29}
30
31/** Opens `view`, one of `views`, alone, from its top, and keeps it in the store. */
32export async function openView($: EngineInterface, view: string, views: readonly string[]): Promise<void> {
33 await update($, routes, () => opened(view))
34 await writeKept($, keptView(views), view)
35}
36
37/** Shows `view` over `shown`, the route the pane shows, which shows again, as it was scrolled, once back. */
38export async function pushView(
39 $: EngineInterface,
40 shown: Route,
41 view: string,
42 params: Record<string, string> = {},
43): Promise<void> {
44 await update($, routes, found => pushed(found, shown, view, params))
45}
46
47/** Shows the route under the one shown again. */
48export async function goBack($: EngineInterface): Promise<void> {
49 await update($, routes, back)
50}
51
52/** Scrolls `shown`, the route the pane shows, to `offset`. */
53export async function scrollRoute($: EngineInterface, shown: Route, offset: number): Promise<void> {
54 await update($, routes, found => scrolledTo(found, shown, offset))
55}
56