SLOPSHOPPER

pager-view

This session's pager name and new mail above the prompt; its pager conversation and peers in a pane

newpanebandcommandprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-07iMUngHee/dotfiles/claude/mods/pager-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pager-view
│ ┃ pager ✕ › fix the failing auth test and add an audit log call │ ┃ 1: messages 2: peers │ ┃ This session has no pager name yet. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pager │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pager
1: messages 2: peers This session has no pager name yet.
README

dotfiles

Personal configuration files managed via ~/.config/ and synced with git.

What's tracked

DirectoryToolKey files
ai/Shared (Claude + Codex)PERSONAL.md, guardrails.md, rules/, memory/, skills/, AGENTS.manifest
claude/Claude CodeCLAUDE.md, DEVGUARD.md, settings.json, hooks/, agents/, commands/, skills/ (Claude-only)
codex/Codex CLIconfig.toml.template, skills/ (Codex-only), scripts/
notifier/Shared AI notifiermacOS Swift app, Linux Go daemon, Go sender, shared icon
ghostty/Ghosttyconfig, shaders/ (cursor animation)
aerospace/AeroSpace (macOS)aerospace.toml (shipped default trimmed to digit workspaces so Option stays free for terminal Meta keys; float rules, Omarchy gaps)
brave/Brave Originvimium-options.json (Vimium export; restore by hand from its Options page when Brave Sync has not carried it over)
nvim/Neoviminit.lua, lua/plugins/
tmux/tmuxtmux.conf, scripts/, status/
zsh/Zsh.zshrc, custom/plugins/
homebrew/Homebrew BundleBrewfile, bootstrap.sh (macOS package set + shell env)
arch/Arch Linux / Omarchypackages.sh (pacman + AUR, the Brewfile mapping), bootstrap.sh (packages + mise + shell env)
lib/Shared bootstrapshell-env.sh (~/.zshenv ZDOTDIR + oh-my-zsh, used by both Unix package layers)
windows/Native Windowsbootstrap.ps1, packages.ps1 (winget), profile.ps1, starship.toml, terminal/, notifier/, Cowork skill packaging
.ideavimrcIdeaVim (JetBrains)Standalone file

AI assistant 3-tier layout

ai/ is the single source of truth for tool-agnostic content (rules, memory, most skills). claude/ and codex/ hold per-tool deploy logic and tool-only files. notifier/ is shared runtime infrastructure for Claude/Codex desktop notifications. The orchestrator at ai/scripts/bootstrap.sh calls each tool's bootstrap. See ai/README.md, claude/README.md, codex/README.md.

Everything else under ~/.config/ is gitignored. See .gitignore for the allowlist.

Machine-local settings

Anything true of one machine and not another stays out of the tracked files. Each config loads an optional private file last, so it overrides without being edited into the shared one:

ConfigHook in the tracked fileMachine-local file
zshsource $ZDOTDIR/private.shzsh/private.sh
ghosttyconfig-file = ?"…/private.conf"ghostty/private.conf
tmuxif-shell '[ -r … ]' 'source-file …'tmux/private.conf
nvimplugins/99_private.lua → require("private.plugins")nvim/lua/private/plugins.lua

All four are absent by default and silent when missing, so a fresh clone works with none of them. On Omarchy they are where the theme wiring lives, which is why macOS keeps Catppuccin while that machine follows the system theme.

One case the seam cannot cover: Omarchy's display text size slider rewrites font-size in ghostty/config at a path it hardcodes, so moving the line out would make the slider silently do nothing. That one line is normalized on its way into the index by the clean filter in lib/git-filter-machine-local.sh (wired in .gitattributes, registered by ai/scripts/bootstrap.sh) — the working tree keeps this monitor's size and git never sees it. The same filter drops a # >>> machine-local … # <<< machine-local block whole; aerospace/aerospace.toml keeps its work-machine float rules there, since AeroSpace has no include to put them in a private file.

Submodules

