SLOPSHOPPER

up-to-date

One line above the prompt showing what needs you, from sources you opt into

newpanebandprocesstimer
v0.4.2MITupdated 2026-10-26TomDiem/claude-code-mods/mods/up-to-date
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · up-to-date
│ ┃ up-to-date ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing needs you. │ ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · up-to-date
Nothing needs you.
README

up-to-date

One line above the prompt showing what needs you, from sources you opt in to.

🔀 app#12 review requested +2

One segment per source, led by its icon and showing its most urgent item, with +N for the rest. Segments are colored by urgency with your theme's warning and error colors: warning for a review request, error for failing CI.

Details

Open a pane with everything behind the line, grouped by reason:

┌──────────────────────────────────────────────┐
│ 🔀 Review requested                           │
│   claude-code-mods#4 One segment per source   │
│ 🔀 CI failing                                 │
│   claude-code-mods#5 Calendar adapter         │
└──────────────────────────────────────────────┘
  • Keyboard (any layout): ctrl+x tab focuses the line, then Enter opens the pane.
  • Mouse: click the line. Claude Code only reports clicks in the fullscreen layout ("tui": "fullscreen" in settings.json), so in the default layout use the keyboard.
  • Esc closes the pane.

Each repo#number is a link to the PR, followed by its title (dim) and (draft) for a draft. The links are OSC 8 hyperlinks: open one with your terminal's link gesture (ctrl+click in Windows Terminal). A source that is failing since a good fetch says last updated N min ago; one that never worked says why; with nothing to list the pane says Nothing needs you.. The pane reads what is already fetched, so opening it never calls gh.

What it shows

