One line above the prompt with what needs you: next meeting, PRs, Linear issues and Slack DMs. /glance lists them all.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 184 lines1// glance: one line above the prompt with what needs you: next meeting, PRs, Linear, Slack.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, McpToolResult, Register } from 'claude-code'
4
5import type { Dm, Issue, Meeting, Pr, Source } from '../types'
6import { bandsFile, parseOrder, stackBands } from './bands'
7import type { Part } from './pick'
8import {
9 PR_QUERY, SEPARATOR, clock, dmPart, dmsFrom, fit, issuePart, issuesFrom, meetingPart, meetingsFrom, mergeDms, prPart, prsFrom, slackIdFrom, when,
10} from './pick'
11
12const EVERY_MS = 5 * 60 * 1000
13const TICK_MS = 60 * 1000 // the meeting countdown moves each minute without a fetch
14const SLACK_WINDOW_S = 2 * 60 * 60
15
16// Held by the host, so what was fetched survives a hot reload of this file.
17const meetings = atom({ plugin: 'glance', key: 'meetings' } as const, { items: [], fetchedAt: 0, isFailed: false } as Source<Meeting>)
18const prs = atom({ plugin: 'glance', key: 'prs' } as const, { items: [], fetchedAt: 0, isFailed: false } as Source<Pr>)
19const issues = atom({ plugin: 'glance', key: 'issues' } as const, { items: [], fetchedAt: 0, isFailed: false } as Source<Issue>)
20const dms = atom({ plugin: 'glance', key: 'dms' } as const, { items: [], fetchedAt: 0, isFailed: false } as Source<Dm>)
21const triedAt = atom({ plugin: 'glance', key: 'triedAt' } as const, 0)
22const minute = atom({ plugin: 'glance', key: 'minute' } as const, 0)
23
24let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
25
26export const register: Register = on => {
27 on('session.start', async ($, e, next) => {
28 const result = await next(e)
29 await $.command.register({ name: 'glance', description: 'Refresh glance and list everything that needs you' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
30 void refresh($, false).catch(() => {}) // in the background: a slow source never holds up the session
31 $.clock.every(TICK_MS, () => void tick($).catch(() => {}))
32 return result
33 })
34
35 on('command.run', { command: 'glance' }, async $ => {
36 await refresh($, true)
37 return { text: await details($) }
38 })
39
40 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
41 const rest = await next(e) // what other mods and Claude Code draw here stays
42 if (e.props.hasSurvey) return rest
43 await read($, minute) // redraw each minute, for the countdown
44 const now = await $.clock.now()
45 const [m, p, i, d] = [await read($, meetings), await read($, prs), await read($, issues), await read($, dms)]
46
47 const next1 = m.items.find(x => x.end > now)
48 const parts = [
49 dimmed(next1 && meetingPart(next1, now), m.isFailed),
50 dimmed(prPart(p.items), p.isFailed),
51 dimmed(issuePart(i.items), i.isFailed),
52 dimmed(dmPart(d.items), d.isFailed),
53 ]
54 if (parts.every(x => !x)) return rest
55
56 const texts = fit(parts, e.props.bodyColumns - 2)
57 const { Box, Text } = $.ui.resolve(e)
58 const shown = parts.map((part, k) => ({ part, text: texts[k] })).filter(x => x.part && x.text)
59 const mine = (
60 <Box flexDirection="row" paddingX={1}>
61 {shown.map(({ part, text }, k) => (
62 <Text key={String(k)} wrap="truncate" color={part!.isDim ? undefined : part!.color} dimColor={part!.isDim}>
63 {(k > 0 ? SEPARATOR : '') + text}
64 </Text>
65 ))}
66 </Box>
67 )
68 return stackBands(rest, 'glance', mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
69 })
70}
71
72// The band order file, as the claude-code-mods checklist saves it; [] on any error.
73async function readBandOrder($: EngineInterface): Promise<string[]> {
74 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
75 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
76}
77
78// Forgets the cached band order, for tests.
79export function resetBandOrder() {
80 bandOrder = undefined
81}
82
83// A source that failed to refresh still shows its last answer, dimmed.
84function dimmed(part: Part | undefined, isFailed: boolean): Part | undefined {
85 return part && { ...part, isDim: isFailed }
86}
87
88async function tick($: EngineInterface) {
89 const now = await $.clock.now()
90 await update($, minute, () => now)
91 await refresh($, false)
92}
93
94// Every 5 minutes, unless /glance asked. Each source fails on its own and keeps its last answer.
95async function refresh($: EngineInterface, isForced: boolean) {
96 const now = await $.clock.now()
97 if (!isForced && (await read($, triedAt)) > 0 && now - (await read($, triedAt)) < EVERY_MS) return
98 await update($, triedAt, () => now)
99
100 const [m, p, i, d] = await Promise.all([
101 attempt(async () => meetingsFrom(await mcp($, 'claude.ai Google Calendar', 'list_events', { pageSize: 30, orderBy: 'startTime' }), now)),
102 attempt(async () => {
103 const { exitCode, stdout } = await $.process.run(['gh', 'api', 'graphql', '-f', `query=${PR_QUERY}`], { timeoutMs: 20_000 })
104 if (exitCode !== 0) throw new Error('gh failed')
105 return prsFrom(JSON.parse(stdout))
106 }),
107 attempt(async () =>
108 issuesFrom(await mcp($, 'claude.ai Linear', 'list_issues', {
109 assignee: 'me', state: 'started', limit: 25, fields: ['title', 'status', 'url', 'updatedAt'],
110 }))),
111 attempt(() => slack($, now)),
112 ])
113 await update($, meetings, s => settle(s, m, now))
114 await update($, prs, s => settle(s, p, now))
115 await update($, issues, s => settle(s, i, now))
116 await update($, dms, s => settle(s, d, now))
117}
118
119// Slack's search has no "mentions me" filter: to:me finds DMs, and a second
120// search for <@your-id> finds channel mentions. The id is looked up once and
121// kept in memory only. Without it, DMs alone still show.
122let slackId: string | undefined
123async function slack($: EngineInterface, now: number) {
124 const search = { after: String(Math.floor(now / 1000) - SLACK_WINDOW_S), sort: 'timestamp', limit: 10, include_bots: false, include_context: false, natural_language_query: '' }
125 slackId ??= await mcp($, 'claude.ai Slack', 'slack_read_user_profile', { response_format: 'detailed' }).then(slackIdFrom, () => undefined)
126 const [direct, mentions] = await Promise.all([
127 mcp($, 'claude.ai Slack', 'slack_search_public_and_private', { ...search, filters: 'to:me' }).then(dmsFrom),
128 slackId
129 ? mcp($, 'claude.ai Slack', 'slack_search_public_and_private', { ...search, keywords: [`<@${slackId}>`], channel_types: 'public_channel,private_channel' }).then(dmsFrom, () => [])
130 : [],
131 ])
132 return mergeDms(direct, mentions)
133}
134
135// The items, or undefined when the source failed.
136async function attempt<T>(get: () => Promise<T[]>): Promise<T[] | undefined> {
137 try {
138 return await get()
139 } catch {
140 return undefined
141 }
142}
143
144// A failed source keeps its last items and is marked, so the line dims it.
145function settle<T>(s: Source<T>, items: T[] | undefined, now: number): Source<T> {
146 return items ? { items, fetchedAt: now, isFailed: false } : { ...s, isFailed: true }
147}
148
149// A connector's answer is JSON in its first text block.
150async function mcp($: EngineInterface, server: string, tool: string, args: Record<string, unknown>) {
151 const result: McpToolResult = await $.mcp.call(server, tool, args)
152 const block = result.content.find(c => c.type === 'text') as { text?: string } | undefined
153 if (result.isError || !block?.text) throw new Error(`${server} ${tool} failed`)
154 return JSON.parse(block.text)
155}
156
157// What /glance prints: everything behind the line.
158async function details($: EngineInterface) {
159 const now = await $.clock.now()
160 const [m, p, i, d] = [await read($, meetings), await read($, prs), await read($, issues), await read($, dms)]
161 const stale = (s: Source<unknown>) => (s.isFailed ? (s.fetchedAt ? ` _(couldn't refresh, from ${clock(s.fetchedAt)})_` : " _(couldn't reach it)_") : '')
162 const lines: string[] = []
163
164 lines.push(`**📅 Meetings**${stale(m)}`)
165 const upcoming = m.items.filter(x => x.end > now).slice(0, 3)
166 if (upcoming.length === 0) lines.push('- nothing in the next 7 days')
167 for (const x of upcoming) lines.push(`- ${x.title}, ${when(x, now)}`)
168
169 lines.push('', `**🔀 PRs that need you**${stale(p)}`)
170 if (p.items.length === 0) lines.push('- none')
171 const reason = { review: 'review requested', ci: 'CI failing', changes: 'changes requested' }
172 for (const x of p.items) lines.push(`- [${x.repo}#${x.number}](${x.url}) ${x.title}: **${reason[x.reason]}**`)
173
174 lines.push('', `**📋 Linear in progress**${stale(i)}`)
175 if (i.items.length === 0) lines.push('- none')
176 for (const x of i.items) lines.push(`- [${x.id}](${x.url}) ${x.status}: ${x.title}`)
177
178 lines.push('', `**💬 Slack DMs and mentions, last 2 hours**${stale(d)}`)
179 if (d.items.length === 0) lines.push('- nothing new')
180 for (const x of d.items) lines.push(`- ${clock(x.at)} ${x.from}: ${x.text || '(no text)'}${x.url ? ` ([open](${x.url}))` : ''}`)
181
182 return lines.join('\n')
183}
184hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73hooks/pick.ts 280 lines1// Pure parts of glance: read each source's answer, rank it, and fit the line.
2import type { Dm, Issue, Meeting, Pr } from '../types'
3
4const MIN = 60_000
5const LONG_BLOCK = 3 * 60 * MIN // a block running now that's longer than this doesn't hide the next meeting
6const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
7
8// ---- Calendar ---------------------------------------------------------------
9
10type CalEvent = {
11 summary?: string
12 status?: string
13 eventType?: string
14 start?: { dateTime?: string; date?: string }
15 end?: { dateTime?: string; date?: string }
16 attendees?: { self?: boolean; responseStatus?: string }[]
17 htmlLink?: string
18}
19
20/**
21 * Timed events that haven't ended and that you haven't declined, soonest
22 * first. A long block already running (a workshop, "busy") is left out, so it
23 * can't hide the next meeting.
24 */
25export function meetingsFrom(answer: { events?: CalEvent[] }, now: number): Meeting[] {
26 return (answer.events ?? [])
27 .filter(e => e.status !== 'cancelled' && e.start?.dateTime && e.end?.dateTime) // all-day events have only a date
28 .filter(e => e.eventType === undefined || e.eventType === 'DEFAULT' || e.eventType === 'FROM_GMAIL')
29 .filter(e => e.attendees?.find(a => a.self)?.responseStatus !== 'declined')
30 .map(e => ({
31 title: e.summary?.trim() || '(no title)',
32 start: Date.parse(e.start!.dateTime!),
33 end: Date.parse(e.end!.dateTime!),
34 url: e.htmlLink ?? '',
35 }))
36 .filter(m => m.end > now)
37 .filter(m => !(m.start <= now && m.end - m.start > LONG_BLOCK))
38 .sort((a, b) => a.start - b.start)
39}
40
41// ---- GitHub -----------------------------------------------------------------
42
43// One call: reviews asked of you, and your own open PRs with their CI and review state.
44export const PR_QUERY = `query {
45 review: search(query: "is:open is:pr review-requested:@me archived:false", type: ISSUE, first: 20) {
46 nodes { ... on PullRequest { number title url repository { name } } }
47 }
48 mine: search(query: "is:open is:pr author:@me archived:false", type: ISSUE, first: 50) {
49 nodes { ... on PullRequest { number title url isDraft reviewDecision repository { name }
50 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } } }
51 }
52}`
53
54type PrNode = {
55 number?: number
56 title?: string
57 url?: string
58 isDraft?: boolean
59 reviewDecision?: string | null
60 repository?: { name: string }
61 commits?: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] }
62}
63
64/** PRs that need you: reviews asked of you, then yours with failing CI (drafts last), then yours with changes requested. */
65export function prsFrom(answer: { data?: { review?: { nodes: PrNode[] }; mine?: { nodes: PrNode[] } } }): Pr[] {
66 const pr = (n: PrNode, reason: Pr['reason']): Pr => ({
67 repo: n.repository?.name ?? '',
68 number: n.number ?? 0,
69 title: n.title ?? '',
70 url: n.url ?? '',
71 reason,
72 })
73 const mine = (answer.data?.mine?.nodes ?? []).filter(n => n.number)
74 const ciState = (n: PrNode) => n.commits?.nodes[0]?.commit.statusCheckRollup?.state
75 const isFailing = (n: PrNode) => ciState(n) === 'FAILURE' || ciState(n) === 'ERROR'
76 const out = [
77 ...(answer.data?.review?.nodes ?? []).filter(n => n.number).map(n => pr(n, 'review')),
78 ...mine.filter(n => isFailing(n) && !n.isDraft).map(n => pr(n, 'ci')),
79 ...mine.filter(n => isFailing(n) && n.isDraft).map(n => pr(n, 'ci')),
80 ...mine.filter(n => !isFailing(n) && n.reviewDecision === 'CHANGES_REQUESTED').map(n => pr(n, 'changes')),
81 ]
82 const seen = new Set<string>()
83 return out.filter(p => !seen.has(p.url) && seen.add(p.url))
84}
85
86// ---- Linear -----------------------------------------------------------------
87
88type LinearIssue = { id?: string; title?: string; status?: string; url?: string; updatedAt?: string }
89
90/** Your started issues (In Progress, In Review), most recently touched first. */
91export function issuesFrom(answer: { issues?: LinearIssue[] }): Issue[] {
92 return (answer.issues ?? [])
93 .filter(i => i.id)
94 .map(i => ({ id: i.id!, title: i.title ?? '', status: i.status ?? '', url: i.url ?? '', updatedAt: Date.parse(i.updatedAt ?? '') || 0 }))
95 .sort((a, b) => b.updatedAt - a.updatedAt)
96}
97
98// ---- Slack ------------------------------------------------------------------
99
100/**
101 * Messages from Slack's search answer, newest first. The connector answers in
102 * markdown, one "### Result" block per message with From, Message_ts,
103 * Permalink and Text lines.
104 */
105export function dmsFrom(answer: { results?: string }): Dm[] {
106 const blocks = (answer.results ?? '').split(/^### Result /m).slice(1)
107 const out: Dm[] = []
108 for (const block of blocks) {
109 const from = /^From: (.+?)(?: <[^>]*>)?(?: \(ID: [^)]*\))?\s*$/m.exec(block)?.[1]?.trim()
110 const ts = Number(/^Message_ts: ([\d.]+)/m.exec(block)?.[1])
111 if (!from || !ts) continue
112 const url = /^Permalink: \[[^\]]*\]\(([^)]+)\)/m.exec(block)?.[1] ?? ''
113 const at = block.search(/^Text:/m)
114 const body = at < 0 ? '' : block.slice(at).replace(/^Text: */, '').split(/\n---/)[0] ?? ''
115 const text = body
116 .split('\n')
117 .map(line => plain(line).replace(/:[a-z0-9_+-]+:/gi, '').replace(/\s+/g, ' ').trim())
118 .find(line => line !== '') ?? ''
119 out.push({ from, text, at: Math.round(ts * 1000), url })
120 }
121 return out.sort((a, b) => b.at - a.at)
122}
123
124/** Slack markup as read: <@U1|name> is @name, <url|label> is label. */
125function plain(line: string) {
126 return line
127 .replace(/<@[A-Z0-9]+\|([^>]+)>/g, '@$1')
128 .replace(/<@[A-Z0-9]+>/g, '@someone')
129 .replace(/<#[A-Z0-9]+\|([^>]*)>/g, '#$1')
130 .replace(/<(?:https?:|mailto:)[^|>]*\|([^>]+)>/g, '$1')
131 .replace(/<((?:https?:|mailto:)[^>]+)>/g, '$1')
132}
133
134/** DMs and mentions together, newest first, each message once. */
135export function mergeDms(...lists: Dm[][]): Dm[] {
136 const seen = new Set<string>()
137 return lists
138 .flat()
139 .sort((a, b) => b.at - a.at)
140 .filter(d => {
141 const key = d.url || `${d.from}@${d.at}`
142 return !seen.has(key) && !!seen.add(key)
143 })
144}
145
146/** The signed-in user's id from Slack's detailed profile answer. */
147export function slackIdFrom(answer: { result?: string }) {
148 return /^User ID: ([A-Z0-9]+)$/m.exec(answer.result ?? '')?.[1]
149}
150
151// ---- The line ---------------------------------------------------------------
152
153/** One part of the line: its texts from fullest to shortest. */
154export type Part = { levels: string[]; color?: string; isDim?: boolean }
155
156/** Clock time as 24-hour HH:MM in the machine's time zone. */
157export function clock(ms: number) {
158 const d = new Date(ms)
159 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
160}
161
162function dayKey(ms: number) {
163 const d = new Date(ms)
164 return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
165}
166
167/** When a meeting is, said the shortest way that's still clear. */
168export function when(m: Meeting, now: number) {
169 if (m.start <= now) return `now until ${clock(m.end)}`
170 const mins = Math.ceil((m.start - now) / MIN)
171 if (mins < 60) return `in ${mins} min`
172 if (dayKey(m.start) === dayKey(now)) return `at ${clock(m.start)}`
173 if (dayKey(m.start) === dayKey(now + 86_400_000)) return `tomorrow ${clock(m.start)}`
174 return `${WEEKDAY[new Date(m.start).getDay()]} ${clock(m.start)}`
175}
176
177export function meetingPart(m: Meeting, now: number): Part {
178 const w = when(m, now)
179 const isNow = m.start <= now
180 const isSoon = !isNow && m.start - now <= 15 * MIN
181 const say = (title: string) => (isNow ? `📅 now: ${title} until ${clock(m.end)}` : `📅 ${title} ${w}`)
182 return {
183 levels: [say(m.title), say(cut(m.title, 16)), `📅 ${w}`],
184 color: isNow ? 'cyan' : isSoon ? 'yellow' : undefined,
185 }
186}
187
188const REASON: Record<Pr['reason'], string> = { review: 'review requested', ci: 'CI failing', changes: 'changes requested' }
189
190export function prPart(prs: Pr[]): Part | undefined {
191 const [top] = prs
192 if (!top) return undefined
193 const more = prs.length > 1 ? ` +${prs.length - 1}` : ''
194 return {
195 levels: [
196 `🔀 ${top.repo}#${top.number}: ${REASON[top.reason]}${more}`,
197 `🔀 #${top.number} ${REASON[top.reason]}${more}`,
198 `🔀 ${prs.length} PR${prs.length === 1 ? '' : 's'}`,
199 ],
200 color: top.reason === 'ci' ? 'red' : top.reason === 'review' ? 'yellow' : undefined,
201 }
202}
203
204export function issuePart(issues: Issue[]): Part | undefined {
205 const [top] = issues
206 if (!top) return undefined
207 const more = issues.length > 1 ? ` +${issues.length - 1}` : ''
208 return {
209 levels: [
210 `📋 ${top.id} ${top.status}: ${cut(top.title, 28)}${more}`,
211 `📋 ${top.id} ${top.status}${more}`,
212 `📋 ${issues.length} active`,
213 ],
214 }
215}
216
217export function dmPart(dms: Dm[]): Part | undefined {
218 const [top] = dms
219 if (!top) return undefined
220 const more = dms.length > 1 ? ` +${dms.length - 1}` : ''
221 const name = top.from.split(' ')[0]
222 return {
223 levels: [
224 top.text ? `💬 ${name}: "${cut(top.text, 30)}"${more}` : `💬 ${name}${more}`,
225 `💬 ${dms.length} new`,
226 ],
227 }
228}
229
230export const SEPARATOR = ' · '
231
232/**
233 * Shrinks the parts until the line fits `columns`. Parts are in line order
234 * (meeting, PRs, Linear, Slack); Slack gives way first and the meeting last.
235 * Returns each part's chosen text, in order.
236 */
237export function fit(parts: (Part | undefined)[], columns: number): (string | undefined)[] {
238 const level = parts.map(() => 0)
239 const text = () => parts.map((p, i) => p && p.levels[Math.min(level[i] ?? 0, p.levels.length - 1)])
240 const width = () => text().filter(Boolean).reduce((sum, t, i) => sum + cells(t!) + (i > 0 ? SEPARATOR.length : 0), 0)
241 // Each step shortens one part by one level: Slack, then Linear and PRs, then all three again, then the meeting.
242 const steps = [3, 2, 1, 3, 2, 1, 0, 0]
243 for (const i of steps) {
244 if (width() <= columns) break
245 level[i] = (level[i] ?? 0) + 1
246 }
247 return text()
248}
249
250/** Terminal cells a string takes: wide characters count two, variation selectors none. */
251export function cells(s: string) {
252 let n = 0
253 for (const ch of s) {
254 const c = ch.codePointAt(0)!
255 if (c === 0xfe0f || c === 0x200d) continue
256 n += isWide(c) ? 2 : 1
257 }
258 return n
259}
260
261// Emoji, and East Asian wide and fullwidth letters (Hangul, CJK, kana, fullwidth forms).
262function isWide(c: number) {
263 return (
264 c >= 0x1f000 ||
265 (c >= 0x2600 && c <= 0x27bf) ||
266 (c >= 0x1100 && c <= 0x115f) ||
267 (c >= 0x2e80 && c <= 0xa4cf) ||
268 (c >= 0xac00 && c <= 0xd7a3) ||
269 (c >= 0xf900 && c <= 0xfaff) ||
270 (c >= 0xfe30 && c <= 0xfe4f) ||
271 (c >= 0xff00 && c <= 0xff60) ||
272 (c >= 0xffe0 && c <= 0xffe6)
273 )
274}
275
276export function cut(s: string, max: number) {
277 const chars = [...s]
278 return chars.length <= max ? s : `${chars.slice(0, max - 1).join('').trimEnd()}…`
279}
280types/index.d.ts 26 lines1// What glance keeps per source: the items that need you, ranked, and how the last fetch went.
2export type Meeting = { title: string; start: number; end: number; url: string }
3export type Pr = { repo: string; number: number; title: string; url: string; reason: 'review' | 'ci' | 'changes' }
4export type Issue = { id: string; title: string; status: string; url: string; updatedAt: number }
5export type Dm = { from: string; text: string; at: number; url: string }
6
7export type Source<T> = {
8 items: T[]
9 // When the last good answer came in (0: never), and whether the latest try failed.
10 fetchedAt: number
11 isFailed: boolean
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 glance: {
17 meetings: Source<Meeting>
18 prs: Source<Pr>
19 issues: Source<Issue>
20 dms: Source<Dm>
21 triedAt: number
22 minute: number
23 }
24 }
25}
26