PathRepo
zsh/custom/plugins/zsh-autosuggestionszsh-users/zsh-autosuggestions
zsh/custom/plugins/zsh-history-substring-searchzsh-users/zsh-history-substring-search
zsh/custom/plugins/zsh-syntax-highlightingzsh-users/zsh-syntax-highlighting
zsh/custom/plugins/fzf-tabaloxaf/fzf-tab
ghostty/shaderssahaj-b/ghostty-cursor-shaders

Setup

macOS / Linux

git clone --recurse-submodules <repo> ~/.config
~/.config/bootstrap.sh   # packages + shell env, then deploys Claude + Codex config

bootstrap.sh picks the package layer from the machine, then hands off to the same ai/scripts/bootstrap.sh in every case:

MachinePackage layerSource of claude / codex
macOShomebrew/bootstrap.sh → brew bundleHomebrew casks
Arch / Omarchyarch/bootstrap.sh → pacman + AURmise (~/.config/mise/config.toml, machine-local)
Linux with Homebrew, no pacmanhomebrew/bootstrap.shHomebrew

On Omarchy the package step uses omarchy-pkg-add / omarchy-pkg-aur-add when they exist, so packages installed here look the same as ones added through the Omarchy menu. arch/packages.sh --dry-run prints the plan without installing; --skip-optional leaves out desktop apps and AUR.

~/.config is usually not empty on a fresh Omarchy install — ghostty/, nvim/ and tmux/ already exist. The tracked versions here replace them; move the originals aside first if you want them back.

Windows

winget install --id Git.Git --exact
git clone --recurse-submodules <repo> $HOME\.config
pwsh -File $HOME\.config\windows\bootstrap.ps1   # winget packages + shell/terminal, then the SAME ai/scripts/bootstrap.sh under Git Bash

Requires Developer Mode (Settings > System > For developers) so the deploy can create symlinks unelevated. windows/bootstrap.ps1 replaces bootstrap.sh only at the package layer — the Claude/Codex deploy is delegated to ai/scripts/bootstrap.sh verbatim, so there is one implementation of the 3-tier merge, not two. See windows/README.md.

For per-tool details: ai/, claude/, codex/, windows/.