SourceIconMost urgent item, in order
GitHub🔀review requested: a review is requested from you (warning). CI failing: your open PR has failing CI, drafts last (error). changes requested: your open PR has changes requested
Calendar📅in 12m: Standup: a meeting starts within 15 minutes (warning). now: Standup: a meeting is in progress (suggestion). Otherwise the next one by clock time, 14:30 Standup
  • A PR that matches several reasons counts once, under the most urgent.
  • On a narrow terminal the least urgent source shortens fully first (🔀 #12 review +2, then 🔀 3 PRs), then the next one up. Only when every source is at its shortest does the least urgent get dropped, shown as · +N for the N dropped.
  • ~ before a segment (drawn dim) means the source is failing and the segment is the last good result. A source that never worked shows 🔀 github: <reason> dim, or 🔀 ! when there is no room (not-connected, tool-error, parse-error or timeout).
  • Failing sources back off instead of retrying every minute: one ttl after the first failure, then doubling, up to 30 minutes.
  • Calendar draws nothing on the line while it has never worked, since its first fetch can run before the connector is up; the details pane still says why.
  • Calendar shows the next 7 days of your calendar. It leaves out cancelled events, ones you declined, all-day events, free (not busy) events, ones that already ended, and ones longer than calendarMaxBlockHours (3 by default), which are usually focus blocks. The countdown updates every minute without calling the connector again; a meeting further out than today shows its weekday (Wed 09:00 Planning).
  • changes relies on GitHub's review decision, which is empty on repos without required reviews, so those PRs never show it.

Enable sources

Every source is off by default, so a fresh install fetches and draws nothing. Turn one on with /plugin configure up-to-date@tomdiem-mods:

SettingSource
githubGitHub
calendarCalendar
calendarMaxBlockHoursLongest event Calendar counts as a meeting (hours, default 3)

Changes to a source take effect in the next session: restart Claude Code after enabling or disabling one.

Linear and Slack are coming.

Requirements

  • Calendar: the Google Calendar connector connected in claude.ai (/mcp lists it as claude.ai Google Calendar).
  • GitHub: the GitHub CLI (gh) installed and signed in (gh auth login).

Access

Calendar calls the list_events tool of your claude.ai Google Calendar connector (read-only) every 5 minutes, or every 2 minutes while a meeting is in progress or starts within 30 minutes, so a moved event is picked up quickly. Event titles are cached locally like PR titles and appear in the line and the pane.

GitHub runs gh api graphql (read-only search of your PRs) every few minutes, only while the source is enabled and only in interactive sessions (not -p or the SDK). Results, including PR titles from private repos, are cached locally in the plugin's store under the Claude config directory so sessions share them; the line shows only repo#number; titles appear only in the details pane. No model calls, no cost.

Install

/plugin install up-to-date --marketplace TomDiem/claude-code-mods
Source 7 files
hooks/register.tsx 196 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { due, fit, settle, status, ttlOf } from './core'
5import type { Entry, Io, Segment, Source } from './core'
6import { calendar } from './calendar'
7import { details } from './details'
8import type { Section } from './details'
9import { github } from './github'
10import { line } from './line'
11
12const entries = atom({ plugin: 'up-to-date', key: 'entries' } as const, {})
13const now = atom({ plugin: 'up-to-date', key: 'now' } as const, 0)
14
15const MINUTE = 60_000
16const PANE = 'up-to-date'
17// source order breaks ties in rank and orders the pane: a meeting does not wait, a review can
18const sources: Source<any>[] = [calendar, github]
19// sorts after the rank of any real item: a source that never worked comes last
20const AFTER_ALL = 1_000
21
22const storeKey = (source: Source) => `up-to-date:v1:${source.id}`
23
24// jitter spreads the sessions that share one store so they do not all fetch together
25const jitter = () => 0.9 + Math.random() * 0.2
26
27const ioOf = ($: EngineInterface): Io => ({
28  mcp: (server, tool, args) => $.mcp.call(server, tool, args as Record<string, unknown>),
29  run: (argv, options) => $.process.run(argv, options),
30})
31
32// the store is shared and outlives versions: trust only what has the shape of an entry
33const isEntry = (value: unknown): value is Entry<unknown> => {
34  const entry = value as Partial<Entry<unknown>> | undefined
35
36  return Array.isArray(entry?.items) && [entry.at, entry.fails, entry.nextTryAt].every(n => typeof n === 'number')
37}
38
39// a store that misbehaves only costs the sharing between sessions: this session still polls
40const fromStore = async ($: EngineInterface, source: Source) => {
41  try {
42    const value = await $.store.get(storeKey(source))
43
44    return isEntry(value) ? value : undefined
45  } catch {
46    return undefined
47  }
48}
49
50const toStore = async ($: EngineInterface, source: Source, entry: Entry<unknown>) => {
51  try {
52    await $.store.set(storeKey(source), entry)
53  } catch {}
54}
55
56const refresh = async ($: EngineInterface, source: Source, time: number) => {
57  let entry = (await read($, entries))[source.id]
58  const shared = await fromStore($, source)
59
60  // another session may have fetched since: take its result instead of fetching again
61  if (shared && shared.nextTryAt > (entry?.nextTryAt ?? 0)) {
62    entry = shared
63  }
64
65  if (due(entry, time)) {
66    const result = await source.fetch(ioOf($), time)
67
68    entry = settle(entry, result, time, ttlOf(source, result, time), jitter())
69    await toStore($, source, entry)
70  }
71
72  await update($, entries, all => ({ ...all, [source.id]: entry as Entry<unknown> }))
73}
74
75// a source that never worked says why; one that did keeps its last items, marked stale
76const segmentOf = (
77  source: Source,
78  entry: Entry<unknown> | undefined,
79  time: number,
80  options: Record<string, unknown>,
81): Segment | undefined => {
82  const state = status(entry, true)
83
84  if (state === 'failed') {
85    return source.quiet ? undefined : { rank: AFTER_ALL, tone: 'muted', levels: [`${source.icon} ${source.id}: ${entry?.error?.reason}`, `${source.icon} !`] }
86  }
87
88  const segment = source.segment(entry?.items ?? [], time, options)
89
90  return segment && state === 'stale' ? { ...segment, stale: true, levels: segment.levels.map(level => `~${level}`) } : segment
91}
92
93const ago = (ms: number) => {
94  const minutes = Math.max(1, Math.floor(ms / MINUTE))
95
96  return minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ago`
97}
98
99// what the pane lists for a source: its groups, dim-noted when stale; a source that never
100// worked says why instead, a pending one that it is checking; nothing when it is fine and empty
101const sectionOf = (
102  source: Source,
103  entry: Entry<unknown> | undefined,
104  time: number,
105  options: Record<string, unknown>,
106): Section | undefined => {
107  const { icon, label } = source
108  const state = status(entry, true)
109
110  if (state === 'pending') {
111    return { icon, label, groups: [], note: 'checking…' }
112  }
113
114  if (state === 'failed') {
115    return { icon, label, groups: [], note: entry?.error?.reason }
116  }
117
118  const groups = source.details?.(entry?.items ?? [], time, options) ?? []
119
120  if (state === 'stale') {
121    return { icon, label, groups, note: `last updated ${ago(time - (entry?.at ?? 0))}` }
122  }
123
124  return groups.length === 0 ? undefined : { icon, label, groups }
125}
126
127export const register: Register = (on, options) => {
128  const enabled = sources.filter(source => source.enabled(options))
129  let timer: ReturnType<EngineInterface['clock']['every']> | undefined
130  const busy = new Set<string>()
131
132  on('session.start', async ($, e, next) => {
133    // never rejects: a failed tick must not break the session or the interval
134    const tick = async () => {
135      try {
136        const time = await $.clock.now()
137        await update($, now, () => time)
138
139        await Promise.all(
140          enabled
141            .filter(source => !busy.has(source.id))
142            .map(async source => {
143              busy.add(source.id)
144
145              try {
146                await refresh($, source, time)
147              } finally {
148                busy.delete(source.id)
149              }
150            }),
151        )
152      } catch {}
153    }
154
155    // only a person at the prompt sees the line: -p runs and the SDK do not poll
156    if (enabled.length > 0 && e.isInteractive) {
157      timer?.cancel()
158      timer = $.clock.every(MINUTE, tick)
159      void tick()
160    }
161
162    return next(e)
163  })
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    const rest = await next(e)
167    const all = await read($, entries)
168    const time = await read($, now)
169    const segments = enabled.flatMap(source => segmentOf(source, all[source.id], time, options) ?? [])
170    const { shown, hidden } = fit(segments, e.props.bodyColumns)
171
172    if (e.props.hasSurvey || shown.length === 0) {
173      return rest
174    }
175
176    const { Box } = $.ui.resolve(e)
177
178    const open = () => void $.ui.open({ id: PANE, title: 'Up to date', focus: true, closeOnEscape: true }).catch(() => {})
179
180    return (
181      <Box flexDirection="column">
182        {rest}
183        {line($.ui.resolve(e), shown, hidden, open)}
184      </Box>
185    )
186  })
187
188  // drawn from the entries alone: opening the pane never fetches
189  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
190    const all = await read($, entries)
191    const time = await read($, now)
192
193    return details($.ui.resolve(e), enabled.flatMap(source => sectionOf(source, all[source.id], time, options) ?? []))
194  })
195}
196
hooks/core.ts 155 lines
1// Pure logic: no `$`, no clock, no randomness (time and jitter are passed in)
2
3export type Reason = 'not-connected' | 'tool-error' | 'parse-error' | 'timeout'
4
5export type Fetched<I> = { ok: true; items: I[] } | { ok: false; reason: Reason }
6
7// what a source may reach for, injected so adapters never touch `$`
8export type Io = {
9  mcp(server: string, tool: string, args: object): Promise<unknown>
10  run(argv: string[], options?: { timeoutMs?: number }): Promise<{ exitCode: number; stdout: string; stderr: string }>
11}
12
13// what one source shows: its levels run from the fullest text to the shortest, rank 0 is the
14// most urgent item it holds; the tone only says how loud the shell may draw it
15export type Segment = { rank: number; levels: string[]; tone?: 'urgent' | 'alert' | 'active' | 'muted'; stale?: boolean }
16
17// what the details pane lists for a source: one heading per group, a linked row per item
18export type Row = { label: string; title: string; url: string; note?: string }
19export type Group = { heading: string; rows: Row[] }
20
21type Options = Record<string, unknown>
22
23// items are plain JSON so they can be cached; text is built at render time from `now`
24export type Source<I = unknown> = {
25  id: string
26  label: string
27  // leads the source's segment, also when it only says why it is failing
28  icon: string
29  ttlMs: number
30  // the line stays silent while it has never worked: its first fetch can run before the
31  // host has connected, and the quiet retry is enough; the pane still says why
32  quiet?: boolean
33  // a shorter (or longer) wait worked out from what was just fetched; absent: ttlMs
34  ttl?(items: I[], now: number): number
35  enabled(options: Options): boolean
36  fetch(io: Io, now: number): Promise<Fetched<I>>
37  // undefined: nothing to show
38  segment(items: I[], now: number, options: Options): Segment | undefined
39  // the pane's list, most urgent group first; a source without it has no pane section
40  details?(items: I[], now: number, options: Options): Group[]
41}
42
43export type Entry<I> = {
44  items: I[]
45  at: number
46  error?: { reason: Reason; at: number }
47  fails: number
48  nextTryAt: number
49}
50
51const MAX_BACKOFF = 30 * 60_000
52export const SEPARATOR = ' · '
53
54// icons must be emoji-presentation characters from the supplementary plane (🔀 📅): the table
55// does not cover the BMP ones (⏰ ✅) that terminals may draw either way
56// emoji and East Asian wide characters take two columns; the joiner and the emoji selector none
57const isWide = (code: number) =>
58  (code >= 0x1100 && code <= 0x115f) ||
59  (code >= 0x2e80 && code <= 0xa4cf) ||
60  (code >= 0xac00 && code <= 0xd7a3) ||
61  (code >= 0xf900 && code <= 0xfaff) ||
62  (code >= 0xfe30 && code <= 0xfe6f) ||
63  (code >= 0xff00 && code <= 0xff60) ||
64  (code >= 0xffe0 && code <= 0xffe6) ||
65  (code >= 0x1f300 && code <= 0x1f64f) ||
66  (code >= 0x1f680 && code <= 0x1f6ff) ||
67  (code >= 0x1f900 && code <= 0x1faff) ||
68  (code >= 0x20000 && code <= 0x3fffd)
69
70export const cells = (text: string) =>
71  [...text].reduce((sum, char) => {
72    const code = char.codePointAt(0) as number
73
74    return sum + (code === 0xfe0f || code === 0x200d ? 0 : isWide(code) ? 2 : 1)
75  }, 0)
76
77// most urgent first (ties keep source order). Too wide, the least urgent segment shortens
78// level by level until it is at its shortest, then the next more urgent one, and so on; if even
79// that is too wide the least urgent segment is dropped, the rest start over at their fullest
80// level, and the dropped ones are counted in ` · +N`. If not even the most urgent one fits it
81// is still returned at its shortest, for the shell to truncate.
82export const fit = (segments: Segment[], columns: number) => {
83  const ordered = [...segments].sort((a, b) => a.rank - b.rank)
84
85  for (let keep = ordered.length; keep > 0; keep--) {
86    const kept = ordered.slice(0, keep)
87    const levels = kept.map(() => 0)
88    const suffix = keep < ordered.length ? `${SEPARATOR}+${ordered.length - keep}` : ''
89    const text = (i: number) => (kept[i] as Segment).levels[levels[i] as number] ?? ''
90    const wide = () => cells(kept.map((_, i) => text(i)).join(SEPARATOR) + suffix)
91
92    for (let i = keep - 1; i >= 0 && wide() > columns; i--) {
93      while (wide() > columns && (levels[i] as number) < (kept[i] as Segment).levels.length - 1) {
94        levels[i] = (levels[i] as number) + 1
95      }
96    }
97
98    if (wide() <= columns) {
99      return { shown: kept.map((segment, i) => ({ segment, text: text(i) })), hidden: ordered.length - keep }
100    }
101  }
102
103  const first = ordered[0]
104
105  return { shown: first ? [{ segment: first, text: first.levels[first.levels.length - 1] ?? '' }] : [], hidden: 0 }
106}
107
108// a failure keeps the last good items (stale) and backs off: ttl * 2^(fails - 1), capped,
109// but never retries sooner than a healthy source would
110export const settle = <I>(
111  prev: Entry<I> | undefined,
112  result: Fetched<I>,
113  now: number,
114  ttlMs: number,
115  jitter = 1,
116): Entry<I> => {
117  if (result.ok) {
118    return { items: result.items, at: now, fails: 0, nextTryAt: now + ttlMs * jitter }
119  }
120
121  const fails = (prev?.fails ?? 0) + 1
122
123  return {
124    items: prev?.items ?? [],
125    at: prev?.at ?? 0,
126    error: { reason: result.reason, at: now },
127    fails,
128    nextTryAt: now + Math.min(ttlMs * 2 ** (fails - 1), Math.max(ttlMs, MAX_BACKOFF)),
129  }
130}
131
132// how long a good fetch stays fresh; a failure backs off from the fixed ttlMs
133export const ttlOf = <I>(source: Source<I>, result: Fetched<I>, now: number) =>
134  (result.ok && source.ttl?.(result.items, now)) || source.ttlMs
135
136export const due = (entry: Entry<unknown> | undefined, now: number) => !entry || now >= entry.nextTryAt
137
138// pending: enabled, not fetched yet; stale: failing since a good fetch (even an empty one);
139// failed: never fetched successfully. The shell formats times, so this stays pure
140export const status = (entry: Entry<unknown> | undefined, enabled: boolean) => {
141  if (!enabled) {
142    return 'disabled'
143  }
144
145  if (!entry) {
146    return 'pending'
147  }
148
149  if (!entry.error) {
150    return 'ok'
151  }
152
153  return entry.at > 0 ? 'stale' : 'failed'
154}
155
hooks/calendar.ts 161 lines
1import type { Fetched, Group, Io, Reason, Segment, Source } from './core'
2
3// plain JSON, so it can be cached; the text is built from it at render time
4export type Meeting = { id: string; title: string; start: number; end: number; url: string }
5
6const SERVER = 'claude.ai Google Calendar'
7const ICON = '📅'
8const MINUTE = 60_000
9const HOUR = 60 * MINUTE
10const SOON = 15 * MINUTE
11// a meeting that is on or near is where a moved event would hurt, so look more often
12const NEAR_TTL = 2 * MINUTE
13const MAX_BLOCK_HOURS = 3
14const CALENDAR_TTL = 5 * MINUTE
15// the countdown is rounded up, so it reads "in 59m" at most and the clock time takes over after
16const COUNTDOWN = 59 * MINUTE
17const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
18
19type Event = {
20  id?: string
21  summary?: string
22  status?: string
23  eventType?: string
24  transparency?: string
25  availability?: string
26  start?: { dateTime?: string }
27  end?: { dateTime?: string }
28  attendees?: { self?: boolean; responseStatus?: string }[]
29  htmlLink?: string
30}
31
32// all-day events have no dateTime; free time, what I turned down and time I blocked for myself
33// or marked as away are not meetings
34const NOT_MEETINGS = ['OUT_OF_OFFICE', 'FOCUS_TIME', 'WORKING_LOCATION']
35
36const meeting = (event: Event): Meeting | undefined => {
37  const start = Date.parse(event.start?.dateTime ?? '')
38  const end = Date.parse(event.end?.dateTime ?? '')
39  const declined = event.attendees?.some(who => who.self && who.responseStatus === 'declined')
40  const free = event.transparency === 'transparent' || event.availability === 'AVAILABILITY_FREE'
41
42  if (event.status === 'cancelled' || NOT_MEETINGS.includes(event.eventType ?? '') || declined || free || Number.isNaN(start) || Number.isNaN(end)) {
43    return undefined
44  }
45
46  return { id: event.id ?? '', title: event.summary || '(no title)', start, end, url: event.htmlLink ?? '' }
47}
48
49const NOT_CONNECTED = /not connected|unauthori|not found/i
50
51// `mcp` rejects when the server is missing or signed out
52const failure = (error: unknown): Reason => (NOT_CONNECTED.test(String((error as Error)?.message)) ? 'not-connected' : 'tool-error')
53
54// with no events in the window the server leaves `events` out
55const fetchMeetings = async (io: Io): Promise<Fetched<Meeting>> => {
56  let result: { content?: { text?: unknown }[]; isError?: boolean }
57
58  try {
59    result = (await io.mcp(SERVER, 'list_events', { pageSize: 100, orderBy: 'startTime' })) as typeof result
60  } catch (error) {
61    return { ok: false, reason: failure(error) }
62  }
63
64  if (result?.isError) {
65    return { ok: false, reason: failure(new Error(String(result.content?.[0]?.text))) }
66  }
67
68  try {
69    const text = result?.content?.[0]?.text
70    const data = JSON.parse(String(text))
71    const events = data?.events ?? []
72
73    return data && typeof data === 'object' && Array.isArray(events)
74      ? { ok: true, items: events.flatMap((event: Event) => meeting(event ?? {}) ?? []) }
75      : { ok: false, reason: 'parse-error' }
76  } catch {
77    return { ok: false, reason: 'parse-error' }
78  }
79}
80
81// what is still on and short enough to be a meeting, by start; the limit is read at render time
82// so raising it needs no refetch
83const upcoming = (items: Meeting[], now: number, options: Record<string, unknown>) => {
84  const hours = typeof options.calendarMaxBlockHours === 'number' ? options.calendarMaxBlockHours : MAX_BLOCK_HOURS
85
86  return items.filter(item => item.end > now && item.end - item.start <= hours * HOUR).sort((a, b) => a.start - b.start)
87}
88
89const clock = (time: number) => {
90  const date = new Date(time)
91
92  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
93}
94
95// "now", "in 12m", then the clock time, with the weekday when it is not today
96const when = ({ start }: Meeting, now: number) => {
97  if (start <= now) {
98    return 'now'
99  }
100
101  if (start - now <= COUNTDOWN) {
102    return `in ${Math.ceil((start - now) / MINUTE)}m`
103  }
104
105  const today = new Date(now).toDateString() === new Date(start).toDateString()
106
107  return today ? clock(start) : `${DAYS[new Date(start).getDay()]} ${clock(start)}`
108}
109
110// about to start first, then in progress, then by how soon it starts
111const rank = ({ start }: Meeting, now: number) =>
112  start <= now ? 1 : start - now <= SOON ? 0 : start - now <= 2 * HOUR ? 2 : 3
113
114// the most pressing meeting speaks for the source; the others are counted
115const segment = (items: Meeting[], now: number, options: Record<string, unknown>): Segment | undefined => {
116  const meetings = upcoming(items, now, options)
117  const [top] = [...meetings].sort((a, b) => rank(a, now) - rank(b, now))
118
119  if (!top) {
120    return undefined
121  }
122
123  const label = when(top, now)
124  const more = meetings.length > 1 ? ` +${meetings.length - 1}` : ''
125  const live = top.start <= now
126  // relative labels lead with a colon, clock times do not
127  const colon = top.start - now <= COUNTDOWN ? ':' : ''
128
129  return {
130    rank: rank(top, now),
131    ...(live ? { tone: 'active' as const } : rank(top, now) === 0 && { tone: 'urgent' as const }),
132    levels: [
133      `${ICON} ${label}${colon} ${top.title}${more}`,
134      `${ICON} ${label}${more}`,
135      `${ICON} ${meetings.length} meeting${meetings.length > 1 ? 's' : ''}`,
136    ],
137  }
138}
139
140const ttl = (items: Meeting[], now: number) =>
141  upcoming(items, now, {}).some(item => item.start - now <= 30 * MINUTE) ? NEAR_TTL : CALENDAR_TTL
142
143const details = (items: Meeting[], now: number, options: Record<string, unknown>): Group[] => {
144  const rows = upcoming(items, now, options).map(item => ({ label: when(item, now), title: item.title, url: item.url }))
145
146  return rows.length > 0 ? [{ heading: 'Upcoming meetings', rows }] : []
147}
148
149export const calendar: Source<Meeting> = {
150  id: 'calendar',
151  label: 'Calendar',
152  icon: ICON,
153  ttlMs: CALENDAR_TTL,
154  ttl,
155  quiet: true,
156  enabled: options => options.calendar === true,
157  fetch: fetchMeetings,
158  segment,
159  details,
160}
161
hooks/details.tsx 38 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Group, Row } from './core'
4
5type Ui = ReturnType<EngineInterface['ui']['resolve']>
6
7// one enabled source: its groups, and a dim note when it is stale or never worked
8export type Section = { icon: string; label: string; groups: Group[]; note?: string }
9
10// the title, then the note in parentheses; empty when a PR has neither
11const dim = ({ title, note }: Row) => [title, note && `(${note})`].filter(Boolean).join(' ')
12
13// every group of every source with something to say, a heading led by the icon and a linked row
14// per item; the shell fills the sections from the entries, so nothing here fetches
15export const details = ({ Box, Link, Text }: Ui, sections: Section[]) => (
16  <Box flexDirection="column">
17    {sections.length === 0 ? (
18      <Text dimColor>Nothing needs you.</Text>
19    ) : (
20      sections.flatMap(({ icon, label, groups, note }) => [
21        ...(note ? [<Text key={`${label}:note`} dimColor>{groups.length > 0 ? `${label}: ${note}` : `${icon} ${label}: ${note}`}</Text>] : []),
22        ...groups.map(({ heading, rows }) => (
23          <Box key={`${label}:${heading}`} flexDirection="column">
24            <Text>{`${icon} ${heading}`}</Text>
25            {rows.map(row => (
26              <Text key={row.url} wrap="truncate">
27                {'  '}
28                <Link href={row.url}>{row.label}</Link>
29                {dim(row) && <Text dimColor>{` ${dim(row)}`}</Text>}
30              </Text>
31            ))}
32          </Box>
33        )),
34      ])
35    )}
36  </Box>
37)
38
hooks/github.ts 161 lines
1import type { Fetched, Group, Io, Reason, Segment, Source } from './core'
2
3type Why = 'review' | 'ci' | 'changes'
4
5// plain JSON, so it can be cached; the text is built from it at render time
6export type Pr = { repo: string; number: number; title: string; url: string; reason: Why; draft: boolean }
7
8const TIMEOUT_MS = 15_000
9const ICON = '🔀'
10const ORDER: Why[] = ['review', 'ci', 'changes']
11const RANK: Record<Why, number> = { review: 0, ci: 1, changes: 2 }
12const TONE: Record<Why, Segment['tone']> = { review: 'urgent', ci: 'alert', changes: undefined }
13// the full label, then the short one for a narrow terminal
14const LABELS: Record<Why, [string, string]> = {
15  review: ['review requested', 'review'],
16  ci: ['CI failing', 'CI'],
17  changes: ['changes requested', 'changes'],
18}
19const HEADINGS: Record<Why, string> = { review: 'Review requested', ci: 'CI failing', changes: 'Changes requested' }
20
21// one round trip: reviews asked of me, and my open PRs with their CI and review state
22const QUERY = `query {
23  review: search(query: "is:pr is:open archived:false review-requested:@me", type: ISSUE, first: 20) {
24    nodes { ...pull }
25  }
26  mine: search(query: "is:pr is:open archived:false author:@me", type: ISSUE, first: 50) {
27    nodes { ...pull }
28  }
29}
30fragment pull on PullRequest {
31  number title url isDraft reviewDecision
32  repository { name }
33  commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
34}`
35
36type Node = {
37  number?: number
38  title?: string
39  url?: string
40  isDraft?: boolean
41  reviewDecision?: string | null
42  repository?: { name?: string }
43  commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: string } | null } }[] }
44}
45
46const pr = (node: Node, reason: Why): Pr => ({
47  repo: node.repository?.name ?? '',
48  number: node.number ?? 0,
49  title: node.title ?? '',
50  url: node.url ?? '',
51  reason,
52  draft: node.isDraft === true,
53})
54
55const isFailing = (node: Node) => {
56  const state = node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state
57  return state === 'FAILURE' || state === 'ERROR'
58}
59
60// search also returns non-PR nodes as {}; a missing list means the shape drifted
61const nodesOf = (list: unknown): Node[] | undefined => {
62  const nodes = (list as { nodes?: unknown } | undefined)?.nodes
63  return Array.isArray(nodes) ? nodes.filter((node: Node) => typeof node?.number === 'number') : undefined
64}
65
66// most urgent reason wins: review, then failing CI (drafts last), then changes requested
67const pick = (review: Node[], mine: Node[]): Pr[] => {
68  const failing = mine.filter(isFailing)
69  const all = [
70    ...review.map(node => pr(node, 'review')),
71    ...failing.filter(node => !node.isDraft).map(node => pr(node, 'ci')),
72    ...failing.filter(node => node.isDraft).map(node => pr(node, 'ci')),
73    ...mine.filter(node => !isFailing(node) && node.reviewDecision === 'CHANGES_REQUESTED').map(node => pr(node, 'changes')),
74  ]
75  const seen = new Set<string>()
76
77  return all.filter(item => !seen.has(item.url) && !!seen.add(item.url))
78}
79
80const SIGNED_OUT = /gh auth login|not logged in|bad credentials|authentication|HTTP 401/i
81const NOT_STARTED = /ENOENT|cannot start/i
82const STILL_RUNNING = /still running/i
83
84// `run` rejects when gh cannot start (not installed) or is still running at its time
85const failure = (error: unknown): Reason => {
86  const message = String((error as Error)?.message)
87
88  if (NOT_STARTED.test(message)) {
89    return 'not-connected'
90  }
91
92  return STILL_RUNNING.test(message) ? 'timeout' : 'tool-error'
93}
94
95const fetchPrs = async (io: Io): Promise<Fetched<Pr>> => {
96  let result: Awaited<ReturnType<Io['run']>>
97
98  try {
99    result = await io.run(['gh', 'api', 'graphql', '-f', `query=${QUERY}`], { timeoutMs: TIMEOUT_MS })
100  } catch (error) {
101    return { ok: false, reason: failure(error) }
102  }
103
104  if (result.exitCode !== 0) {
105    return { ok: false, reason: result.exitCode === 4 || SIGNED_OUT.test(result.stderr) ? 'not-connected' : 'tool-error' }
106  }
107
108  try {
109    const data = JSON.parse(result.stdout)?.data
110    const review = nodesOf(data?.review)
111    const mine = nodesOf(data?.mine)
112
113    return review && mine ? { ok: true, items: pick(review, mine) } : { ok: false, reason: 'parse-error' }
114  } catch {
115    return { ok: false, reason: 'parse-error' }
116  }
117}
118
119// the top PR (items arrive most urgent first) speaks for the source; titles are never drawn
120const segment = (items: Pr[]): Segment | undefined => {
121  const [top] = items
122
123  if (!top) {
124    return undefined
125  }
126
127  const [full, short] = LABELS[top.reason]
128  const more = items.length > 1 ? ` +${items.length - 1}` : ''
129
130  return {
131    rank: RANK[top.reason],
132    tone: TONE[top.reason],
133    levels: [
134      `${ICON} ${top.repo}#${top.number} ${full}${more}`,
135      `${ICON} #${top.number} ${short}${more}`,
136      `${ICON} ${items.length} PR${items.length > 1 ? 's' : ''}`,
137    ],
138  }
139}
140
141// one group per reason, most urgent first
142const details = (items: Pr[]): Group[] =>
143  ORDER.flatMap(reason => {
144    const rows = items
145      .filter(item => item.reason === reason)
146      .map(({ repo, number, title, url, draft }) => ({ label: `${repo}#${number}`, title, url, ...(draft && { note: 'draft' }) }))
147
148    return rows.length > 0 ? [{ heading: HEADINGS[reason], rows }] : []
149  })
150
151export const github: Source<Pr> = {
152  id: 'github',
153  label: 'GitHub',
154  icon: ICON,
155  ttlMs: 5 * 60_000,
156  enabled: options => options.github === true,
157  fetch: fetchPrs,
158  segment,
159  details,
160}
161
hooks/line.tsx 36 lines
1import type { EngineInterface } from 'claude-code'
2
3import { SEPARATOR } from './core'
4import type { Segment } from './core'
5
6type Ui = ReturnType<EngineInterface['ui']['resolve']>
7
8// theme keys, so the line follows the person's theme; no tone: the terminal's own text color
9const COLORS = { urgent: 'warning', alert: 'error', active: 'suggestion' } as const
10
11// dim: the last good result of a failing source, or a source that never worked
12const style = (segment: Segment) =>
13  segment.stale || segment.tone === 'muted' ? { dimColor: true } : segment.tone ? { color: COLORS[segment.tone] } : {}
14
15// what fit chose, drawn one dim separator between segments, then how many were dropped;
16// flush left, so it lines up with the other mods drawn in the band. One plain button holds it
17// all, so a click anywhere on the line presses (it opens the details)
18export const line = (
19  { Box, Button, Text }: Ui,
20  shown: { segment: Segment; text: string }[],
21  hidden: number,
22  onPress: () => void,
23) => (
24  <Box>
25    <Button plain key="up-to-date:line" onPress={onPress}>
26      {shown.flatMap(({ segment, text }, i) => [
27        ...(i > 0 ? [<Text key={`${i}:sep`} dimColor>{SEPARATOR}</Text>] : []),
28        <Text key={`${i}`} wrap="truncate" {...style(segment)}>
29          {text}
30        </Text>,
31      ])}
32      {hidden > 0 ? [<Text key="hidden" dimColor>{`${SEPARATOR}+${hidden}`}</Text>] : []}
33    </Button>
34  </Box>
35)
36
types/index.d.ts 16 lines
1// what a source last fetched (see hooks/core.ts), kept as plain JSON
2export type Entry = {
3  items: unknown[]
4  at: number
5  error?: { reason: 'not-connected' | 'tool-error' | 'parse-error' | 'timeout'; at: number }
6  fails: number
7  nextTryAt: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    // each enabled source's entry by source id, and the time of the last tick
13    'up-to-date': { entries: Record<string, Entry>; now: number }
14  }
15}
16