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

<h1 align="center">Claude Code mods</h1>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>
<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>
Add the marketplace once, then install any mod by name:
claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods
Or install the general-purpose set in one go:
for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
claude plugin install "$m@claude-code-mods"
done
Restart Claude Code after installing. To try one without installing:
git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar
Needs Claude Code 2.1.287+. A few mods need more (Chrome,
gh, a connector); the tables say which.
| Mod | What it does | Command | |
|---|---|---|---|
| 🛰️ | mission-control | Live map of agents, tool calls and the code they touch | /mission |
| 📊 | context-bar | Your context window as one stacked bar, a color per category, with token counts and where it compacts | /context-bar |
| 🌦️ | token-weather | Context fill from Clear to Compact soon, plus a prompt-cache countdown | |
| ⏱️ | cache-clock | A prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+ | /cache-clock setup |
| 📍 | where-am-i | Goal, doing now, waiting on you, next step | /where |
| ➡️ | next-steps | 2 or 3 likely next prompts after each turn, one key to draft one | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 💳 | openai-balance | Your OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key | /openai-balance |
| 📡 | agent-radar | One live line per running subagent | /radar |
| 🔍 | review-watch | One live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends | |
| 🌐 | browser-lanes | Whether this session has a browser, and who holds it | /browser |
| 🕌 | prayer-times | The current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr | /prayers |
| 🎬 | replay-theater | Steps through the last turn's edits, one diff at a time | /replay |
| 📝 | md-preview | Renders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images | /md |
| Mod | What it does | Command | |
|---|---|---|---|
| 💥 | blast-radius | Holds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer |
| Mod | What it does | Command | |
|---|---|---|---|
| 🔀 | switchboard | Picks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost | /route |
These are built around my own tools and rules. Fork them and change the rules to yours.
| Mod | What it does | Command | |
|---|---|---|---|
| 👀 | glance | One line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors | /glance |
| 🚦 | merge-gate | Holds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours | /gate |
| 📏 | rulebook-guard | Enforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes | |
| 💾 | session-saver | Saves where you left off, shows it on resume. Needs unpause | /park [note] |
| Mod | What it does | Command | |
|---|---|---|---|
| 📱 | reels | YouTube Shorts while Claude works, pauses when it's done | /reels |
| 🐍 | snake | Snake while Claude works | /snake |
| 🎵 | now-playing | What Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app | /music |
<a id="mission-control"></a>
Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.
w shows the agents and every tool call, livec shows the code map, with import arrowsThe Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
hooks/register.tsx 172 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 type { Part } from './pick'
7import {
8 PR_QUERY, SEPARATOR, clock, dmPart, dmsFrom, fit, issuePart, issuesFrom, meetingPart, meetingsFrom, mergeDms, prPart, prsFrom, slackIdFrom, when,
9} from './pick'
10
11const EVERY_MS = 5 * 60 * 1000
12const TICK_MS = 60 * 1000 // the meeting countdown moves each minute without a fetch
13const SLACK_WINDOW_S = 2 * 60 * 60
14
15// Held by the host, so what was fetched survives a hot reload of this file.
16const meetings = atom({ plugin: 'glance', key: 'meetings' } as const, { items: [], fetchedAt: 0, isFailed: false } as Source<Meeting>)
17const prs = atom({ plugin: 'glance', key: 'prs' } as const, { items: [], fetchedAt: 0, isFailed: false } as Source<Pr>)
18const issues = atom({ plugin: 'glance', key: 'issues' } as const, { items: [], fetchedAt: 0, isFailed: false } as Source<Issue>)
19const dms = atom({ plugin: 'glance', key: 'dms' } as const, { items: [], fetchedAt: 0, isFailed: false } as Source<Dm>)
20const triedAt = atom({ plugin: 'glance', key: 'triedAt' } as const, 0)
21const minute = atom({ plugin: 'glance', key: 'minute' } as const, 0)
22
23export const register: Register = on => {
24 on('session.start', async ($, e, next) => {
25 const result = await next(e)
26 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
27 void refresh($, false).catch(() => {}) // in the background: a slow source never holds up the session
28 $.clock.every(TICK_MS, () => void tick($).catch(() => {}))
29 return result
30 })
31
32 on('command.run', { command: 'glance' }, async $ => {
33 await refresh($, true)
34 return { text: await details($) }
35 })
36
37 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
38 const rest = await next(e) // what other mods and Claude Code draw here stays
39 if (e.props.hasSurvey) return rest
40 await read($, minute) // redraw each minute, for the countdown
41 const now = await $.clock.now()
42 const [m, p, i, d] = [await read($, meetings), await read($, prs), await read($, issues), await read($, dms)]
43
44 const next1 = m.items.find(x => x.end > now)
45 const parts = [
46 dimmed(next1 && meetingPart(next1, now), m.isFailed),
47 dimmed(prPart(p.items), p.isFailed),
48 dimmed(issuePart(i.items), i.isFailed),
49 dimmed(dmPart(d.items), d.isFailed),
50 ]
51 if (parts.every(x => !x)) return rest
52
53 const texts = fit(parts, e.props.bodyColumns - 2)
54 const { Box, Text } = $.ui.resolve(e)
55 const shown = parts.map((part, k) => ({ part, text: texts[k] })).filter(x => x.part && x.text)
56 return (
57 <Box flexDirection="column">
58 <Box flexDirection="row" paddingX={1}>
59 {shown.map(({ part, text }, k) => (
60 <Text key={String(k)} wrap="truncate" color={part!.isDim ? undefined : part!.color} dimColor={part!.isDim}>
61 {(k > 0 ? SEPARATOR : '') + text}
62 </Text>
63 ))}
64 </Box>
65 {rest}
66 </Box>
67 )
68 })
69}
70
71// A source that failed to refresh still shows its last answer, dimmed.
72function dimmed(part: Part | undefined, isFailed: boolean): Part | undefined {
73 return part && { ...part, isDim: isFailed }
74}
75
76async function tick($: EngineInterface) {
77 const now = await $.clock.now()
78 await update($, minute, () => now)
79 await refresh($, false)
80}
81
82// Every 5 minutes, unless /glance asked. Each source fails on its own and keeps its last answer.
83async function refresh($: EngineInterface, isForced: boolean) {
84 const now = await $.clock.now()
85 if (!isForced && (await read($, triedAt)) > 0 && now - (await read($, triedAt)) < EVERY_MS) return
86 await update($, triedAt, () => now)
87
88 const [m, p, i, d] = await Promise.all([
89 attempt(async () => meetingsFrom(await mcp($, 'claude.ai Google Calendar', 'list_events', { pageSize: 30, orderBy: 'startTime' }), now)),
90 attempt(async () => {
91 const { exitCode, stdout } = await $.process.run(['gh', 'api', 'graphql', '-f', `query=${PR_QUERY}`], { timeoutMs: 20_000 })
92 if (exitCode !== 0) throw new Error('gh failed')
93 return prsFrom(JSON.parse(stdout))
94 }),
95 attempt(async () =>
96 issuesFrom(await mcp($, 'claude.ai Linear', 'list_issues', {
97 assignee: 'me', state: 'started', limit: 25, fields: ['title', 'status', 'url', 'updatedAt'],
98 }))),
99 attempt(() => slack($, now)),
100 ])
101 await update($, meetings, s => settle(s, m, now))
102 await update($, prs, s => settle(s, p, now))
103 await update($, issues, s => settle(s, i, now))
104 await update($, dms, s => settle(s, d, now))
105}
106
107// Slack's search has no "mentions me" filter: to:me finds DMs, and a second
108// search for <@your-id> finds channel mentions. The id is looked up once and
109// kept in memory only. Without it, DMs alone still show.
110let slackId: string | undefined
111async function slack($: EngineInterface, now: number) {
112 const search = { after: String(Math.floor(now / 1000) - SLACK_WINDOW_S), sort: 'timestamp', limit: 10, include_bots: false, include_context: false, natural_language_query: '' }
113 slackId ??= await mcp($, 'claude.ai Slack', 'slack_read_user_profile', { response_format: 'detailed' }).then(slackIdFrom, () => undefined)
114 const [direct, mentions] = await Promise.all([
115 mcp($, 'claude.ai Slack', 'slack_search_public_and_private', { ...search, filters: 'to:me' }).then(dmsFrom),
116 slackId
117 ? mcp($, 'claude.ai Slack', 'slack_search_public_and_private', { ...search, keywords: [`<@${slackId}>`], channel_types: 'public_channel,private_channel' }).then(dmsFrom, () => [])
118 : [],
119 ])
120 return mergeDms(direct, mentions)
121}
122
123// The items, or undefined when the source failed.
124async function attempt<T>(get: () => Promise<T[]>): Promise<T[] | undefined> {
125 try {
126 return await get()
127 } catch {
128 return undefined
129 }
130}
131
132// A failed source keeps its last items and is marked, so the line dims it.
133function settle<T>(s: Source<T>, items: T[] | undefined, now: number): Source<T> {
134 return items ? { items, fetchedAt: now, isFailed: false } : { ...s, isFailed: true }
135}
136
137// A connector's answer is JSON in its first text block.
138async function mcp($: EngineInterface, server: string, tool: string, args: Record<string, unknown>) {
139 const result: McpToolResult = await $.mcp.call(server, tool, args)
140 const block = result.content.find(c => c.type === 'text') as { text?: string } | undefined
141 if (result.isError || !block?.text) throw new Error(`${server} ${tool} failed`)
142 return JSON.parse(block.text)
143}
144
145// What /glance prints: everything behind the line.
146async function details($: EngineInterface) {
147 const now = await $.clock.now()
148 const [m, p, i, d] = [await read($, meetings), await read($, prs), await read($, issues), await read($, dms)]
149 const stale = (s: Source<unknown>) => (s.isFailed ? (s.fetchedAt ? ` _(couldn't refresh, from ${clock(s.fetchedAt)})_` : " _(couldn't reach it)_") : '')
150 const lines: string[] = []
151
152 lines.push(`**📅 Meetings**${stale(m)}`)
153 const upcoming = m.items.filter(x => x.end > now).slice(0, 3)
154 if (upcoming.length === 0) lines.push('- nothing in the next 7 days')
155 for (const x of upcoming) lines.push(`- ${x.title}, ${when(x, now)}`)
156
157 lines.push('', `**🔀 PRs that need you**${stale(p)}`)
158 if (p.items.length === 0) lines.push('- none')
159 const reason = { review: 'review requested', ci: 'CI failing', changes: 'changes requested' }
160 for (const x of p.items) lines.push(`- [${x.repo}#${x.number}](${x.url}) ${x.title}: **${reason[x.reason]}**`)
161
162 lines.push('', `**📋 Linear in progress**${stale(i)}`)
163 if (i.items.length === 0) lines.push('- none')
164 for (const x of i.items) lines.push(`- [${x.id}](${x.url}) ${x.status}: ${x.title}`)
165
166 lines.push('', `**💬 Slack DMs and mentions, last 2 hours**${stale(d)}`)
167 if (d.items.length === 0) lines.push('- nothing new')
168 for (const x of d.items) lines.push(`- ${clock(x.at)} ${x.from}: ${x.text || '(no text)'}${x.url ? ` ([open](${x.url}))` : ''}`)
169
170 return lines.join('\n')
171}
172hooks/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