Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
{
"name": "md-preview",
"description": "Shows the Markdown files Claude edits, rendered like GitHub, in a pane next to the chat. Before and after side by side. /md opens it.",
"version": "0.1.7",
"version": "0.1.8",
"source": "./mods/md-preview",
"category": "See what's happening",
"tags": [
Expand All @@ -73,7 +73,7 @@
{
"name": "glance",
"description": "One line above the prompt with what needs you: next meeting, PRs, Linear issues and Slack DMs. /glance lists them all.",
"version": "0.1.1",
"version": "0.1.2",
"source": "./mods/glance",
"category": "My setup (fork and adapt)",
"tags": [
Expand All @@ -90,7 +90,7 @@
{
"name": "heatmap",
"description": "A GitHub-style grid of your Claude Code time in a /heatmap pane, with streaks, peaks and top projects.",
"version": "0.1.1",
"version": "0.1.2",
"source": "./mods/heatmap",
"category": "See what's happening",
"tags": [
Expand Down Expand Up @@ -155,7 +155,7 @@
{
"name": "prayer-times",
"description": "The current prayer and how long is left, the next prayer, and zawal, above the prompt. Computed on your computer from your location; nothing is sent anywhere.",
"version": "0.1.4",
"version": "0.1.5",
"source": "./mods/prayer-times",
"category": "See what's happening",
"tags": [
Expand Down Expand Up @@ -287,7 +287,7 @@
{
"name": "switchboard",
"description": "Picks the model for each subagent that names none, with OpenAI's Decisions API or Jev, from its short label only. One line per spawn above the prompt; /route shows each pick and what each subagent cost.",
"version": "0.3.2",
"version": "0.3.3",
"source": "./mods/switchboard",
"category": "Spend less",
"tags": [
Expand Down
2 changes: 1 addition & 1 deletion mods/glance/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "glance",
"version": "0.1.1",
"version": "0.1.2",
"description": "One line above the prompt with what needs you: next meeting, PRs, Linear issues and Slack DMs. /glance lists them all.",
"author": {
"name": "Hamza Zafar",
Expand Down
7 changes: 5 additions & 2 deletions mods/glance/hooks/register.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// glance: one line above the prompt with what needs you: next meeting, PRs, Linear, Slack.
import { atom, read, update } from 'claude-code'
import type { EngineInterface, McpToolResult, Register } from 'claude-code'
import type { EngineInterface, McpToolResult, Register, Timer } from 'claude-code'

import type { Dm, Issue, Meeting, Pr, Source } from '../types'
import { bandsFile, parseOrder, stackBands } from './bands'
Expand All @@ -24,11 +24,14 @@ const minute = atom({ plugin: 'glance', key: 'minute' } as const, 0)
let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply

export const register: Register = on => {
let timer: Timer | undefined // one minute timer, even if session.start fires again

on('session.start', async ($, e, next) => {
const result = await next(e)
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
void refresh($, false).catch(() => {}) // in the background: a slow source never holds up the session
$.clock.every(TICK_MS, () => void tick($).catch(() => {}))
timer?.cancel()
timer = $.clock.every(TICK_MS, () => void tick($).catch(() => {}))
return result
})

Expand Down
11 changes: 11 additions & 0 deletions mods/glance/tests/glance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@ describe('glance', () => {
expect(h.ghCalls()).toBe(2)
})

test('a second session.start keeps one minute timer, not two', async ($, on) => {
const h = engine(on)
let ticks = 0 // each tick writes the minute once
on('state.set', (_$: any, e: any, next: any) => (e.key === 'minute' && ticks++, next(e)))
await start($)
await start($)
await h.clock.advance(MIN)
await settle()
expect(ticks).toBe(1)
})

test('a failing source is left out, and the others still show', async ($, on) => {
engine(on, ['claude.ai Slack', 'gh'])
await start($)
Expand Down
2 changes: 1 addition & 1 deletion mods/heatmap/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "heatmap",
"version": "0.1.1",
"version": "0.1.2",
"description": "A GitHub-style grid of your Claude Code time in a /heatmap pane, with streaks, peaks and top projects.",
"author": {
"name": "Hamza Zafar",
Expand Down
6 changes: 3 additions & 3 deletions mods/heatmap/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const register: Register = on => {
void (async () => {
await refresh($)
await milestoneToast($, rows ?? [])
})()
})().catch(() => {})
return r
})

Expand All @@ -67,7 +67,7 @@ export const register: Register = on => {
on('command.run', { command: 'heatmap' }, async $ => {
await update($, isLoading, () => rows === null)
await $.ui.open({ id: PANE, title: 'Heatmap', focus: true })
void refresh($).then(() => update($, isLoading, () => false)) // today stays current mid-session
void refresh($).then(() => update($, isLoading, () => false)).catch(() => {}) // today stays current mid-session
return { text: 'Opened the heatmap' }
})

Expand All @@ -86,7 +86,7 @@ export const register: Register = on => {
</Box>
)
}
if (!(await read($, isLoading))) void refresh($) // a hot reload dropped the rows
if (!(await read($, isLoading))) void refresh($).catch(() => {}) // a hot reload dropped the rows
return (
<Box flexDirection="column">
<Text dimColor>Reading your session history… the first time takes a few seconds.</Text>
Expand Down
2 changes: 1 addition & 1 deletion mods/md-preview/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "md-preview",
"version": "0.1.7",
"version": "0.1.8",
"description": "Shows the Markdown files Claude edits, rendered like GitHub, in a pane next to the chat. Before and after side by side. /md opens it.",
"author": {
"name": "Hamza Zafar",
Expand Down
6 changes: 3 additions & 3 deletions mods/md-preview/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ async function renderSoon($: EngineInterface) {
void afterDraw($).catch(() => {})
return
}
$.clock.after(300, () => void run($, true))
$.clock.after(300, () => void run($, true).catch(() => {}))
}

// Draws a file Claude changed, in the background once the turn ends, so /md opens on it drawn.
Expand All @@ -536,7 +536,7 @@ async function startAhead($: EngineInterface) {
if (ahead.size === 0 || draw.pending) return // the drawing under way picks it up when done
draw.pending = true
draw.dirty = false
$.clock.after(300, () => void run($, false)) // edits close together wait for each other
$.clock.after(300, () => void run($, false).catch(() => {})) // edits close together wait for each other
}

// The one drawing at a time, for the pane or ahead: two Chromes must never share the profile.
Expand Down Expand Up @@ -570,7 +570,7 @@ async function afterDraw($: EngineInterface) {
if (draw.pending || ahead.size === 0 || turn.isOn) return
draw.pending = true
draw.dirty = false
void run($, false)
void run($, false).catch(() => {})
}

// Whether the open pane shows something other than what it should.
Expand Down
2 changes: 1 addition & 1 deletion mods/prayer-times/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prayer-times",
"version": "0.1.4",
"version": "0.1.5",
"description": "The current prayer and how long is left, the next prayer, and zawal, above the prompt. Computed on your computer from your location; nothing is sent anywhere.",
"author": {
"name": "Hamza Zafar",
Expand Down
6 changes: 4 additions & 2 deletions mods/prayer-times/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// /prayers lists today's times. A toast says when a prayer begins, and again when its time
// is nearly over.
import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register } from 'claude-code'
import type { EngineInterface, Register, Timer } from 'claude-code'

import { bandsFile, parseOrder, stackBands } from './bands'

Expand Down Expand Up @@ -50,14 +50,16 @@ export const register: Register = (on, options) => {
const warnMinutes = Math.min(120, num(options.warnMinutes, 15))
let toasted = ''
let warned = ''
let timer: Timer | undefined // one timer, even if session.start fires again

on('session.start', async ($, e, next) => {
const r = await next(e)
await $.command.register({ name: 'prayers', description: "Today's prayer times" }).catch(() => {})
const first = await $.clock.now()
await update($, now, () => first)
// The line moves each minute; a toast marks the start of each prayer, and another its last minutes.
$.clock.every(30_000, () => {
timer?.cancel()
timer = $.clock.every(30_000, () => {
void (async () => {
const t = await $.clock.now()
await update($, now, () => t)
Expand Down
14 changes: 14 additions & 0 deletions mods/prayer-times/tests/prayer-times.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,20 @@ describe('prayer-times', () => {
expect(toasts.filter(x => / ends in /.test(x))).toEqual([expect.stringMatching(/^⏳ Dhuhr ends in 1[45] min \(\d\d:\d\d\)$/)])
})

test('a second session.start keeps one timer, not two', { options: { latitude: 51.5074, longitude: -0.1278 } }, async ($, on) => {
const clock = mock.clock(on, { now: Date.now() })
let ticks = 0 // each tick writes the time once
on('session.start', (_$: any, e: any) => ({ sessionId: 's', cwd: e.cwd }))
on('command.register', () => ({ value: undefined }))
on('ui.toast', () => ({ value: undefined }))
on('state.set', (_$: any, e: any, next: any) => (e.key === 'now' && ticks++, next(e)))
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' } as any)
ticks = 0
await clock.advance(30_000)
expect(ticks).toBe(1)
})

test('without a location it asks for one and computes nothing', async ($, on) => {
mock.clock(on, { now: Date.now() })
resetBandOrder()
Expand Down
4 changes: 2 additions & 2 deletions mods/reels/tests/reels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ function engine(on: any, opts: { installed: boolean }) {
})
on('process.spawn', async function* (_$: any, e: any) {
spawned.push(e.argv)
if (e.argv.at(-1) !== 'pane') return { code: 0 }
if (e.argv.at(-1) !== 'pane') return { value: { code: 0, signal: null } }
running = true
yield { stream: 'stdout', text: 'R ready\nF 1 /Users/me/.claude-mods/reels/frames/f-1.png\n' }
while (running) await new Promise(done => (globalThis as any).setTimeout(done, 5))
return { code: 0 }
return { value: { code: 0, signal: null } }
})
return { sent, spawned }
}
Expand Down
2 changes: 1 addition & 1 deletion mods/switchboard/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "switchboard",
"version": "0.3.2",
"version": "0.3.3",
"description": "Picks the model for each subagent that names none, with OpenAI's Decisions API or Jev, from its short label only. One line per spawn above the prompt; /route shows each pick and what each subagent cost.",
"author": {
"name": "Hamza Zafar",
Expand Down
6 changes: 4 additions & 2 deletions mods/switchboard/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// in time, nothing changes. In auto mode the pick replaces the model the spawn would have run
// on; in suggest mode it is only shown. /route lists every subagent, its pick and its cost.
import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register } from 'claude-code'
import type { EngineInterface, Register, Timer } from 'claude-code'

import type { Route, Tier } from '../types'
import { bandsFile, parseOrder, stackBands } from './bands'
Expand Down Expand Up @@ -50,12 +50,14 @@ export const register: Register = (on, options) => {
openai: text(options.openaiApiKey),
zeroRetention: options.gatewayZeroRetention === true,
}
let timer: Timer | undefined // one redraw timer, even if session.start fires again

on('session.start', async ($, e, next) => {
const r = await next(e)
await $.command.register({ name: 'route', description: 'Show which model Switchboard picked for each subagent, and what it cost' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
// Redraws the band so a finished line leaves after 30 s; quiet when nothing is shown.
$.clock.every(5_000, () => {
timer?.cancel()
timer = $.clock.every(5_000, () => {
void (async () => {
if ((await read($, routes)).some(r => isShown(r, Date.now() - 5_000))) await update($, now, () => Date.now())
})().catch(() => {})
Expand Down
18 changes: 17 additions & 1 deletion mods/switchboard/tests/switchboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, test } from 'claude-code/testing'
import { describe, expect, mock, test } from 'claude-code/testing'

import { baseline, move, resetBandOrder, totals, trim } from '../hooks/register'
import { LADDERS, costOf, parsePick, pickRequest, tierOf } from '../hooks/route'
Expand Down Expand Up @@ -224,6 +224,22 @@ describe('switchboard', () => {
expect(spawned).toEqual([undefined])
})

test('a second session.start keeps one redraw timer, not two', async ($, on) => {
const clock = mock.clock(on, { now: Date.now() })
let ticks = 0 // each tick writes the time once while a line shows
on('session.start', (_$: any, e: any) => ({ sessionId: 's', cwd: e.cwd }))
on('command.register', () => ({ value: undefined }))
on('env.get', () => ({ value: undefined }))
on('agent.spawn', (_$: any, e: any) => ({ model: e.parentModel, agentId: 'a1' }))
on('state.set', (_$: any, e: any, next: any) => (e.key === 'now' && ticks++, next(e)))
await $.session.start(START)
await $.session.start(START)
await $.agent.spawn(SPAWN as any) // a running subagent: its line shows
await clock.advance(5_000)
await new Promise(done => (globalThis as any).setTimeout(done, 20)) // the tick runs in the background
expect(ticks).toBe(1)
})

test('keys its band and follows the band order file', { options: { picker: 'openai' } }, async ($, on) => {
// another band mod drew its column beneath
const beneath = {
Expand Down
4 changes: 2 additions & 2 deletions site/mods/glance/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@400;600&family=Atkinson+Hyperlegible+Next:wght@400;600;700&display=swap">
<link rel="stylesheet" href="../../style.css">
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "SoftwareSourceCode", "name": "glance", "description": "One line above the Claude Code prompt with what needs you: the next meeting, PRs, Linear issues and Slack DMs. /glance lists them all.", "url": "https://hamzafer.github.io/claude-code-mods/mods/glance/", "codeRepository": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/glance", "programmingLanguage": "TypeScript", "runtimePlatform": "Claude Code", "version": "0.1.1", "license": "https://opensource.org/licenses/MIT", "author": {"@type": "Person", "name": "hamzafer", "url": "https://github.com/hamzafer"}, "image": "https://hamzafer.github.io/claude-code-mods/images/og.png", "isPartOf": {"@type": "CollectionPage", "name": "Claude Code mods", "url": "https://hamzafer.github.io/claude-code-mods/"}}</script>
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "SoftwareSourceCode", "name": "glance", "description": "One line above the Claude Code prompt with what needs you: the next meeting, PRs, Linear issues and Slack DMs. /glance lists them all.", "url": "https://hamzafer.github.io/claude-code-mods/mods/glance/", "codeRepository": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/glance", "programmingLanguage": "TypeScript", "runtimePlatform": "Claude Code", "version": "0.1.2", "license": "https://opensource.org/licenses/MIT", "author": {"@type": "Person", "name": "hamzafer", "url": "https://github.com/hamzafer"}, "image": "https://hamzafer.github.io/claude-code-mods/images/og.png", "isPartOf": {"@type": "CollectionPage", "name": "Claude Code mods", "url": "https://hamzafer.github.io/claude-code-mods/"}}</script>
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Claude Code mods", "item": "https://hamzafer.github.io/claude-code-mods/"}, {"@type": "ListItem", "position": 2, "name": "glance", "item": "https://hamzafer.github.io/claude-code-mods/mods/glance/"}]}</script>
</head>
<body class="mod c-setup">
Expand All @@ -43,7 +43,7 @@
<header class="mod-head">
<h1>glance</h1>
<p class="lede">One line above the prompt with what needs you: next meeting, PRs, Linear issues and Slack DMs.</p>
<p class="meta"><span class="cat-chip"><span class="sq" aria-hidden="true">■</span> Fork and adapt</span> <span>Version 0.1.1</span></p>
<p class="meta"><span class="cat-chip"><span class="sq" aria-hidden="true">■</span> Fork and adapt</span> <span>Version 0.1.2</span></p>
</header>
<div class="mod-body">
<div class="mod-main">
Expand Down
4 changes: 2 additions & 2 deletions site/mods/heatmap/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@400;600&family=Atkinson+Hyperlegible+Next:wght@400;600;700&display=swap">
<link rel="stylesheet" href="../../style.css">
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "SoftwareSourceCode", "name": "heatmap", "description": "A GitHub-style grid of your Claude Code time in a pane: one square per day of active minutes, with your streak, peak hours and top projects. Run /heatmap.", "url": "https://hamzafer.github.io/claude-code-mods/mods/heatmap/", "codeRepository": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/heatmap", "programmingLanguage": "TypeScript", "runtimePlatform": "Claude Code", "version": "0.1.1", "license": "https://opensource.org/licenses/MIT", "author": {"@type": "Person", "name": "hamzafer", "url": "https://github.com/hamzafer"}, "image": "https://hamzafer.github.io/claude-code-mods/images/gallery/heatmap.png", "isPartOf": {"@type": "CollectionPage", "name": "Claude Code mods", "url": "https://hamzafer.github.io/claude-code-mods/"}}</script>
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "SoftwareSourceCode", "name": "heatmap", "description": "A GitHub-style grid of your Claude Code time in a pane: one square per day of active minutes, with your streak, peak hours and top projects. Run /heatmap.", "url": "https://hamzafer.github.io/claude-code-mods/mods/heatmap/", "codeRepository": "https://github.com/hamzafer/claude-code-mods/tree/main/mods/heatmap", "programmingLanguage": "TypeScript", "runtimePlatform": "Claude Code", "version": "0.1.2", "license": "https://opensource.org/licenses/MIT", "author": {"@type": "Person", "name": "hamzafer", "url": "https://github.com/hamzafer"}, "image": "https://hamzafer.github.io/claude-code-mods/images/gallery/heatmap.png", "isPartOf": {"@type": "CollectionPage", "name": "Claude Code mods", "url": "https://hamzafer.github.io/claude-code-mods/"}}</script>
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Claude Code mods", "item": "https://hamzafer.github.io/claude-code-mods/"}, {"@type": "ListItem", "position": 2, "name": "heatmap", "item": "https://hamzafer.github.io/claude-code-mods/mods/heatmap/"}]}</script>
</head>
<body class="mod c-see">
Expand All @@ -43,7 +43,7 @@
<header class="mod-head">
<h1>heatmap</h1>
<p class="lede">A GitHub-style grid of your Claude Code time, one square per day.</p>
<p class="meta"><span class="cat-chip"><span class="sq" aria-hidden="true">■</span> See what&#x27;s happening</span> <span>Version 0.1.1</span></p>
<p class="meta"><span class="cat-chip"><span class="sq" aria-hidden="true">■</span> See what&#x27;s happening</span> <span>Version 0.1.2</span></p>
</header>
<figure class="shot"><div class="shot-frame"><img style="--w:836px" src="../../images/gallery/heatmap.png" width="836" height="412" alt="heatmap: a year of Claude Code activity as orange squares, with a 25-day streak, peak hours and top projects" decoding="async" fetchpriority="high"></div><figcaption>A year of sample activity, with the streak and top projects.</figcaption></figure>
<div class="mod-body">
Expand Down
Loading
Loading