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

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.
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 │
└──────────────────────────────────────────────┘
ctrl+x tab focuses the line, then Enter opens the pane."tui": "fullscreen" in settings.json), so in the default layout use the keyboard.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.
| Source | Icon | Most 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 |
🔀 #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).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.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:
| Setting | Source |
|---|---|
github | GitHub |
calendar | Calendar |
calendarMaxBlockHours | Longest 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.
/mcp lists it as claude.ai Google Calendar).gh) installed and signed in (gh auth login).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.
/plugin install up-to-date --marketplace TomDiem/claude-code-modshooks/register.tsx 196 lines1import { 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}
196hooks/core.ts 155 lines1// 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}
155hooks/calendar.ts 161 lines1import 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}
161hooks/details.tsx 38 lines1import 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)
38hooks/github.ts 161 lines1import 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}
161hooks/line.tsx 36 lines1import 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)
36types/index.d.ts 16 lines1// 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