Source 3 files
hooks/register.tsx 470 lines
1// pager-view: this session's pager name and its new mail as one line above the
2// prompt, and a /pager pane with the session's conversation and its peers.
3// Read-only: it lists and exports; it never sends, marks or delivers anything.
4//
5// I/O happens in events and timers only; ui.render reads $.state and nothing else.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface as Engine, On, RenderElement, RenderInput } from 'claude-code'
9
10import type { Entry, PagerState, Tab } from '../types'
11import {
12  agoOf,
13  conversationOf,
14  exportedOf,
15  flatOf,
16  inboundOf,
17  isMissing,
18  liveOf,
19  nameOf,
20  newCountOf,
21  newestOf,
22  peersOf,
23  rosterOf,
24  stampOf,
25} from './pager'
26import type { Run } from './pager'
27
28export const PANE = 'pager'
29const POLL_MS = 5_000
30const REFRESH_DEBOUNCE_MS = 300
31const SHOWN_ENTRIES = 200
32
33const EMPTY: PagerState = {
34  sessionId: '',
35  name: null,
36  entries: [],
37  peers: [],
38  newCount: 0,
39  live: 0,
40  lastAgo: null,
41  now: 0,
42  error: null,
43}
44
45const pager = atom({ plugin: 'pager-view', key: 'pager' } as const, EMPTY)
46const tab = atom({ plugin: 'pager-view', key: 'tab' } as const, 'messages')
47const pick = atom({ plugin: 'pager-view', key: 'pick' } as const, null)
48
49const baselineKey = (sessionId: string) => `baseline:${sessionId}`
50
51async function run($: Engine, argv: string[]): Promise<Run> {
52  try {
53    return await $.process.run(argv, { timeoutMs: 10_000 })
54  } catch (error) {
55    return { exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
56  }
57}
58
59// Module state: a reload starts it over, as it does $.clock timers. `generation`
60// moves when the session id changes under the process (/clear, /resume, /branch);
61// a refresh begun under an older one is dropped. The baseline lives in $.store,
62// keyed by session id, so a reload keeps it and a new session starts its own.
63let generation = 0
64let isRefreshing = false
65let isDirty = false
66let pending: { cancel: () => void } | null = null
67
68async function isPaneOpen($: Engine): Promise<boolean> {
69  return (await $.ui.panes()).some(one => one.id === PANE)
70}
71
72/** Moves the baseline to `newest` when that is later; answers the baseline in force. */
73async function baselineAt($: Engine, sessionId: string, newest: number, isSeen: boolean): Promise<number> {
74  const stored = await $.store.get(baselineKey(sessionId))
75  const current = typeof stored === 'number' ? stored : null
76  // No baseline yet: what arrived before this session's first look is not new.
77  const next = current === null || (isSeen && newest > current) ? newest : current
78  if (next !== current) await $.store.set(baselineKey(sessionId), next)
79  return next
80}
81
82async function refreshOnce($: Engine): Promise<void> {
83  const started = generation
84  const sessionId = await $.session.id()
85  const isStale = async () => started !== generation || sessionId !== (await $.session.id())
86  const settle = async (next: (prev: PagerState) => PagerState) => {
87    if (await isStale()) return
88    await update($, pager, next)
89  }
90  const failed = (error: string) =>
91    settle(prev => (prev.sessionId === sessionId ? { ...prev, error } : { ...EMPTY, sessionId, error }))
92
93  const whoami = await run($, ['pager', 'whoami', '--session', sessionId])
94  if (isMissing(whoami)) return settle(() => ({ ...EMPTY, sessionId }))
95  const name = nameOf(whoami)
96  if (name !== null && typeof name === 'object') return failed(name.error)
97  if (name === null) return settle(() => ({ ...EMPTY, sessionId }))
98
99  const inbound = inboundOf(await run($, ['pager', 'ls', '--session', sessionId]))
100  if ('error' in inbound) return failed(inbound.error)
101  const rows = exportedOf(await run($, ['pager', 'export']))
102  if ('error' in rows) return failed(rows.error)
103  const isSeen = await isPaneOpen($)
104  // The band counts live peers too, so `who` runs on every refresh (~13 ms measured).
105  const who = peersOf(await run($, ['pager', 'who']))
106  if ('error' in who) return failed(who.error)
107  const now = await $.clock.now()
108  const home = (await $.env.get('HOME')) ?? ''
109  if (await isStale()) return
110
111  const baseline = await baselineAt($, sessionId, newestOf(inbound), isSeen)
112  const entries = conversationOf(rows, inbound, sessionId).slice(-SHOWN_ENTRIES)
113  const last = entries.at(-1)
114  await settle(() => ({
115    sessionId,
116    name,
117    entries,
118    peers: rosterOf(who, home),
119    newCount: newCountOf(inbound, baseline),
120    live: liveOf(who, name),
121    lastAgo: last ? agoOf(last.at, now) : null,
122    now,
123    error: null,
124  }))
125}
126
127async function refresh($: Engine): Promise<void> {
128  if (isRefreshing) {
129    isDirty = true
130    return
131  }
132  isRefreshing = true
133  try {
134    do {
135      isDirty = false
136      await refreshOnce($)
137    } while (isDirty)
138  } finally {
139    isRefreshing = false
140  }
141}
142
143function soon($: Engine): void {
144  pending?.cancel()
145  pending = $.clock.after(REFRESH_DEBOUNCE_MS, () => {
146    pending = null
147    void refresh($)
148  })
149}
150
151/** /pager and a click on the band's name: open the pane, and count the look as seen. */
152async function openPane($: Engine): Promise<void> {
153  await $.ui.open({ id: PANE, title: 'pager', focus: true })
154  // Clear the badge now; the next refresh moves the stored baseline (the pane is open).
155  const state = await read($, pager)
156  const sessionId = await $.session.id()
157  if (state.sessionId === sessionId && state.newCount > 0) {
158    await update($, pager, prev => ({ ...prev, newCount: 0 }))
159  }
160  soon($)
161}
162
163async function switched($: Engine): Promise<void> {
164  generation += 1
165  await update($, pager, () => EMPTY)
166  await update($, pick, () => null)
167}
168
169/** `j`, `k` and a press on a message's time: which message the reader shows. */
170async function pickMessage($: Engine, id: number | null): Promise<void> {
171  await update($, pick, () => id)
172}
173
174export function register(on: On): void {
175  on('session.start', async ($, e, next) => {
176    await $.command.register({
177      name: PANE,
178      description: "Show this session's pager conversation and peers",
179      immediate: true,
180    })
181    const result = await next(e)
182    $.clock.every(POLL_MS, () => void refresh($))
183    soon($)
184    return result
185  })
186
187  // These only observe; if one fails, .catch hands back what next settled to (or
188  // runs it once when it had not run), so a failure never blocks or repeats work.
189  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
190    const result = await next(e)
191    await switched($)
192    soon($)
193    return result
194  }).catch(($, e, next) => next(e))
195
196  on('classic.SessionEnd', async ($, e, next) => {
197    generation += 1
198    return next(e)
199  }).catch(($, e, next) => next(e))
200
201  on('command.run', { command: PANE }, async $ => {
202    await openPane($)
203    return {}
204  }).catch(($, e, next) => next(e))
205
206  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207    if (e.props.hasSurvey) return next(e)
208    const state = await read($, pager)
209    const below = await next(e)
210    const line = bandLine($, e, state)
211    if (line === null) return below
212    const { Box } = $.ui.resolve(e)
213    return (
214      <Box flexDirection="column">
215        {line}
216        {below}
217      </Box>
218    )
219  })
220
221  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
222    const state = await read($, pager)
223    const shown = await read($, tab)
224    const picked = await read($, pick)
225    return paneOf($, e, state, shown, picked)
226  })
227}
228
229const PREVIEW_CHARS = 60
230
231function bandLine($: Engine, e: RenderInput<'AbovePrompt'>, state: PagerState): RenderElement | null {
232  if (state.name === null) return null
233  const { Box, Text, Button } = $.ui.resolve(e)
234  const last = state.entries.at(-1)
235  const flat = last ? flatOf(last.body) : ''
236  const preview = flat.length > PREVIEW_CHARS ? `${flat.slice(0, PREVIEW_CHARS - 1)}…` : flat
237  return (
238    <Box flexDirection="row">
239      {/* The name is the way into /pager: a click (or ctrl+x tab, Enter) opens it. */}
240      <Button
241        key="open-pager"
242        label={`✉ ${state.name}`}
243        plain
244        onPress={() => openPane($)}
245      />
246      <Text wrap="truncate-end">
247        {state.newCount > 0 ? <Text color="warning" bold>{`  ● ${state.newCount} new`}</Text> : ''}
248        {last ? (
249          <Text>
250            {'  '}
251            <Text color={last.direction === 'in' ? 'suggestion' : 'success'}>
252              {last.direction === 'in' ? '←' : '→'} {last.peer}
253            </Text>
254            <Text dimColor>
255              {state.lastAgo !== null ? ` ${state.lastAgo}` : ''}
256              {'  '}
257              {preview}
258            </Text>
259          </Text>
260        ) : (
261          ''
262        )}
263        {state.live > 0 ? <Text dimColor>{`  · ${state.live} live`}</Text> : ''}
264        {state.error !== null ? <Text dimColor> ?</Text> : ''}
265      </Text>
266    </Box>
267  )
268}
269
270const TABS: { tab: Tab; label: string; hotkey: string }[] = [
271  { tab: 'messages', label: 'messages', hotkey: '1' },
272  { tab: 'peers', label: 'peers', hotkey: '2' },
273]
274
275function paneOf(
276  $: Engine,
277  e: RenderInput<'Pane'>,
278  state: PagerState,
279  shown: Tab,
280  picked: number | null,
281): RenderElement {
282  const { Box, Text, Button } = $.ui.resolve(e)
283  const tabs = (
284    <Box flexDirection="row" columnGap={3}>
285      {TABS.map(one => (
286        <Button
287          key={`tab-${one.tab}`}
288          label={one.label}
289          hotkey={one.hotkey}
290          plain
291          dimColor={one.tab !== shown}
292          onPress={() => update($, tab, () => one.tab)}
293        />
294      ))}
295    </Box>
296  )
297  const status = state.error !== null ? <Text dimColor>⚠ pager: {state.error}</Text> : ''
298  if (state.name === null) {
299    return (
300      <Box flexDirection="column">
301        {tabs}
302        <Text dimColor>This session has no pager name yet.</Text>
303        {status}
304      </Box>
305    )
306  }
307  const body =
308    shown === 'messages'
309      ? state.entries.length === 0
310        ? <Text dimColor>No messages to or from {state.name} yet.</Text>
311        : messagesBody($, e, state, picked)
312      : peersBody($, e, state)
313  return (
314    <Box flexDirection="column">
315      {tabs}
316      {body}
317      {status}
318    </Box>
319  )
320}
321
322const arrowOf = (entry: Entry) => (entry.direction === 'in' ? '←' : '→')
323const toneOf = (entry: Entry) => (entry.direction === 'in' ? 'suggestion' : 'success')
324
325/**
326 * The messages tab: an index of recent messages over a reader that shows the
327 * picked one (the newest when none is picked) in full.
328 */
329function messagesBody($: Engine, e: RenderInput<'Pane'>, state: PagerState, picked: number | null): RenderElement {
330  const { Box, Text, Button } = $.ui.resolve(e)
331  const entries = state.entries
332  const found = picked === null ? -1 : entries.findIndex(entry => entry.id === picked)
333  const at = found < 0 ? entries.length - 1 : found
334  const entry = entries[at]!
335  // The index keeps the picked row in its window, the newest at the bottom.
336  const size = Math.min(entries.length, Math.max(3, Math.floor(e.props.scroll.bodyRows * 0.35)))
337  const first = Math.max(0, Math.min(entries.length - size, at - Math.floor(size / 2)))
338  const windowed = entries.slice(first, first + size)
339  const older = at > 0 ? entries[at - 1]!.id : null
340  // Stepping onto the newest returns to following new mail.
341  const newer = at < entries.length - 1 ? (at + 1 === entries.length - 1 ? null : entries[at + 1]!.id) : undefined
342  const age = agoOf(entry.at, state.now)
343  const header = `── #${entry.id} · ${arrowOf(entry)} ${entry.peer}${entry.isHuman ? ' (human)' : ''} · ${stampOf(entry.at)}${
344    age === null ? '' : age === 'now' ? ' · just now' : ` · ${age} ago`
345  } `
346  const isTerminal = e.surface === 'terminal'
347  const rule = isTerminal ? '─'.repeat(Math.max(2, e.props.bodyColumns - header.length)) : '──'
348  const left = entry.length - entry.body.length
349  return (
350    <Box flexDirection="column">
351      {windowed.map(one => {
352        const isPicked = one === entry
353        return (
354          <Box flexDirection="row">
355            <Text color="claude">{isPicked ? '▌' : ' '}</Text>
356            <Button
357              key={`msg-${one.id}`}
358              label={stampOf(one.at)}
359              plain
360              dimColor={!isPicked}
361              onPress={() => pickMessage($, one === entries.at(-1) ? null : one.id)}
362            />
363            <Box flexShrink={1}>
364              <Text wrap="truncate-end">
365                {' '}
366                <Text color={toneOf(one)}>
367                  {arrowOf(one)} {one.peer.padEnd(5)}
368                </Text>
369                {one.isHuman ? <Text color="claude"> (human)</Text> : ''}
370                <Text bold={isPicked}> {flatOf(one.body)}</Text>
371              </Text>
372            </Box>
373          </Box>
374        )
375      })}
376      <Text> </Text>
377      <Text color="subtle" wrap="truncate-end">
378        {header}
379        {rule}
380      </Text>
381      {entry.body.split('\n').map(line => (
382        <Text wrap="wrap">{line === '' ? ' ' : line}</Text>
383      ))}
384      {left > 0 ? (
385        <Text dimColor wrap="wrap">
386          … {left.toLocaleString('en-US')} more characters · full text: pager export (#{entry.id})
387        </Text>
388      ) : (
389        ''
390      )}
391      <Text> </Text>
392      <Box flexDirection="row" columnGap={2}>
393        <Button
394          key="older"
395          label="older"
396          hotkey="j"
397          plain
398          dimColor={older === null}
399          onPress={() => (older === null ? undefined : pickMessage($, older))}
400        />
401        <Button
402          key="newer"
403          label="newer"
404          hotkey="k"
405          plain
406          dimColor={newer === undefined}
407          onPress={() => (newer === undefined ? undefined : pickMessage($, newer))}
408        />
409        <Text dimColor wrap="truncate-end">
410          {isTerminal ? '· 1 2 tabs · ↑↓ scroll' : '· click a time to read it'}
411        </Text>
412      </Box>
413    </Box>
414  )
415}
416
417const COLUMNS = { name: 6, tool: 7, host: 8, last: 10 }
418
419const startCut = (text: string, room: number) => (text.length > room ? `…${text.slice(text.length - room + 1)}` : text)
420
421/** The peers tab: aligned columns, live hosts first, this session marked. */
422function peersBody($: Engine, e: RenderInput<'Pane'>, state: PagerState): RenderElement {
423  const { Box, Text } = $.ui.resolve(e)
424  if (state.peers.length === 0) return <Text dimColor>loading…</Text>
425  const cell = (width: number, text: string, extra: Record<string, boolean> = {}) => (
426    <Box width={width + 1} flexShrink={0}>
427      <Text wrap="truncate-end" {...extra}>
428        {text}
429      </Text>
430    </Box>
431  )
432  // The root takes what the fixed columns leave; cut from its start, it keeps
433  // the folder that tells two sessions apart.
434  const fixed = 2 + COLUMNS.name + COLUMNS.tool + COLUMNS.host + COLUMNS.last + 4
435  const room = Math.max(8, e.props.bodyColumns - fixed)
436  return (
437    <Box flexDirection="column">
438      <Box flexDirection="row">
439        <Text dimColor>{'  '}</Text>
440        {cell(COLUMNS.name, 'name', { dimColor: true })}
441        {cell(COLUMNS.tool, 'tool', { dimColor: true })}
442        {cell(COLUMNS.host, 'host', { dimColor: true })}
443        {cell(COLUMNS.last, 'last', { dimColor: true })}
444        <Text dimColor>root</Text>
445      </Box>
446      {state.peers.map(peer => {
447        const isLive = peer.host === 'live'
448        const isSelf = peer.name === state.name
449        const tone = { dimColor: !isLive, bold: isSelf }
450        return (
451          <Box flexDirection="row">
452            <Box width={2} flexShrink={0}>
453              <Text color={isLive ? 'success' : undefined} dimColor={!isLive}>
454                {isLive ? '●' : '○'}
455              </Text>
456            </Box>
457            {cell(COLUMNS.name, peer.name, tone)}
458            {cell(COLUMNS.tool, peer.tool, tone)}
459            {cell(COLUMNS.host, peer.host, tone)}
460            {cell(COLUMNS.last, peer.last, tone)}
461            <Text dimColor={!isLive} bold={isSelf}>
462              {startCut(`${peer.root}${isSelf ? ' (this)' : ''}`, room)}
463            </Text>
464          </Box>
465        )
466      })}
467    </Box>
468  )
469}
470
hooks/pager.ts 185 lines
1// Pure readers for pager-view: what the pager CLI prints, turned into this
2// session's name, its conversation and its peers. register.tsx does the I/O.
3
4import type { Entry, Peer } from '../types'
5
6export type { Entry, Peer }
7
8/** What one `$.process.run` answered, the fields these readers look at. */
9export type Run = {
10  exitCode: number
11  stdout: string
12  stderr: string
13  isStdoutTruncated?: boolean
14}
15
16export type Failure = { error: string }
17
18/** The run's failure, if it failed: a non-zero exit or output cut short. */
19export function failureOf(run: Run): string | undefined {
20  if (run.isStdoutTruncated) return 'output truncated'
21  if (run.exitCode !== 0) {
22    return run.stderr.trim().split('\n').at(-1) || `exit ${run.exitCode}`
23  }
24  return undefined
25}
26
27/** A run that never started because `pager` is not installed. */
28export function isMissing(run: Run): boolean {
29  return run.exitCode === -1 && /ENOENT|not found|No such file/i.test(run.stderr)
30}
31
32/** `pager whoami --session <id>`: the `name:` line, or null when it has none. */
33export function nameOf(run: Run): string | null | Failure {
34  const failure = failureOf(run)
35  if (failure) return { error: failure }
36  const line = run.stdout.split('\n').find(one => /^name:/.test(one))
37  const name = line?.replace(/^name:\s*/, '').trim()
38  return name ? name : null
39}
40
41/**
42 * `pager ls --session <id>`: the ids of the messages addressed to this session,
43 * under any of its names. Only the ID column (`#<n>`) is read.
44 */
45export function inboundOf(run: Run): Set<number> | Failure {
46  const failure = failureOf(run)
47  if (failure) return { error: failure }
48  const lines = run.stdout.split('\n').filter(line => line.trim() !== '')
49  if (lines.length === 0 || (lines.length === 1 && lines[0]!.trim() === 'nothing here')) {
50    return new Set()
51  }
52  if (!/^ID\s+INBOX\b/.test(lines[0]!)) return { error: 'unexpected ls output' }
53  const ids = new Set<number>()
54  for (const line of lines.slice(1)) {
55    const m = /^#(\d+)\s/.exec(line)
56    if (m) ids.add(Number(m[1]))
57  }
58  return ids
59}
60
61type Exported = {
62  v?: number
63  id?: number
64  created_at?: string
65  alias?: string
66  sender_session?: string | null
67  sender_label?: string | null
68  origin?: string
69  body?: string
70}
71
72/** `pager export` JSONL, the rows of version 1. */
73export function exportedOf(run: Run): Exported[] | Failure {
74  const failure = failureOf(run)
75  if (failure) return { error: failure }
76  const rows: Exported[] = []
77  for (const line of run.stdout.split('\n')) {
78    if (line.trim() === '') continue
79    try {
80      const row = JSON.parse(line) as Exported
81      if (row.v === 1 && typeof row.id === 'number') rows.push(row)
82    } catch {
83      return { error: 'unreadable export line' }
84    }
85  }
86  return rows
87}
88
89/** The most of a body kept and drawn; the rest is named, with the way to read it. */
90export const BODY_CAP = 6_000
91
92/** A body on one line, as the band and the message index show it. */
93export function flatOf(body: string): string {
94  return body.trim().replace(/\s*\n\s*/g, ' ⏎ ')
95}
96
97/** This session's conversation: what it received (by id) and what it sent. */
98export function conversationOf(
99  rows: readonly Exported[],
100  inbound: ReadonlySet<number>,
101  sessionId: string,
102): Entry[] {
103  const entries: Entry[] = []
104  for (const row of rows) {
105    const id = row.id!
106    const isOut = row.sender_session === sessionId
107    const isIn = inbound.has(id)
108    if (!isOut && !isIn) continue
109    entries.push({
110      id,
111      at: row.created_at ?? '',
112      direction: isIn ? 'in' : 'out',
113      peer: isIn ? row.sender_label || 'unknown' : row.alias || 'unknown',
114      body: (row.body ?? '').slice(0, BODY_CAP),
115      length: (row.body ?? '').length,
116      isHuman: row.origin === 'human',
117    })
118  }
119  return entries.sort((a, b) => a.at.localeCompare(b.at) || a.id - b.id)
120}
121
122/** `pager who`: NAME TOOL ROOT HOST LAST, HOST being live, gone or unknown. */
123export function peersOf(run: Run): Peer[] | Failure {
124  const failure = failureOf(run)
125  if (failure) return { error: failure }
126  const lines = run.stdout.split('\n').filter(line => line.trim() !== '')
127  if (lines.length === 0) return []
128  if (!/^NAME\s+TOOL\b/.test(lines[0]!)) return { error: 'unexpected who output' }
129  const peers: Peer[] = []
130  for (const line of lines.slice(1)) {
131    const m = /^(\S+)\s+(\S+)\s+(.+?)\s+(live|gone|unknown)\s+(.+)$/.exec(line.trim())
132    if (m) peers.push({ name: m[1]!, tool: m[2]!, root: m[3]!, host: m[4]!, last: m[5]!.trim() })
133  }
134  return peers
135}
136
137/**
138 * The peers as the pane lists them: live hosts first, then the order pager gave
139 * (latest activity first), the home folder shown as `~`.
140 */
141export function rosterOf(peers: readonly Peer[], home: string): Peer[] {
142  const tilde = (root: string) =>
143    home !== '' && (root === home || root.startsWith(`${home}/`)) ? `~${root.slice(home.length)}` : root
144  const live = peers.filter(peer => peer.host === 'live')
145  const rest = peers.filter(peer => peer.host !== 'live')
146  return [...live, ...rest].map(peer => ({ ...peer, root: tilde(peer.root) }))
147}
148
149/** The highest inbound id, the baseline a look at the pane moves to. */
150export function newestOf(inbound: ReadonlySet<number>): number {
151  let max = 0
152  for (const id of inbound) if (id > max) max = id
153  return max
154}
155
156export function newCountOf(inbound: ReadonlySet<number>, baseline: number): number {
157  let count = 0
158  for (const id of inbound) if (id > baseline) count += 1
159  return count
160}
161
162/** `2026-10-07T05:48:19.655Z` → `10-07 14:48` in the host's time zone. */
163export function stampOf(iso: string): string {
164  const date = new Date(iso)
165  if (Number.isNaN(date.getTime())) return '--:--'
166  const two = (n: number) => String(n).padStart(2, '0')
167  return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`
168}
169
170/** Peers other than `self` whose host pager judges live. */
171export function liveOf(peers: readonly Peer[], self: string): number {
172  return peers.filter(peer => peer.host === 'live' && peer.name !== self).length
173}
174
175/** How long before `nowMs` an ISO time was: `now`, `12m`, `3h`, `2d`. */
176export function agoOf(iso: string, nowMs: number): string | null {
177  const then = Date.parse(iso)
178  if (Number.isNaN(then)) return null
179  const minutes = Math.floor(Math.max(0, nowMs - then) / 60_000)
180  if (minutes < 1) return 'now'
181  if (minutes < 60) return `${minutes}m`
182  if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`
183  return `${Math.floor(minutes / (24 * 60))}d`
184}
185
types/index.d.ts 48 lines
1/** One message of this session's pager conversation, oldest first. */
2export type Entry = {
3  id: number
4  at: string
5  direction: 'in' | 'out'
6  peer: string
7  /** The body as sent, line breaks kept, cut at BODY_CAP characters. */
8  body: string
9  /** The body's full length, so a cut one can say how much is left. */
10  length: number
11  isHuman: boolean
12}
13
14export type Peer = { name: string; tool: string; root: string; host: string; last: string }
15
16/**
17 * What the band and the /pager pane draw, for `sessionId`. `name` null: pager
18 * has no name for this session (or no pager at all), so nothing is drawn.
19 * `error` says the latest refresh failed; the rest is the last good read.
20 */
21export type PagerState = {
22  sessionId: string
23  name: string | null
24  entries: Entry[]
25  peers: Peer[]
26  newCount: number
27  /** Peers other than this session whose host is live. */
28  live: number
29  /** How long ago the last entry was, as of the refresh that read it (`3m`). */
30  lastAgo: string | null
31  /** When that refresh ran (ms since the epoch): the ages the pane draws count from it. */
32  now: number
33  error: string | null
34}
35
36export type Tab = 'messages' | 'peers'
37
38declare module 'claude-code' {
39  interface PluginState {
40    'pager-view': {
41      pager: PagerState
42      tab: Tab
43      /** The message the reader shows; null follows the newest. */
44      pick: number | null
45    }
46  }
47}
48