SLOPSHOPPER

glance

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

newbandcommandprocesstimer
★ 167v0.1.0MITupdated 2026-10-03hamzafer/claude-code-mods/mods/glance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glance
› fix the failing auth test and add an audit log call ⏺ 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 › /glance ⎿ glance: **📅 Meetings** _(couldn't reach it)_ ⎿ glance: - nothing in the next 7 days ⎿ glance: ⎿ glance: **🔀 PRs that need you** _(couldn't reach it)_ ⎿ glance: - none ⎿ glance: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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>

🚀 Install

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.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA 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-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
💳openai-balanceYour 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-radarOne live line per running subagent/radar
🔍review-watchOne 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-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks 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

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds 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-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat 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>

🛰️ Flagship: mission-control

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.

mission-control at 4x: two subagents and a logout feature landing 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, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

Source 3 files
hooks/register.tsx 172 lines
1// 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}
172
hooks/pick.ts 280 lines
1// 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}
280
types/index.d.ts 26 lines
1// 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