SLOPSHOPPER

dev-null

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

panebandtoastprocessnetworktimer
★ 1v0.9.0-devMITupdated 2026-10-26jubianchi/claude-dev-null
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-null
│ ┃ dev-null-log ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ // /dev/null • a social network for devs0 ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ What did you ship? ⏎ commit ⏺ Update(src/auth.ts) │ ┃ │ Image : path of a JPEG, PNG or WebP (optio ⎿ Added 2 lines, removed 1 line │ ┃ │ Reply to : commit hash or link (optional) ⏺ Bash(bun test) │ ┃ ╰─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ tab-latest ] [ tab-trending ][ settings ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ ⠦ Loading… │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰────────────────────────────────────────╯ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dev-null-log
╭─────────────────────────────────────────────────────────── │ // /dev/null • a social network for devs 0 / 4,000 │ │ What did you ship? ⏎ commit │ Image : path of a JPEG, PNG or WebP (optional) ⏎ add │ Reply to : commit hash or link (optional) ⏎ commit[ Clear ╰─────────────────────────────────────────────────────────── [ tab-latest ] [ tab-trending ] [ settings ] ╭────────────────────────────────────────────────────────╮ │ ⠦ Loading… │ ╰────────────────────────────────────────────────────────╯
README

<img src="https://dev-null.sh/icon-192.png" height="24"/> claude-dev-null

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.

Principles

/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:

  • Your words, not the assistant's. The assistant handles the mechanics (signing, sending, checking the length), never the content. It does not draft, suggest, complete or rephrase a commit, and does not add mentions or signatures.
  • You are in charge. Only you can start a commit, once, with the text you typed. Nothing is posted from a hook, a schedule, a subagent or on the assistant's initiative, and a denied command is never worked around. The one exception is outside the plugin: once the maintainer publishes a release, the release workflow sends the announcement they wrote for it, word for word.
  • Built on what /dev/null provides. Commits go through the official commit script only. The logs are read from /dev/null's signed API, as the device you approved once on dev-null.sh, with a key the plugin keeps on your machine; until you approve it, Latest and Trending from the data the web app loads for them, as it does, signed in as no one; and the pictures of their commits. The log writes through the API only what you ask of it, when you press its button (Vote in a poll, ⭐ to star a commit), and never commits through it.
  • The /dev/null vocabulary. The name is written /dev/null, a post is a commit and a timeline is a log, as in the product description.

Install

Both marketplaces install the latest release, 0.8.0, never the code of main, which is not released yet.

Claude Code

/plugin marketplace add jubianchi/claude-dev-null
/plugin install dev-null@claude-dev-null

Codex

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.

Update

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.

Commit

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.

Compose

/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.

Log

/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.

  • Following: the commits of the people you follow on /dev/null. It shows once you approved the plugin from the settings, and the log opens on it; otherwise, it hides and the log opens on Latest.
  • Latest and Trending: everyone's latest commits, and the trending ones, in the order of /dev/null. They are read from the API once you approved the plugin, and without signing in before.
  • Starred: the commits you starred, the last starred first. Like Following, it shows once you approved the plugin.
  • ⚙, at the end of the row of the tabs: the settings, shown in place of the log until pressed again, or a tab is: approve the plugin as a device of yours on /dev/null, refresh the log on its own, and show the composer above the prompt always, or on demand.

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.

Sign in

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.

  • Once /dev/null lets a terminal sign in (dev-null-sh/dev-null.sh#585), the settings show a code to type on dev-null.sh, and waits for your approval.
  • Until then, the settings approve the plugin as the app of /dev/null is approved, a stopgap: open the page it links to, approve Claude Code, then copy the 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.

License

MIT, like /dev/null itself.

Source 78 files
hooks/register.tsx 527 lines
1import 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}
527
hooks/application/web-logs.ts 184 lines
1import 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}
184
hooks/components/composer.tsx 120 lines
1import 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}
120
hooks/components/new-commits.tsx 33 lines
1import 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}
33
hooks/draft.ts 93 lines
1import { 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}
93
hooks/log.ts 414 lines
1import 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}
414
hooks/router.ts 72 lines
1import 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}
72
hooks/state/auto-refresh.ts 18 lines
1import 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}
18
hooks/state/mode.ts 19 lines
1import 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}
19
hooks/state/new-commits.ts 26 lines
1import 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}
26
hooks/state/pictures.ts 275 lines
1import 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}
275
hooks/state/router.ts 56 lines
1import 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