Claude Clubhouse: a toolbar above the prompt, rooms for usage, agents, commands, tool rules and colors, and whole-window themes on macOS

A plugin for Claude Code that turns the space around your prompt into a place of your own. It adds a toolbar above the prompt and a set of rooms you open from it or with /clubhouse. On a Mac it can also color the whole Claude window.
It is a mod: code that runs inside Claude Code on your own computer. It is not an MCP connector and has no server.
Source and updates: https://github.com/dcglanton-stack/claude-clubhouse
| Where | Works |
|---|---|
| Claude desktop app, Code tab, Local session | Yes |
| Claude Code in the terminal | Yes, without the whole-window color |
| Cloud sessions (claude.ai/code, the phone app, Cloud in the desktop app) | No |
| claude.ai chat and Cowork | No |
Claude Code only draws a mod's toolbar and panes in sessions on your own computer, and a cloud session does not install plugins (What carries over from your setup, Where mods run). Tested with Claude Code 2.1.286 on macOS.
Pick one way. With two, the Clubhouse loads twice, and settings do not carry over between them.
/clubhouse.The directory listing is under review; until it is live, use the next way.
/plugin marketplace add dcglanton-stack/claude-clubhouse
/plugin install clubhouse@claude-clubhouse
/clubhouse.Update: /plugin marketplace update claude-clubhouse
Remove: /plugin uninstall clubhouse@claude-clubhouse, then delete ~/.claude/clubhouse-helper.
The whole-window color and the sketch pad need two small Mac programs, built once on your Mac. Everything else works without them.
/clubhouse build. It takes under a minute.helper/build.sh in it.If the build says Apple's command line tools are missing, run xcode-select --install and try again.
In a terminal in the project's folder, run these two commands, then start a new session there. They write to the project's .claude/settings.json.
claude plugin marketplace add dcglanton-stack/claude-clubhouse --scope project
claude plugin install clubhouse@claude-clubhouse --scope project
| You do | What happens |
|---|---|
Click the house on the bar, or type /clubhouse | Opens the home screen: rooms, your top commands, what is on the bar, usage |
/clubhouse off | Hides everything the Clubhouse adds |
/clubhouse on | Brings it back |
/clubhouse agents | Agent HQ: agents at work, plus your own saved agents |
/clubhouse summary | The last reply cut down to a few bullet points |
/clubhouse opinion | Second opinion: ask Claude here, or an outside model, about the session without adding to it |
/clubhouse tools | Tool rules: make Claude ask first, or block it, per tool; one-click money safeguard |
/clubhouse recipes | Recipes: turn a terminal command into a tool Claude can call |
/clubhouse watch | Night watch: check on things on a timer while you are away |
/clubhouse notes | Session notes: leave a note for a later session |
/clubhouse to-do | To-do: a scratchpad list for this session; /clubhouse to-do call the bank adds a task |
/clubhouse ticker | Ticker: a stock or coin price on the toolbar, search and favorites |
/clubhouse sports | Live sports: games on now and this week; one score on the toolbar |
/clubhouse weather | Weather: the next 7 days where you are, and the weather on the toolbar |
/clubhouse fonts | Fonts: the font of the Clubhouse headings |
/ship | Release what is on main: next version tag, push, GitHub release with notes (asks first) |
/clubhouse commands | Every slash command, led by your most used and most recent |
/clubhouse toolbar | Toolbar: add, remove and move toolbar items, add rows, make your own buttons |
/clubhouse usage | Both limits, context and the cache timer, full size |
/clubhouse colors | The color picker |
/clubhouse color reset | Puts the colors back to their defaults |
/clubhouse build | Builds the helper for whole-window color (same as the Build the helper button in Colors) |
This is the full list of what the mod runs, fetches, writes, reads and sends. The Clubhouse has no server of its own and sends nothing to its author.
| Hook | What it does |
|---|---|
tool.call, every tool | Applies the rule you set for that tool in the Tools room: refuses the call (Blocked) or asks you first (Ask first). Otherwise the call goes on unchanged to Claude Code's own permission check. It never approves a call for you and never changes a call's arguments. |
tool.call, the Skill tool | Refuses a skill you hid in the Commands room. Other calls pass through unchanged. |
tool.call, tools named mcp__clubhouse__... | Stands in for the tool. These are your recipes: terminal commands you typed and saved in the Recipes room. The hook asks Claude Code's permission system first, refuses on deny, asks you on ask, then runs your command and returns its output as the tool's result. It answers no other tool. |
agent.spawn | Asks you before Claude starts a helper agent when your limit is under the spend cap you set. It changes nothing about the agent. |
agent.offer | Hides agents you deleted. |
TaskStop, when you press Stand down on an agent in Agent HQ.permissionMode: 'default' and no hooks, so they ask for permission as usual.| Program | When and why |
|---|---|
~/.claude/clubhouse-helper/window-tint | The window color helper, if you built it. Started through /bin/sh -c when colors are on. Also run with --pixels <file> to read a logo's colors. |
~/.claude/clubhouse-helper/draw-pad | The sketch pad, when you press Draw it. |
/bin/sh helper/build.sh | When you press Build the helper or type /clubhouse build. Compiles the two programs above with swiftc. |
plutil and defaults, through /bin/sh -c | Read whether the Claude app and macOS are in light or dark mode. |
curl | Saves a team's logo picture from a.espncdn.com into the logos folder, because a mod's own fetch returns text only. The picture is only drawn, never run. |
pbcopy | When you press a Copy button. |
/bin/rm -f | Deletes a sketch file after the prompt that used it has been answered. |
git, gh release create | Only when you type /ship, and only after you confirm. |
Your own commands, through /bin/sh -c | A recipe, or a Night watch check command. Both are text you typed. What Claude fills into a recipe is passed as separate arguments, never pasted into the command. |
Nothing fetched from the network is ever run. Fetched data is read as JSON and drawn.
All are GET requests. None carries your conversation, your files or a credential.
| Host | What for | What is sent |
|---|---|---|
api.open-meteo.com, geocoding-api.open-meteo.com | Forecast and place search | The place name you typed, or a latitude and longitude |
ipwho.is | Finds your rough location when you ask the Weather room to | Nothing beyond the request itself |
query1.finance.yahoo.com, query2.finance.yahoo.com | Prices and symbol search | The symbols you chose |
site.api.espn.com, a.espncdn.com | Scores, schedules, team logos | The league and date |
The ticker, scores and weather only fetch while you have them switched on. Yahoo's and ESPN's feeds are unofficial and not guaranteed to keep working.
| Path | What |
|---|---|
~/.claude/clubhouse-helper/tint.json | Your colors, read by the window color helper |
~/.claude/clubhouse-helper/front | The id of the session in use, so one session controls the color |
~/.claude/clubhouse-helper/logos/ | Downloaded logo pictures |
~/.claude/themes/clubhouse.json | A Claude Code theme, only when you press Apply to Claude Code in Colors |
~/.claude/clubhouse-window-tint.js | A style snippet for the app's Developer Mode, only when you press its button in Colors |
theme, when you press Apply to Claude Code. Undo puts back the value it had. It sets no environment variables.| What | Why | Where it goes |
|---|---|---|
HOME, CLAUDE_CONFIG_DIR, CLAUDE_CODE_PLUGIN_DIRS | To find your home folder and your copy of the Clubhouse | Nowhere |
Its own files under ~/.claude/clubhouse-helper/ | Session in use, logo pictures | Nowhere |
plugins/known_marketplaces.json | To find helper/build.sh | Nowhere |
The userThemeMode key of the Claude app's config.json | Light or dark mode | Nowhere |
| Usage and context numbers | To draw the meter | Nowhere |
| The last reply, or an excerpt of the conversation | Summary and Second opinion rooms | A Claude model on your own plan |
| Your draft prompt | Tidy button, Safeguard warning | A Claude model on your own plan |
| A design file you point the Fonts room at | To pick a matching font | A Claude model on your own plan |
It reads no tokens, keys or other credentials (formatTokens in the source formats token counts such as "12k"). Nothing read from a file or the conversation is put in a network request.
| Feature | What goes in the prompt |
|---|---|
| Night watch | Your instruction and, if you set a check command, that command's output marked as data. Sent on the timer you set. |
| Handoff | A fixed prompt asking Claude to write HANDOFF.md, after you press the button. |
| Is this AGI? | That fixed question, when you press the button. |
| Draw it, Gaslighting, Prune, Tidy, your own buttons | Text placed in the prompt box for you to send. Draw it adds the sketch's file path. |
| Session notes | A note you wrote, added as context to a later session's first prompt. |
| "in the clubhouse" | A context line naming your Clubhouse folder, when your prompt contains those words. |
| Safeguard warning (off by default) | Can hold a prompt and ask you before it is sent. It never rewrites a prompt. |
command.run answers /clubhouse and /ship, and counts which commands you use for the Commands room.command.describe and prompt.attachment remove the commands and skills you hid from the slash menu and from what Claude is told.tool.describe asks for your recipes to be listed for Claude.turn.complete and session.measure update the usage meter.| Problem | What to do |
|---|---|
/clubhouse is not a command | Start a new session; plugins load when a session starts. In the desktop app, quit and reopen it. Check the session is Local, not Cloud. |
| The toolbar is missing | Type /clubhouse on. |
| The window color is missing | Type /clubhouse build, then open Colors. The helper writes what it is doing to ~/.claude/clubhouse-helper/tint.log. |
| A price or score stopped updating | Yahoo's and ESPN's feeds are unofficial and sometimes refuse requests. It recovers on its own. |
| Everything shows twice | The Clubhouse is installed two ways. Remove one. |
For anything else, or a security concern, open an issue: https://github.com/dcglanton-stack/claude-clubhouse/issues
Privacy policy: PRIVACY.md
{file}, for what changes; Claude fills it in, and the value reaches the command as one argument, never as shell code. Recipes run in the session's folder, are kept across sessions, follow Tool rules and the session's permission mode, and can be tried from the room.11:46 4Q, Bot 5th, Final, or the start time). The room lists NFL, college football, NBA, MLB, NHL, MLS and Premier League games on now or in the next 7 days. Scores come from ESPN's unofficial feed every half minute while live.HANDOFF.md once your next prompt is finished, so a fresh session can pick up./clubhouse to-do and the task; press the circle beside a task to cross it off, and again to bring it back. Claude does not read the list, and it is gone when the session ends./ship (or /ship minor, /ship major, /ship v1.2.0) only runs from a clean main: it reads the last tag, drafts notes from the commits since, shows them, and after you confirm it tags, pushes and publishes the GitHub release.DESIGN.md (Anthropic's style): serif headings, hairline borders, warm neutrals and one clay accent. The Anthropic palette and three ready-made looks are in Colors.helper/WindowTint.swift, built by helper/build.sh); it notices dark or light mode on its own and stops when the Clubhouse is off or Claude quits.To edit the Clubhouse, work from your own copy instead of an installed one. Clone it:
git clone https://github.com/dcglanton-stack/claude-clubhouse.git ~/claude-clubhouse
Then add these two lines inside "env" in ~/.claude/settings.json, with the path to your clone:
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-clubhouse/clubhouse",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
Tell Claude in any session: "in the clubhouse, ..." and it edits your copy: the Clubhouse tells Claude where the folder is and to keep the change on your computer. CLAUDE.md holds the rules Claude needs, and these two commands check the result:
claude plugin validate clubhouse
claude plugin test clubhouse
Cloning gives you a complete copy on your own computer. Changing it, by hand or by telling Claude "in the clubhouse, ...", changes that copy
hooks/register.tsx 493 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Blueprint, Game, Limit, Prefs, Quote } from '../types'
5import {
6 HELPER_BINARY,
7 HELPER_CONFIG,
8 HELPER_FRONT,
9 READ_APP_MODE,
10 START_HELPER,
11 appModeFrom,
12 coversApp,
13 helperConfig,
14 lookOf,
15} from './lib/appColor'
16import { agents } from './features/agents'
17import { band } from './features/band'
18import { bar } from './features/bar'
19import { colors } from './features/colors'
20import { commands } from './features/commands'
21import { home } from './features/home'
22import { fontsRoom } from './features/fonts'
23import { nightWatch } from './features/watch'
24import { weatherRoom } from './features/weather'
25import { sportsRoom } from './features/sports'
26import { tickerRoom } from './features/ticker'
27import { notesRoom } from './features/notes'
28import { todoRoom } from './features/todo'
29import { opinionRoom } from './features/opinion'
30import { recipesRoom } from './features/recipes'
31import { summaryRoom } from './features/summary'
32import { tint } from './features/tint'
33import { tools } from './features/tools'
34import { usage } from './features/usage'
35import { usageRoom } from './features/usageRoom'
36import {
37 AGENTS_KEY,
38 COLOR_PRESETS_KEY,
39 COMMANDS_KEY,
40 DEFAULT_HIDDEN_PLAN,
41 DEFAULT_PREFS,
42 HIDDEN_KEY,
43 HIDDEN_PLAN_KEY,
44 LIMITS_KEY,
45 PREFS_KEY,
46 PREFS_SHAPE,
47 TOOL_RULES_KEY,
48 asBlueprints,
49 asColorPresets,
50 asCommandStats,
51 asHiddenPlan,
52 asNames,
53 asToolRules,
54 asToolbarPresets,
55 isLimitList,
56 mergePrefs,
57 sameSettings,
58} from './lib/defaults'
59import { FONT_PRESETS_KEY, asFontPresets } from './lib/fonts'
60import { percentLeft } from './lib/format'
61import {
62 DEFAULT_SPORTS,
63 LOGO_FOLDER,
64 SPORTS_KEY,
65 SPORTS_POLL_MS,
66 asSportsPlan,
67 dayOf,
68 gamesFrom,
69 isWorthChecking,
70 leagueOf,
71 logoKey,
72 logoSlot,
73 logoUrl,
74 scoreboardUrl,
75 withLogo,
76} from './lib/sports'
77import { pixelsFrom, pngOf, toBase64 } from './lib/png'
78import { PROJECT_COLORS_KEY, asProjectColors, inProject } from './lib/project'
79import { front } from './features/front'
80import { redrawn } from './lib/tone'
81import type { Tone } from './lib/tone'
82import {
83 DEFAULT_TICKER,
84 FEED_HEADERS,
85 QUOTES_KEY,
86 TICKER_KEY,
87 TICKER_POLL_MS,
88 asQuotes,
89 asTickerPlan,
90 isFresh,
91 quoteFrom,
92 quoteUrl,
93 watched,
94} from './lib/ticker'
95import { TOOLBAR_PRESETS_KEY } from './lib/toolbar'
96import { DEFAULT_WEATHER, WEATHER_KEY, WEATHER_POLL_MS, asWeatherPlan, forecastFrom, forecastUrl } from './lib/weather'
97import { NOTES_KEY, asNotes, claim } from './lib/notes'
98import { RECIPES_KEY, asRecipes, toolSpecOf } from './lib/recipes'
99import {
100 SAVED_WATCHES_KEY,
101 WATCH_COMMAND_MS,
102 WATCH_POLL_MS,
103 asSavedWatches,
104 claimed,
105 dueWatches,
106 settled,
107 stepOf,
108 withEntry,
109} from './lib/watch'
110
111const contextPercent = atom({ plugin: 'clubhouse', key: 'contextPercent' } as const, null)
112const contextSize = atom({ plugin: 'clubhouse', key: 'contextSize' } as const, null)
113const limits = atom({ plugin: 'clubhouse', key: 'limits' } as const, [])
114const now = atom({ plugin: 'clubhouse', key: 'now' } as const, 0)
115const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
116 shape: PREFS_SHAPE,
117})
118const commandStats = atom({ plugin: 'clubhouse', key: 'commandStats' } as const, {})
119const agentBank = atom({ plugin: 'clubhouse', key: 'agentBank' } as const, [])
120const hiddenCommands = atom({ plugin: 'clubhouse', key: 'hiddenCommands' } as const, [])
121const hiddenPlan = atom({ plugin: 'clubhouse', key: 'hiddenPlan' } as const, DEFAULT_HIDDEN_PLAN)
122const toolRules = atom({ plugin: 'clubhouse', key: 'toolRules' } as const, {})
123const recipes = atom({ plugin: 'clubhouse', key: 'recipes' } as const, [])
124const lastReplyAt = atom({ plugin: 'clubhouse', key: 'lastReplyAt' } as const, null)
125const watches = atom({ plugin: 'clubhouse', key: 'watches' } as const, [])
126const watchLog = atom({ plugin: 'clubhouse', key: 'watchLog' } as const, [])
127const savedWatches = atom({ plugin: 'clubhouse', key: 'savedWatches' } as const, [])
128const notes = atom({ plugin: 'clubhouse', key: 'notes' } as const, [])
129const colorPresets = atom({ plugin: 'clubhouse', key: 'colorPresets' } as const, [])
130const fontPresets = atom({ plugin: 'clubhouse', key: 'fontPresets' } as const, [])
131const toolbarPresets = atom({ plugin: 'clubhouse', key: 'toolbarPresets' } as const, [])
132const ticker = atom({ plugin: 'clubhouse', key: 'ticker' } as const, DEFAULT_TICKER)
133const quotes = atom({ plugin: 'clubhouse', key: 'quotes' } as const, {})
134const sports = atom({ plugin: 'clubhouse', key: 'sports' } as const, DEFAULT_SPORTS)
135const liveGame = atom({ plugin: 'clubhouse', key: 'liveGame' } as const, null)
136const logos = atom({ plugin: 'clubhouse', key: 'logos' } as const, {})
137const sportsCheckedAt = atom({ plugin: 'clubhouse', key: 'sportsCheckedAt' } as const, 0)
138const weather = atom({ plugin: 'clubhouse', key: 'weather' } as const, DEFAULT_WEATHER)
139const forecast = atom({ plugin: 'clubhouse', key: 'forecast' } as const, null)
140const receivedNotes = atom({ plugin: 'clubhouse', key: 'receivedNotes' } as const, [])
141const pendingNotes = atom({ plugin: 'clubhouse', key: 'pendingNotes' } as const, [])
142const sessionFolder = atom({ plugin: 'clubhouse', key: 'sessionFolder' } as const, '')
143const hasBooted = atom({ plugin: 'clubhouse', key: 'hasBooted' } as const, false)
144const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
145const projectRoot = atom({ plugin: 'clubhouse', key: 'projectRoot' } as const, '')
146
147const TICK_MS = 30_000
148const PIXELS_TIMEOUT_MS = 5_000
149
150async function appModeNow($: EngineInterface): Promise<Prefs['appMode'] | null> {
151 const ran = await $.process.run(['/bin/sh', '-c', READ_APP_MODE]).catch(() => null)
152
153 return ran === null ? null : appModeFrom(ran.stdout)
154}
155
156async function tick($: EngineInterface): Promise<void> {
157 const at = await $.clock.now()
158 await update($, now, () => at)
159 const mode = await appModeNow($)
160 const held = await read($, prefs)
161
162 if (coversApp(held)) {
163 await $.process.run(['/bin/sh', '-c', START_HELPER]).catch(() => undefined)
164 }
165
166 const own = asProjectColors(await $.store.get(PROJECT_COLORS_KEY))[await read($, projectRoot)]
167 const wanted = inProject(mergePrefs(await $.store.get(PREFS_KEY)), own)
168 const isChangedElsewhere = !sameSettings(wanted.prefs, held)
169 await update($, sharedPalette, () => wanted.shared)
170
171 if (!isChangedElsewhere && (mode === null || mode === held.appMode)) return
172 const turned = {
173 ...(isChangedElsewhere ? wanted.prefs : held),
174 isHelperReady: held.isHelperReady,
175 appMode: mode ?? held.appMode,
176 }
177 await update($, prefs, () => turned)
178 const userFolder = await $.env.get('HOME')
179
180 if (userFolder === undefined || !(await isInFront($, userFolder))) return
181 await $.fs.write(`${userFolder}/${HELPER_CONFIG}`, helperConfig(turned)).catch(() => undefined)
182}
183
184async function isInFront($: EngineInterface, userFolder: string): Promise<boolean> {
185 const holder = await $.fs.read(`${userFolder}/${HELPER_FRONT}`).then(
186 text => text.trim(),
187 () => '',
188 )
189
190 return holder === '' || holder === (await $.session.id().catch(() => ''))
191}
192
193async function redrawnLogo($: EngineInterface, helper: string, path: string, tone: Tone): Promise<string | null> {
194 const ran = await $.process.run([helper, '--pixels', path], { timeoutMs: PIXELS_TIMEOUT_MS }).catch(() => null)
195 const pixels = ran !== null && ran.exitCode === 0 ? pixelsFrom(ran.stdout) : null
196
197 return pixels === null ? null : toBase64(pngOf(redrawn(tone, pixels)))
198}
199
200async function loadLogos($: EngineInterface, game: Game): Promise<void> {
201 const userFolder = await $.env.get('HOME')
202 const held: { [key: string]: string } = await read($, logos)
203
204 if (userFolder === undefined) return
205 const { tone } = lookOf(await read($, prefs), 'desktop')
206
207 for (const team of [game.home, game.away]) {
208 const key = logoKey(game.league, team)
209 const slot = logoSlot(key, tone)
210
211 if (team.logo === null || held[slot] !== undefined) continue
212 const path = `${userFolder}/${LOGO_FOLDER}/${key}.png`
213
214 if (!(await $.fs.exists(path).catch(() => false))) {
215 await $.process
216 .run(['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && curl -s -m 10 -o "$1" "$2"', 'sh', path, logoUrl(team.logo)])
217 .catch(() => undefined)
218 }
219
220 const drawn = tone === null ? null : await redrawnLogo($, `${userFolder}/${HELPER_BINARY}`, path, tone)
221 const picture = drawn ?? (await $.fs.read(path, { as: 'bytes' }).catch(() => null))?.base64 ?? ''
222
223 if (picture.length > 0) {
224 await update($, logos, known => withLogo(known, key, slot, picture))
225 }
226 }
227}
228
229async function scoreCheck($: EngineInterface): Promise<void> {
230 const held = await read($, prefs)
231 const plan = await read($, sports)
232
233 if (!held.isEnabled || plan.gameId === null || held.bar.sports?.isShown !== true) return
234 const at = await $.clock.now()
235 const shown: Game | null = await read($, liveGame)
236 const current = shown !== null && shown.id === plan.gameId ? shown : null
237
238 if (current !== null) await loadLogos($, current)
239 if (!isWorthChecking(current, at, await read($, sportsCheckedAt))) return
240 await update($, sportsCheckedAt, () => at)
241 const league = leagueOf(plan.gameLeague)
242 const days = [undefined, ...(plan.gameDay === null ? [] : [plan.gameDay, dayOf(Date.parse(`${plan.gameDay.slice(0, 4)}-${plan.gameDay.slice(4, 6)}-${plan.gameDay.slice(6)}`) - 86_400_000)])]
243
244 for (const day of days) {
245 const page = await $.http.fetch(scoreboardUrl(league, day), { headers: FEED_HEADERS }).catch(() => null)
246 const found = page !== null && page.ok ? gamesFrom(page.text, league).find(game => game.id === plan.gameId) : undefined
247
248 if (found !== undefined) {
249 await update($, liveGame, () => found)
250 await loadLogos($, found)
251
252 return
253 }
254 }
255}
256
257async function skyCheck($: EngineInterface): Promise<void> {
258 const held = await read($, prefs)
259 const plan = await read($, weather)
260 const isRoomOpen = (await $.ui.panes().catch(() => [])).some(pane => pane.id === 'clubhouse-weather')
261
262 if (!held.isEnabled || plan.place === null || !(held.bar.weather?.isShown === true || isRoomOpen)) return
263 const page = await $.http.fetch(forecastUrl(plan)).catch(() => null)
264 const found = page !== null && page.ok ? forecastFrom(page.text, await $.clock.now()) : null
265
266 if (found !== null) {
267 await update($, forecast, () => found)
268 }
269}
270
271async function priceCheck($: EngineInterface): Promise<void> {
272 const held = await read($, prefs)
273 const isRoomOpen = (await $.ui.panes().catch(() => [])).some(pane => pane.id === 'clubhouse-ticker')
274
275 if (!held.isEnabled || !(held.bar.ticker?.isShown === true || isRoomOpen)) return
276 const at = await $.clock.now()
277 const plan = await read($, ticker)
278 const symbols = isRoomOpen ? watched(plan) : plan.symbol === null ? [] : [plan.symbol]
279 const shared = asQuotes(await $.store.get(QUOTES_KEY).catch(() => null), at)
280 const fetched: { [symbol: string]: Quote } = {}
281
282 for (const symbol of symbols) {
283 const kept = shared[symbol]
284
285 if (isFresh(kept, at)) {
286 await update($, quotes, known => ({ ...known, [symbol]: kept }))
287 continue
288 }
289
290 const page = await $.http.fetch(quoteUrl(symbol), { headers: FEED_HEADERS }).catch(() => null)
291 const quote = page !== null && page.ok ? quoteFrom(page.text, at) : null
292
293 if (quote !== null) {
294 fetched[symbol] = quote
295 await update($, quotes, known => ({ ...known, [symbol]: quote }))
296 }
297 }
298
299 if (Object.keys(fetched).length > 0) {
300 await $.store.set(QUOTES_KEY, { ...shared, ...fetched }).catch(() => undefined)
301 }
302}
303
304async function patrol($: EngineInterface): Promise<void> {
305 const at = await $.clock.now()
306 const due = dueWatches(await read($, watches), at)
307
308 if (due.length === 0) return
309 await update($, watches, held => claimed(held, due, at))
310 const fiveHour = (await read($, limits)).find(one => one.kind === 'five_hour')
311 const facts = {
312 at,
313 percentLeft: fiveHour === undefined ? null : percentLeft(fiveHour, at),
314 lastReplyAt: await read($, lastReplyAt),
315 }
316
317 for (const watch of due) {
318 const ran =
319 watch.command === ''
320 ? null
321 : await $.process
322 .run(['/bin/sh', '-c', watch.command], { timeoutMs: WATCH_COMMAND_MS })
323 .catch(() => null)
324 const step = stepOf(watch, { ...facts, ran })
325 await update($, watches, held => settled(held, watch.id, step))
326 await update($, watchLog, held => withEntry(held, { at, text: step.entry }))
327
328 if (step.toast !== null) $.ui.toast(step.toast)
329 if (step.prompt !== null) void $.prompt.submit({ text: step.prompt }).catch(() => undefined)
330 }
331}
332
333export const register: Register = on => {
334 on('session.start', async ($, e, next) => {
335 const userFolder = await $.env.get('HOME')
336 const isHelperReady =
337 userFolder !== undefined && (await $.fs.exists(`${userFolder}/${HELPER_BINARY}`).catch(() => false))
338 const merged = mergePrefs(await $.store.get(PREFS_KEY))
339 const root = (await $.session.repo().catch(() => null))?.root ?? e.cwd
340 const mine = inProject(merged, asProjectColors(await $.store.get(PROJECT_COLORS_KEY))[root])
341 const saved = { ...mine.prefs, isHelperReady, appMode: (await appModeNow($)) ?? merged.appMode }
342 const isStarting = !(await read($, hasBooted))
343 await update($, prefs, () => saved)
344 await update($, sharedPalette, () => mine.shared)
345 await update($, projectRoot, () => root)
346
347 if (userFolder !== undefined) {
348 const id = await $.session.id().catch(() => '')
349
350 if (isStarting && id !== '') {
351 await $.fs.write(`${userFolder}/${HELPER_FRONT}`, id).catch(() => undefined)
352 }
353
354 if (isStarting || (await isInFront($, userFolder))) {
355 await $.fs.write(`${userFolder}/${HELPER_CONFIG}`, helperConfig(saved)).catch(() => undefined)
356 }
357
358 if (coversApp(saved)) {
359 await $.process.run(['/bin/sh', '-c', START_HELPER]).catch(() => undefined)
360 }
361 }
362
363 const savedCommands = asCommandStats(await $.store.get(COMMANDS_KEY))
364 await update($, commandStats, () => savedCommands)
365
366 const savedPlan = asHiddenPlan(await $.store.get(HIDDEN_PLAN_KEY))
367 const startPreset = savedPlan.startWith === null ? undefined : savedPlan.presets[savedPlan.startWith]
368 const savedHidden = startPreset ?? asNames(await $.store.get(HIDDEN_KEY))
369 await update($, hiddenPlan, () => savedPlan)
370 await update($, hiddenCommands, () => savedHidden)
371
372 const savedRules = asToolRules(await $.store.get(TOOL_RULES_KEY))
373 await update($, toolRules, () => savedRules)
374
375 const savedRecipes = asRecipes(await $.store.get(RECIPES_KEY))
376 await update($, recipes, () => savedRecipes)
377
378 for (const one of savedRecipes) {
379 await $.tool.register(toolSpecOf(one)).catch(() => undefined)
380 }
381
382 const keptFonts = asFontPresets(await $.store.get(FONT_PRESETS_KEY))
383 await update($, fontPresets, () => keptFonts)
384 const keptPresets = asColorPresets(await $.store.get(COLOR_PRESETS_KEY))
385 const keptWeather = asWeatherPlan(await $.store.get(WEATHER_KEY))
386 await update($, weather, () => keptWeather)
387 const keptSports = asSportsPlan(await $.store.get(SPORTS_KEY))
388 await update($, sports, () => keptSports)
389 const keptTicker = asTickerPlan(await $.store.get(TICKER_KEY))
390 await update($, ticker, () => keptTicker)
391 const keptLayouts = asToolbarPresets(await $.store.get(TOOLBAR_PRESETS_KEY))
392 await update($, toolbarPresets, () => keptLayouts)
393 await update($, colorPresets, () => keptPresets)
394 await update($, sessionFolder, () => e.cwd)
395 const keptNotes = asNotes(await $.store.get(NOTES_KEY))
396 const isNewSession = !(await read($, hasBooted))
397 const { taken, left } = isNewSession ? claim(keptNotes, e.cwd) : { taken: [], left: keptNotes }
398 await update($, hasBooted, () => true)
399 await update($, notes, () => left)
400
401 if (taken.length > 0) {
402 await $.store.set(NOTES_KEY, left)
403 await update($, pendingNotes, () => taken)
404 await update($, receivedNotes, () => taken)
405 $.ui.toast(
406 `${taken.length === 1 ? 'A session note is' : `${taken.length} session notes are`} waiting: Claude reads ${taken.length === 1 ? 'it' : 'them'} with your first message.`,
407 { timeoutMs: 8000 },
408 )
409 }
410
411 const keptWatches = asSavedWatches(await $.store.get(SAVED_WATCHES_KEY))
412 await update($, savedWatches, () => keptWatches)
413
414 const savedAgents: Blueprint[] = asBlueprints(await $.store.get(AGENTS_KEY))
415 await update($, agentBank, () => savedAgents)
416
417 for (const one of savedAgents) {
418 await $.agent
419 .register({
420 name: one.name,
421 description: one.purpose,
422 prompt: one.prompt,
423 model: one.model,
424 permissionMode: 'default',
425 })
426 .catch(() => undefined)
427 }
428
429 const measured = await $.session.usage()
430 const savedLimits = await $.store.get(LIMITS_KEY)
431 const live: Limit[] = measured.rateLimits.map(one => ({
432 kind: one.kind,
433 percentUsed: one.percentUsed,
434 resetsAt: one.resetsAt ?? null,
435 }))
436 const known = live.length > 0 ? live : isLimitList(savedLimits) ? savedLimits : []
437 await update($, limits, () => known)
438 await update($, contextPercent, () => measured.context.percent ?? null)
439 const { tokens, window } = measured.context
440 await update($, contextSize, () => (tokens === undefined || window === undefined ? null : { tokens, window }))
441
442 const startedAt = await $.clock.now()
443 await update($, now, () => startedAt)
444 $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
445 $.clock.every(WATCH_POLL_MS, () => void patrol($).catch(() => undefined))
446 $.clock.every(TICKER_POLL_MS, () => void priceCheck($).catch(() => undefined))
447 void priceCheck($).catch(() => undefined)
448 $.clock.every(SPORTS_POLL_MS, () => void scoreCheck($).catch(() => undefined))
449 void scoreCheck($).catch(() => undefined)
450 $.clock.every(WEATHER_POLL_MS, () => void skyCheck($).catch(() => undefined))
451 void skyCheck($).catch(() => undefined)
452
453 await $.command.register({
454 name: 'clubhouse',
455 description: 'Open Claude Clubhouse. Add a room to open it: agents, summary, opinion, tools, recipes, watch, notes, to-do, ticker, sports, weather, commands, toolbar, usage, colors, fonts; or on, off, color reset',
456 argumentHint: '[on|off|agents|summary|opinion|tools|recipes|watch|notes|to-do|ticker|sports|weather|commands|toolbar|usage|colors|fonts|color reset]',
457 immediate: true,
458 })
459 await $.command
460 .register({
461 name: 'ship',
462 description: 'Release what is on main: tag the next version, push, and publish a GitHub release with notes. Asks before it does anything.',
463 argumentHint: '[patch|minor|major|v1.2.3]',
464 immediate: true,
465 })
466 .catch(() => undefined)
467
468 return next(e)
469 })
470
471 usage(on)
472 band(on)
473 home(on)
474 bar(on)
475 summaryRoom(on)
476 opinionRoom(on)
477 tools(on)
478 recipesRoom(on)
479 nightWatch(on)
480 notesRoom(on)
481 todoRoom(on)
482 tickerRoom(on)
483 sportsRoom(on)
484 fontsRoom(on)
485 weatherRoom(on)
486 usageRoom(on)
487 commands(on)
488 agents(on)
489 colors(on)
490 tint(on)
491 front(on)
492}
493hooks/lib/appColor.ts 144 lines1import type { Prefs } from '../../types'
2import { fromHsl, inkOn, isLight, luminance, mix, standOut, toHsl } from './color'
3import { HELPER_BOOST, clearOn, drawnFor, helperStages, toneFor } from './tone'
4import type { Tone, ToneSpec } from './tone'
5
6export type Look = {
7 tone: Tone | null
8 background: string | null
9 ink: string
10 accent: string
11 clawd: string
12 track: string | null
13 edge: string | null
14 hairline: string
15}
16
17const HELPER_FOLDER = '.claude/clubhouse-helper'
18const APP_CONFIG = 'Library/Application Support/Claude/config.json'
19const APP_SURFACE = { dark: '#151515', light: '#f0eee6' } as const
20const CORNER_RADIUS = 18
21const HAIRLINE = '#87867f'
22const EDGE_CONTRAST = 4.5
23const HAIRLINE_CONTRAST = 2.5
24const TRACK_CONTRAST = 1.25
25const SIDEBAR_SHADE = { lighterUnderDarkText: 0.3, deeperUnderLightText: 0.55 } as const
26const SIDEBAR_TURN = 14
27const WARM_HUES = { until: 75, from: 300 } as const
28const HEADING_CONTRAST = 3
29const MASCOT_CONTRAST = 1.5
30
31export const HELPER_CONFIG = `${HELPER_FOLDER}/tint.json`
32export const HELPER_BINARY = `${HELPER_FOLDER}/window-tint`
33export const HELPER_FRONT = `${HELPER_FOLDER}/front`
34export const START_HELPER = `[ -x "$HOME/${HELPER_BINARY}" ] && (nohup "$HOME/${HELPER_BINARY}" >/dev/null 2>&1 &)`
35export const READ_APP_MODE =
36 `printf 'theme=%s\\n' "$(plutil -extract userThemeMode raw -o - "$HOME/${APP_CONFIG}" 2>/dev/null)"; ` +
37 `printf 'system=%s\\n' "$(defaults read -g AppleInterfaceStyle 2>/dev/null)"`
38
39export function appModeFrom(output: string): Prefs['appMode'] | null {
40 const theme = output.match(/^theme=(.*)$/m)?.[1]?.trim()
41 const system = output.match(/^system=(.*)$/m)?.[1]?.trim()
42
43 if (theme === undefined || system === undefined || (theme === '' && system === '')) return null
44 if (theme === 'light' || theme === 'dark') return theme
45
46 return system === 'Dark' ? 'dark' : 'light'
47}
48
49export function backdropOf(prefs: Prefs): string {
50 return prefs.palette.background ?? APP_SURFACE[prefs.appMode]
51}
52
53export function inkOf(prefs: Prefs): string {
54 return prefs.palette.text ?? inkOn(backdropOf(prefs))
55}
56
57export function coversApp(prefs: Prefs): boolean {
58 const { background, text } = prefs.palette
59 const hasOwnColor = background !== null || (text ?? null) !== null
60
61 return prefs.isEnabled && prefs.isHelperReady && prefs.reach === 'app' && hasOwnColor
62}
63
64export function swapsLightAndDark(prefs: Prefs): boolean {
65 const isInkDarker = luminance(inkOf(prefs)) < luminance(backdropOf(prefs))
66
67 return coversApp(prefs) && isInkDarker === (prefs.appMode === 'dark')
68}
69
70export function paintOf(prefs: Prefs): string | null {
71 return coversApp(prefs) ? null : prefs.palette.background
72}
73
74export function tintsRows(prefs: Prefs): boolean {
75 return (
76 prefs.isEnabled &&
77 prefs.reach !== 'rooms' &&
78 prefs.palette.background !== null &&
79 !coversApp(prefs)
80 )
81}
82
83export function lookOf(prefs: Prefs, surface: string): Look {
84 const backdrop = backdropOf(prefs)
85 const ink = inkOf(prefs)
86 const { accent, clawd } = prefs.palette
87
88 if (!coversApp(prefs) || surface === 'terminal') {
89 return {
90 tone: null,
91 background: paintOf(prefs),
92 ink,
93 accent: standOut(accent, backdrop, ink, HEADING_CONTRAST),
94 clawd: standOut(clawd, backdrop, ink, MASCOT_CONTRAST),
95 track: null,
96 edge: null,
97 hairline: HAIRLINE,
98 }
99 }
100
101 const tone = toneFor({ target: backdrop, ink, isLightApp: prefs.appMode === 'light' })
102
103 return {
104 tone,
105 background: null,
106 ink,
107 accent: clearOn(tone, accent, HEADING_CONTRAST),
108 clawd: clearOn(tone, clawd, MASCOT_CONTRAST),
109 track: clearOn(tone, backdrop, TRACK_CONTRAST),
110 edge: drawnFor(tone, clearOn(tone, backdrop, EDGE_CONTRAST)),
111 hairline: drawnFor(tone, clearOn(tone, backdrop, HAIRLINE_CONTRAST)),
112 }
113}
114
115export function sidebarOf(spec: ToneSpec): string {
116 if (luminance(spec.ink) <= luminance(spec.target)) {
117 return mix(spec.target, '#ffffff', SIDEBAR_SHADE.lighterUnderDarkText)
118 }
119
120 const { hue, saturation, lightness } = toHsl(mix(spec.target, '#000000', SIDEBAR_SHADE.deeperUnderLightText))
121 const isWarm = hue < WARM_HUES.until || hue >= WARM_HUES.from
122
123 return fromHsl({ hue: hue + (isWarm ? -SIDEBAR_TURN : SIDEBAR_TURN), saturation, lightness })
124}
125
126export function helperConfig(prefs: Prefs): string {
127 const target = backdropOf(prefs)
128 const ink = inkOf(prefs)
129 const isLightApp = prefs.appMode === 'light'
130 const sidebar = sidebarOf({ target, ink, isLightApp })
131
132 return `${JSON.stringify({
133 enabled: coversApp(prefs),
134 target,
135 ink,
136 radius: CORNER_RADIUS,
137 isLightApp,
138 boost: HELPER_BOOST,
139 coverSidebar: prefs.coversSidebar === true,
140 sidebar,
141 ...helperStages({ target, ink, isLightApp }, sidebar),
142 })}\n`
143}
144hooks/features/agents.tsx 498 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { AgentModel, Blueprint } from '../../types'
5import { agentStatus, agentSvg } from '../lib/clawd'
6import {
7 AGENTS_KEY,
8 AGENT_MODELS,
9 AGENT_PREFIX,
10 DEFAULT_AGENT_DESK,
11 DEFAULT_PREFS,
12 MODEL_LABEL,
13 PREFS_SHAPE,
14 agentSlug,
15} from '../lib/defaults'
16import {
17 DO_NOT_START,
18 START_ALL,
19 START_ONE,
20 capQuestion,
21 capRefusal,
22 fiveHourLeft,
23 isCapped,
24 liftUntil,
25} from '../lib/cap'
26import { makeParts } from '../lib/parts'
27
28const agentBank = atom({ plugin: 'clubhouse', key: 'agentBank' } as const, [])
29const agentDesk = atom({ plugin: 'clubhouse', key: 'agentDesk' } as const, DEFAULT_AGENT_DESK)
30const now = atom({ plugin: 'clubhouse', key: 'now' } as const, 0)
31const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
32 shape: PREFS_SHAPE,
33})
34const pulse = atom({ plugin: 'clubhouse', key: 'pulse' } as const, 0)
35const limits = atom({ plugin: 'clubhouse', key: 'limits' } as const, [])
36const capLiftedUntil = atom({ plugin: 'clubhouse', key: 'capLiftedUntil' } as const, null)
37
38const ROW_UNIT = 2
39const HEAD_UNIT = 3
40const ROW_WIDTH = Math.ceil(17.4 * ROW_UNIT)
41const ROW_HEIGHT = Math.ceil(12 * ROW_UNIT)
42const HEAD_WIDTH = Math.ceil(17.4 * HEAD_UNIT)
43const HEAD_HEIGHT = Math.ceil(12 * HEAD_UNIT)
44const PAST_SHOWN = 6
45const AUTO = 'auto'
46
47const STATUS_WORD: Record<string, string> = {
48 running: 'on assignment',
49 completed: 'mission complete',
50 failed: 'mission failed',
51 killed: 'stood down',
52}
53
54const EFFORT_LABELS = [
55 'quick lookup or formatting',
56 'everyday coding or research',
57 'deep reasoning or careful review',
58] as const
59
60const EFFORT_MODEL: Record<string, AgentModel> = {
61 'quick lookup or formatting': 'haiku',
62 'everyday coding or research': 'sonnet',
63 'deep reasoning or careful review': 'opus',
64}
65
66type Draft = { name: string; purpose: string; prompt: string; model: string }
67
68const BLANK_DRAFT: Draft = { name: '', purpose: '', prompt: '', model: AUTO }
69
70let draft: Draft = BLANK_DRAFT
71let task = ''
72let ownDispatches = 0
73
74function reason(error: unknown): string {
75 return error instanceof Error ? error.message : 'unknown error'
76}
77
78const AFTER_SPAWN_MS = 1500
79
80async function tell($: EngineInterface, note: string): Promise<void> {
81 await update($, agentDesk, desk => ({ ...desk, note }))
82 await update($, pulse, beat => beat + 1)
83}
84
85async function saveBank($: EngineInterface, change: (bank: Blueprint[]) => Blueprint[]): Promise<void> {
86 await update($, agentBank, change)
87 await $.store.set(AGENTS_KEY, await read($, agentBank))
88}
89
90async function saveDraft($: EngineInterface): Promise<void> {
91 const name = agentSlug(draft.name)
92 const purpose = draft.purpose.trim()
93 const prompt = draft.prompt.trim()
94
95 if (name === '' || purpose === '' || prompt === '') {
96 await tell($, 'An agent needs a name, a purpose and instructions. Press Enter in each box to set it.')
97
98 return
99 }
100
101 try {
102 const isAuto = draft.model === AUTO
103 const picked = isAuto
104 ? EFFORT_MODEL[(await $.model.classify(`${purpose}\n${prompt}`, EFFORT_LABELS)) ?? '']
105 : AGENT_MODELS.find(model => model === draft.model)
106 const made: Blueprint = { name, purpose, prompt, model: picked ?? 'sonnet', isAuto }
107
108 await $.agent.register({
109 name: made.name,
110 description: made.purpose,
111 prompt: made.prompt,
112 model: made.model,
113 permissionMode: 'default',
114 })
115 await saveBank($, bank => [...bank.filter(one => one.name !== name), made])
116 draft = BLANK_DRAFT
117 await update($, agentDesk, desk => ({ ...desk, mode: 'idle', target: null }))
118 await tell(
119 $,
120 `Saved ${name} on ${MODEL_LABEL[made.model]}${isAuto ? ' (picked for you)' : ''}. Claude can now send it out, or press Send.`,
121 )
122 } catch (error) {
123 await tell($, `Could not save the agent: ${reason(error)}`)
124 }
125}
126
127async function nextModel($: EngineInterface, name: string): Promise<void> {
128 try {
129 const held = (await read($, agentBank)).find(one => one.name === name)
130
131 if (held === undefined) return
132 const model = AGENT_MODELS[(AGENT_MODELS.indexOf(held.model) + 1) % AGENT_MODELS.length] ?? 'sonnet'
133 const made: Blueprint = { ...held, model, isAuto: false }
134
135 await $.agent.register({
136 name: made.name,
137 description: made.purpose,
138 prompt: made.prompt,
139 model: made.model,
140 permissionMode: 'default',
141 })
142 await saveBank($, bank => bank.map(one => (one.name === name ? made : one)))
143 await tell($, `${name} now runs on ${MODEL_LABEL[model]}.`)
144 } catch (error) {
145 await tell($, `Could not change the model: ${reason(error)}`)
146 }
147}
148
149async function removeBlueprint($: EngineInterface, name: string): Promise<void> {
150 await saveBank($, bank => bank.filter(one => one.name !== name))
151 await tell($, `${name} was removed from your agents. Claude can no longer send it out.`)
152}
153
154async function dispatch($: EngineInterface, name: string): Promise<void> {
155 const orders = task.trim()
156 const held = (await read($, agentBank)).find(one => one.name === name)
157
158 if (held === undefined || orders === '') {
159 await tell($, 'Type the task and press Enter, then press Dispatch.')
160
161 return
162 }
163
164 try {
165 ownDispatches += 1
166 const send = () =>
167 $.agent.spawn({
168 subagentType: `${AGENT_PREFIX}${name}`,
169 prompt: orders,
170 description: `${name}: ${orders.slice(0, 48)}`,
171 })
172 const started = await send()
173 .then(
174 first => (held.model === 'fable' && first.deny !== undefined ? null : first),
175 error => {
176 if (held.model === 'fable') return null
177 throw error
178 },
179 )
180 .then(async first => {
181 if (first !== null) return first
182 await $.agent.register({
183 name: held.name,
184 description: held.purpose,
185 prompt: held.prompt,
186 model: 'opus',
187 permissionMode: 'default',
188 })
189 await tell($, `Fable was not available, so ${name} runs on Opus this time.`)
190
191 return send()
192 })
193 .finally(() => {
194 ownDispatches -= 1
195 })
196
197 if (started.deny !== undefined) {
198 await tell($, `${name} was not sent: ${started.deny}`)
199
200 return
201 }
202
203 task = ''
204 await update($, agentDesk, desk => ({ ...desk, mode: 'idle', target: null }))
205 const isTold = await $.session
206 .append({
207 message: {
208 type: 'user',
209 content: [
210 {
211 type: 'text',
212 text:
213 `Claude Clubhouse notice: the user dispatched the agent "${AGENT_PREFIX}${name}" from Agent HQ. ` +
214 `It runs in the background on ${MODEL_LABEL[held.model]}. Purpose: ${held.purpose} ` +
215 `Its instructions: ${held.prompt} Its task: ${orders} ` +
216 'Do not start a duplicate of this work; its result arrives when it finishes.',
217 },
218 ],
219 },
220 })
221 .then(
222 () => true,
223 () => false,
224 )
225 await tell(
226 $,
227 isTold
228 ? `${name} is on assignment, and Claude has been told.`
229 : `${name} is on assignment. Claude could not be told automatically, so mention it yourself.`,
230 )
231 } catch (error) {
232 await tell($, `Something went wrong sending ${name}: ${reason(error)}`)
233 }
234}
235
236async function standDown($: EngineInterface, id: string, label: string): Promise<void> {
237 try {
238 const stopped = await $.tool.call({ tool: 'TaskStop', task_id: id })
239 const refusal = stopped.deny ?? (stopped.isError === true ? 'the engine could not stop it' : null)
240 await tell(
241 $,
242 refusal === null ? `${label} was told to stand down.` : `Could not stop ${label}: ${refusal}`,
243 )
244 } catch (error) {
245 await tell($, `Could not stop ${label}: ${reason(error)}`)
246 }
247}
248
249export function agents(on: On): void {
250 on('agent.spawn', async ($, e, next) => {
251 const at = await $.clock.now()
252 const list = await read($, limits)
253 const cap = (await read($, prefs)).spendCap ?? 0
254 const left = fiveHourLeft(list, at)
255
256 if (ownDispatches === 0 && left !== null && isCapped({ cap, left, liftedUntil: await read($, capLiftedUntil), at })) {
257 const answer = await $.ui
258 .ask(capQuestion(left, cap, e.description), {
259 header: 'Spend cap',
260 options: [START_ONE, START_ALL, DO_NOT_START],
261 })
262 .catch(() => DO_NOT_START)
263
264 if (answer === START_ALL) {
265 await update($, capLiftedUntil, () => liftUntil(list, at))
266 } else if (answer !== START_ONE) {
267 return { deny: capRefusal(left, cap) }
268 }
269 }
270
271 $.clock.after(AFTER_SPAWN_MS, () => void update($, pulse, beat => beat + 1).catch(() => undefined))
272
273 return next(e)
274 })
275
276 on('agent.offer', async ($, e, next) => {
277 if (!e.agent.startsWith(AGENT_PREFIX)) {
278 return next(e)
279 }
280
281 const bank = await read($, agentBank)
282
283 return bank.some(one => `${AGENT_PREFIX}${one.name}` === e.agent)
284 ? next(e)
285 : { isOffered: false }
286 })
287
288 on('ui.render', { component: 'Pane', requestId: 'clubhouse-agents' }, async ($, e) => {
289 const elements = $.ui.resolve(e)
290 const { Box, Text } = elements
291 const chosen = await read($, prefs)
292 await read($, pulse)
293 await read($, now)
294 const desk = await read($, agentDesk)
295 const bank = await read($, agentBank)
296 const roster = (await $.agent.list()).filter(one => !desk.dismissed.includes(one.id))
297 const working = roster.filter(one => agentStatus(one.status) === 'running')
298 const past = roster
299 .filter(one => agentStatus(one.status) !== 'running')
300 .slice(-PAST_SHOWN)
301 .reverse()
302 const { ink, frame, note, plain, title, card, look, picture, Button, Input, Select } = makeParts(
303 elements,
304 chosen,
305 e.surface,
306 )
307 const target = bank.find(one => one.name === desk.target)
308
309 const setDesk = (mode: 'idle' | 'form' | 'send', name: string | null) =>
310 void update($, agentDesk, held => ({ ...held, mode, target: name, note: null }))
311 const openForm = (one: Blueprint | null) => {
312 draft =
313 one === null
314 ? BLANK_DRAFT
315 : { name: one.name, purpose: one.purpose, prompt: one.prompt, model: one.isAuto ? AUTO : one.model }
316 setDesk('form', one?.name ?? null)
317 }
318 const dismiss = (id: string) =>
319 void update($, agentDesk, held => ({ ...held, dismissed: [...held.dismissed, id] }))
320
321 const fieldRow = (one: (typeof roster)[number]) => {
322 const status = agentStatus(one.status)
323 const label = one.name ?? one.description
324
325 return (
326 <Box gap={1} alignItems="center">
327 {picture(
328 agentSvg({ color: look.clawd, unit: ROW_UNIT, status }),
329 `Agent ${label}, ${STATUS_WORD[one.status] ?? one.status}`,
330 ROW_WIDTH,
331 ROW_HEIGHT,
332 ) ?? <Text {...ink}>{status === 'running' ? '[■_■]' : status === 'completed' ? '[■_■]✓' : '[■_■]✗'}</Text>}
333 <Box flexDirection="column">
334 <Text {...ink} wrap="truncate">
335 {one.description}
336 </Text>
337 <Box gap={1} alignItems="center">
338 <Text {...ink} dimColor>
339 {one.type} · {STATUS_WORD[one.status] ?? one.status}
340 </Text>
341 {status === 'running' ? (
342 <Button
343 key={`stop-${one.id}`}
344 label="Stand down"
345 onPress={() => void standDown($, one.id, label)}
346 />
347 ) : (
348 <Button key={`dismiss-${one.id}`} label="Dismiss" onPress={() => dismiss(one.id)} />
349 )}
350 </Box>
351 </Box>
352 </Box>
353 )
354 }
355
356 const bankRow = (one: Blueprint) => (
357 <Box flexDirection="column">
358 <Box gap={1}>
359 <Text {...ink} bold>
360 {one.name}
361 </Text>
362 <Text {...ink} dimColor wrap="truncate">
363 {one.purpose}
364 </Text>
365 </Box>
366 <Box gap={1} flexWrap="wrap">
367 <Button key={`send-${one.name}`} label="Send" variant="primary" onPress={() => setDesk('send', one.name)} />
368 <Button key={`edit-${one.name}`} label="Edit" onPress={() => openForm(one)} />
369 <Button
370 key={`model-${one.name}`}
371 label={MODEL_LABEL[one.model].split(' ')[0] ?? one.model}
372 onPress={() => void nextModel($, one.name)}
373 />
374 <Button key={`delete-${one.name}`} label="Delete" onPress={() => void removeBlueprint($, one.name)} />
375 </Box>
376 </Box>
377 )
378
379 const form = () =>
380 Input !== null && Select !== null
381 ? card(target === undefined ? 'New agent' : `Edit ${target.name}`, [
382 note('Press Enter in each box to set it, then Save.'),
383 <Input
384 key="agent-name"
385 label="Name"
386 placeholder="test-scout"
387 value={draft.name}
388 onInput={typed => {
389 draft = { ...draft, name: typed }
390 }}
391 onSubmit={typed => {
392 draft = { ...draft, name: typed }
393 }}
394 />,
395 <Input
396 key="agent-purpose"
397 label="When to use it"
398 placeholder="Finds which tests cover a change"
399 value={draft.purpose}
400 onInput={typed => {
401 draft = { ...draft, purpose: typed }
402 }}
403 onSubmit={typed => {
404 draft = { ...draft, purpose: typed }
405 }}
406 />,
407 <Input
408 key="agent-prompt"
409 label="Instructions"
410 placeholder="You read code and report file paths. Never edit files."
411 value={draft.prompt}
412 onInput={typed => {
413 draft = { ...draft, prompt: typed }
414 }}
415 onSubmit={typed => {
416 draft = { ...draft, prompt: typed }
417 }}
418 />,
419 <Select
420 key="agent-model"
421 label="Model"
422 value={draft.model}
423 options={[
424 { value: AUTO, label: 'Auto (cheapest that fits the job)' },
425 ...AGENT_MODELS.map(model => ({ value: model, label: MODEL_LABEL[model] })),
426 ]}
427 onSelect={picked => {
428 draft = { ...draft, model: picked }
429 }}
430 />,
431 <Box gap={1}>
432 <Button key="agent-save" label="Save agent" variant="primary" onPress={() => void saveDraft($)} />
433 <Button key="agent-cancel" label="Cancel" onPress={() => setDesk('idle', null)} />
434 </Box>,
435 ])
436 : card('New agent', [note('Adding agents needs a text box, which this screen does not have. Use the desktop app or terminal.')])
437
438 const send = (one: Blueprint) =>
439 Input !== null
440 ? card(`Send ${one.name}`, [
441 note(`Runs on ${MODEL_LABEL[one.model]}. Claude is told what it was sent to do.`),
442 <Input
443 key="agent-task"
444 label="Task"
445 placeholder="What should it do right now?"
446 onInput={typed => {
447 task = typed
448 }}
449 onSubmit={typed => {
450 task = typed
451 }}
452 />,
453 <Box gap={1}>
454 <Button key="agent-dispatch" label="Dispatch" variant="primary" onPress={() => void dispatch($, one.name)} />
455 <Button key="agent-cancel" label="Cancel" onPress={() => setDesk('idle', null)} />
456 </Box>,
457 ])
458 : card(`Send ${one.name}`, [note('Sending needs a text box, which this screen does not have.')])
459
460 return (
461 <Box flexDirection="column" gap={1} {...frame}>
462 <Box gap={1} alignItems="center">
463 {picture(
464 agentSvg({ color: look.clawd, unit: HEAD_UNIT, status: 'running' }),
465 'A Clawd agent in a suit and sunglasses',
466 HEAD_WIDTH,
467 HEAD_HEIGHT,
468 )}
469 {title('Agent HQ')}
470 {desk.mode === 'idle' && (
471 <Button key="agent-new" label="+ New agent" variant="primary" onPress={() => openForm(null)} />
472 )}
473 </Box>
474
475 {desk.mode === 'form' && form()}
476 {desk.mode === 'send' && target !== undefined && send(target)}
477 {desk.note !== null && plain(desk.note)}
478
479 {card(`In the field (${working.length})`, [
480 working.length === 0 && note('No agents out right now.'),
481 ...working.map(fieldRow),
482 ])}
483
484 {card(
485 `Your agents (${bank.length})`,
486 bank.length === 0
487 ? [note('None saved yet. Press + New agent to make one you can reuse in any session.')]
488 : bank.map(bankRow),
489 )}
490
491 {past.length > 0 && card('Back from the field', past.map(fieldRow))}
492
493 {note('An agent can be stood down but not paused: Claude Code has no pause. /clubhouse agents opens this room.')}
494 </Box>
495 )
496 })
497}
498hooks/features/band.tsx 492 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { BarItemId, BarZone, Forecast, Game, Prefs, Quote, Shortcut } from '../../types'
5import { meterSvg, moodFor, moodName } from '../lib/clawd'
6import { backdropOf } from '../lib/appColor'
7import { mix, rampColor } from '../lib/color'
8import {
9 BAR_ITEMS,
10 DEFAULT_PREFS,
11 IDLE_SUMMARY,
12 PREFS_KEY,
13 PREFS_SHAPE,
14 WORKING_SUMMARY,
15} from '../lib/defaults'
16import {
17 WINDOW_LABEL,
18 cacheNote,
19 contextNote,
20 formatSpan,
21 percentLeft,
22 receiptNote,
23 resetIn,
24 textBar,
25} from '../lib/format'
26import { homeIconSvg } from '../lib/icon'
27import { makeParts } from '../lib/parts'
28import { DEFAULT_FONT } from '../lib/fonts'
29import { HANDOFF_ARM, HANDOFF_PERCENT } from '../lib/guard'
30import { DRAW_BINARY, DRAW_DONE, DRAW_MISSING, DRAW_OPEN, DRAW_TIMEOUT_MS, sketchPrompt } from '../lib/draw'
31import { DEFAULT_SPORTS, gameLine, logoFor, logoKey, scoreSvg } from '../lib/sports'
32import { summaryOf, summaryRequest } from '../lib/summary'
33import { PRUNE_PROMPT, withLine } from '../lib/prompts'
34import { DEFAULT_TICKER, DOWN_COLOR, UP_COLOR, changeText, isStale, priceText } from '../lib/ticker'
35import { clearOn, drawnFor } from '../lib/tone'
36import { serifSize, serifSvg } from '../lib/type'
37import { KIND_WORD, weatherSvg } from '../lib/weather'
38import { TIDY_MIN_CHARS, tidyRequest } from '../lib/tidy'
39import { forStore } from '../lib/project'
40
41const contextPercent = atom({ plugin: 'clubhouse', key: 'contextPercent' } as const, null)
42const contextSize = atom({ plugin: 'clubhouse', key: 'contextSize' } as const, null)
43const ticker = atom({ plugin: 'clubhouse', key: 'ticker' } as const, DEFAULT_TICKER)
44const handoff = atom({ plugin: 'clubhouse', key: 'handoff' } as const, 'idle')
45const forecast = atom({ plugin: 'clubhouse', key: 'forecast' } as const, null)
46const sports = atom({ plugin: 'clubhouse', key: 'sports' } as const, DEFAULT_SPORTS)
47const liveGame = atom({ plugin: 'clubhouse', key: 'liveGame' } as const, null)
48const logos = atom({ plugin: 'clubhouse', key: 'logos' } as const, {})
49const quotes = atom({ plugin: 'clubhouse', key: 'quotes' } as const, {})
50const lastReplyAt = atom({ plugin: 'clubhouse', key: 'lastReplyAt' } as const, null)
51const limits = atom({ plugin: 'clubhouse', key: 'limits' } as const, [])
52const now = atom({ plugin: 'clubhouse', key: 'now' } as const, 0)
53const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
54 shape: PREFS_SHAPE,
55})
56const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
57const receipt = atom({ plugin: 'clubhouse', key: 'receipt' } as const, null)
58const lastAnswer = atom({ plugin: 'clubhouse', key: 'lastAnswer' } as const, '')
59const summary = atom({ plugin: 'clubhouse', key: 'summary' } as const, IDLE_SUMMARY)
60
61const BAR_HEIGHT = 32
62const HOME_ICON = 40
63const SCORE_WIDTH = 150
64const WORD_SIZE = 13
65const WEATHER_ICON = 30
66const FAINT_SHARE = 0.35
67const WIDE_METER = 210
68const SLIM_METER = 150
69const TEXT_CELLS = 16
70const COLUMNS_FOR_WIDE_METER = 110
71const COLUMNS_FOR_DETAILS = 150
72const FRAME_CELLS = 4
73const AGI_QUESTION = 'Is this AGI?'
74const GASLIGHT_LINE = 'Chat GPT did this easily. Figure it out.'
75
76async function keep($: EngineInterface, change: (held: Prefs) => Prefs): Promise<void> {
77 await update($, prefs, change)
78 await $.store.set(PREFS_KEY, forStore(await read($, prefs), await read($, sharedPalette)))
79}
80
81async function summarize($: EngineInterface): Promise<void> {
82 const answer = await read($, lastAnswer)
83 await $.ui.open({ id: 'clubhouse-summary', title: 'Summary', focus: true, closeOnEscape: true })
84
85 if (answer.trim() === '') return
86 await update($, summary, () => WORKING_SUMMARY)
87 const reply = await $.model.complete(summaryRequest(answer)).catch(() => null)
88 await update($, summary, () => summaryOf(reply, answer))
89}
90
91type Tidied = { before: string; after: string }
92
93let lastTidy: Tidied | null = null
94
95async function tidy($: EngineInterface): Promise<void> {
96 try {
97 const draft = (await $.prompt.read()).text
98
99 if (lastTidy !== null && draft === lastTidy.after) {
100 await $.prompt.fill({ text: lastTidy.before })
101 lastTidy = null
102 $.ui.toast('Your original draft is back.')
103
104 return
105 }
106
107 if (draft.trim().length < TIDY_MIN_CHARS) {
108 $.ui.toast('Type a longer draft first, then press Tidy.')
109
110 return
111 }
112
113 $.ui.toast('Tidying your draft…')
114 const reply = await $.model.complete(tidyRequest(draft))
115
116 if (!reply.isAnswered || reply.text.trim() === '') {
117 $.ui.toast('The tidy model did not answer. Your draft is unchanged.')
118
119 return
120 }
121
122 const after = reply.text.trim()
123 const { isFilled } = await $.prompt.fill({ text: after })
124
125 if (!isFilled) {
126 $.ui.toast('The prompt box changed while tidying. Your draft is unchanged.')
127
128 return
129 }
130
131 lastTidy = { before: draft, after }
132 $.ui.toast(`Tidied: ${draft.length} to ${after.length} characters. Press Tidy again to undo.`, {
133 timeoutMs: 7000,
134 })
135 } catch {
136 $.ui.toast('Could not tidy the draft. It is unchanged.')
137 }
138}
139
140async function drawIt($: EngineInterface): Promise<void> {
141 const userFolder = await $.env.get('HOME')
142 const pad = `${userFolder ?? ''}/${DRAW_BINARY}`
143
144 if (userFolder === undefined || !(await $.fs.exists(pad).catch(() => false))) {
145 $.ui.toast(DRAW_MISSING, { timeoutMs: 8000 })
146
147 return
148 }
149
150 $.ui.toast(DRAW_OPEN)
151 const ran = await $.process.run([pad], { timeoutMs: DRAW_TIMEOUT_MS }).catch(() => null)
152 const path = ran !== null && ran.exitCode === 0 ? ran.stdout.trim() : ''
153
154 if (path === '') return
155 const draft = await $.prompt.read().then(
156 box => box.text,
157 () => '',
158 )
159 const isFilled = await $.prompt.fill({ text: sketchPrompt(draft, path) }).then(
160 filled => filled.isFilled,
161 () => false,
162 )
163 $.ui.toast(isFilled ? DRAW_DONE : `Your sketch is saved at ${path}. Tell Claude to look at it.`, { timeoutMs: 8000 })
164}
165
166async function offer($: EngineInterface, text: string): Promise<void> {
167 try {
168 const { isFilled } = await $.prompt.fill({ text })
169
170 if (!isFilled) {
171 $.ui.toast('The prompt box is busy. Clear it and press the button again.')
172 }
173 } catch {
174 $.ui.toast('Could not reach the prompt box.')
175 }
176}
177
178async function ask($: EngineInterface, text: string): Promise<void> {
179 await $.prompt.submit({ text }).catch(() => $.ui.toast('Could not send that. Type it in the prompt box instead.'))
180}
181
182async function addToDraft($: EngineInterface, line: string): Promise<void> {
183 const draft = await $.prompt.read().then(
184 box => box.text,
185 () => '',
186 )
187 const isFilled = await $.prompt.fill({ text: withLine(draft, line) }).then(
188 filled => filled.isFilled,
189 () => false,
190 )
191
192 if (!isFilled) $.ui.toast('Could not reach the prompt box.')
193}
194
195export function band(on: On): void {
196 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
197 const chosen = await read($, prefs)
198 const spots = [
199 ...BAR_ITEMS.map(([id]) => chosen.bar[id]),
200 ...chosen.shortcuts.map(one => one.spot),
201 ]
202 const rows = Array.from({ length: chosen.barCount }, (_, index) => index + 1).filter(row =>
203 spots.some(spot => spot?.isShown === true && spot.row === row),
204 )
205
206 if (e.props.hasSurvey || !chosen.isEnabled || rows.length === 0) {
207 return next(e)
208 }
209
210 const elements = $.ui.resolve(e)
211 const canDraw = e.surface !== 'terminal' && 'Svg' in elements
212 const { Box, Text } = elements
213 const { Button, look, picture } = makeParts(elements, chosen, e.surface)
214 const at = await read($, now)
215 const list = await read($, limits)
216 const context = await read($, contextPercent)
217 const size = await read($, contextSize)
218 const plan = await read($, ticker)
219 const handoffState = await read($, handoff)
220 const sky: Forecast | null = await read($, forecast)
221 const game: Game | null = await read($, liveGame)
222 const marks: { [key: string]: string } = await read($, logos)
223 const wanted = (await read($, sports)).gameId
224 const prices: { [symbol: string]: Quote } = await read($, quotes)
225 const made = await read($, receipt)
226 const last = await read($, lastReplyAt)
227 const limit = list.find(one => one.kind === chosen.window)
228 const columns = e.props.bodyColumns
229 const meterWidth = columns >= COLUMNS_FOR_WIDE_METER ? WIDE_METER : SLIM_METER
230 const { background, edge } = look
231 const ink = background === null ? {} : { color: look.ink }
232 const frame =
233 edge !== null
234 ? { borderStyle: 'round', borderColor: edge, paddingX: 1 }
235 : background === null
236 ? {}
237 : { backgroundColor: background }
238 const font = chosen.font ?? DEFAULT_FONT
239 const usesFont = canDraw && font.name !== DEFAULT_FONT.name
240 const faint = mix(look.ink, backdropOf(chosen), FAINT_SHARE)
241 const words = (text: string, style: { isDim?: boolean; isBold?: boolean; color?: string } = {}) => {
242 const spec = {
243 text,
244 size: WORD_SIZE,
245 color: style.color ?? (style.isDim === true ? faint : look.ink),
246 isStrong: style.isBold === true,
247 stack: font.stack,
248 weight: style.isBold === true ? 700 : 400,
249 widen: font.widen,
250 }
251 const drawn = usesFont
252 ? picture(serifSvg(spec), text, serifSize(spec).width, serifSize(spec).height)
253 : null
254
255 return (
256 drawn ?? (
257 <Text
258 {...(style.color === undefined ? ink : { color: style.color })}
259 {...(style.isBold === true ? { bold: true } : {})}
260 {...(style.isDim === true ? { dimColor: true } : {})}
261 wrap="truncate"
262 >
263 {text}
264 </Text>
265 )
266 )
267 }
268 const flipWindow = () =>
269 void keep($, held => ({
270 ...held,
271 window: held.window === 'five_hour' ? 'seven_day' : 'five_hour',
272 }))
273 const openHome = () =>
274 void $.ui.open({ id: 'clubhouse', title: 'Clubhouse', focus: true, closeOnEscape: true })
275
276 const home = () => (
277 <Box gap={1} alignItems="center" flexShrink={0}>
278 {picture(homeIconSvg({ size: HOME_ICON, accent: look.accent }), 'Claude Clubhouse', HOME_ICON, HOME_ICON)}
279 <Button key="home" label={canDraw ? '→' : '⌂ Clubhouse'} onPress={openHome} />
280 </Box>
281 )
282
283 const meter = () => {
284 const windowButton = (
285 <Button key="window" label={WINDOW_LABEL[chosen.window]} onPress={flipWindow} />
286 )
287
288 if (limit === undefined) {
289 return (
290 <Box gap={1} alignItems="center" flexShrink={0}>
291 {windowButton}
292 {words('usage shows after the first reply', { isDim: true })}
293 </Box>
294 )
295 }
296
297 const left = percentLeft(limit, at)
298 const untilReset = resetIn(limit, at)
299 const details = [
300 untilReset !== null && untilReset > 0 ? `resets ${formatSpan(untilReset)}` : null,
301 chosen.bar.cache?.isShown === true ? null : cacheNote(last, at),
302 ]
303 .filter(one => one !== null)
304 .join(' · ')
305 const { filled, empty } = textBar(left, TEXT_CELLS)
306
307 return (
308 <Box gap={1} alignItems="center" flexShrink={0}>
309 {windowButton}
310 {picture(
311 meterSvg({
312 left,
313 barColor: rampColor(left),
314 clawdColor: look.clawd,
315 trackColor: look.track,
316 width: meterWidth,
317 height: BAR_HEIGHT,
318 }),
319 `${left}% of the ${WINDOW_LABEL[chosen.window]} limit left; Clawd looks ${moodName(moodFor(left))}`,
320 meterWidth,
321 BAR_HEIGHT,
322 ) ?? (
323 <Box>
324 {filled !== '' && <Text color={rampColor(left)}>{filled}</Text>}
325 {empty !== '' && <Text dimColor>{empty}</Text>}
326 </Box>
327 )}
328 {words(`${left}%`, { isBold: true })}
329 {columns >= COLUMNS_FOR_DETAILS && details !== '' && words(details, { isDim: true })}
330 </Box>
331 )
332 }
333
334 const piece = (id: BarItemId) => {
335 if (id === 'home') return home()
336 if (id === 'meter') return meter()
337
338 if (id === 'summary') {
339 return <Button key="summarize" label="Summarize" onPress={() => void summarize($)} />
340 }
341
342 if (id === 'tidy') {
343 return <Button key="tidy" label="Tidy" onPress={() => void tidy($)} />
344 }
345
346 if (id === 'weather') {
347 if (sky === null) {
348 return words('weather: set it up in the Clubhouse', { isDim: true })
349 }
350
351 return (
352 <Box gap={1} alignItems="center" flexShrink={0}>
353 {picture(weatherSvg(sky.kind, look.ink, backdropOf(chosen), WEATHER_ICON), KIND_WORD[sky.kind], WEATHER_ICON, WEATHER_ICON)}
354 {words(`${sky.temp}°`, { isBold: true })}
355 {!sky.isWet && sky.chance !== null && words(`${sky.chance}% rain`, { isDim: true })}
356 </Box>
357 )
358 }
359
360 if (id === 'draw') {
361 return <Button key="draw" label="Draw it" onPress={() => void drawIt($)} />
362 }
363
364 if (id === 'agi') {
365 return <Button key="agi" label={AGI_QUESTION} onPress={() => void ask($, AGI_QUESTION)} />
366 }
367
368 if (id === 'gaslight') {
369 return <Button key="gaslight" label="Gaslighting" onPress={() => void addToDraft($, GASLIGHT_LINE)} />
370 }
371
372 if (id === 'prune') {
373 return <Button key="prune" label="Prune" onPress={() => void ask($, PRUNE_PROMPT)} />
374 }
375
376 if (id === 'sports') {
377
378 if (game === null || game.id !== wanted) {
379 return (
380 <Text {...ink} dimColor wrap="truncate">
381 {wanted === null ? 'live score: pick a game in the Clubhouse' : 'live score …'}
382 </Text>
383 )
384 }
385
386 return (
387 picture(
388 scoreSvg({
389 game,
390 ink: look.ink,
391 homeLogo: logoFor(marks, logoKey(game.league, game.home), look.tone),
392 awayLogo: logoFor(marks, logoKey(game.league, game.away), look.tone),
393 width: SCORE_WIDTH,
394 height: HOME_ICON,
395 }),
396 gameLine(game),
397 SCORE_WIDTH,
398 HOME_ICON,
399 ) ?? (
400 <Text {...ink} wrap="truncate">
401 {gameLine(game)}
402 </Text>
403 )
404 )
405 }
406
407 if (id === 'ticker') {
408 const quote = plan.symbol === null ? undefined : prices[plan.symbol]
409
410 if (plan.symbol === null || quote === undefined) {
411 return (
412 words(plan.symbol === null ? 'ticker: pick one in the Clubhouse' : `${plan.symbol} …`, { isDim: true })
413 )
414 }
415
416 const isUp = (quote.changePercent ?? 0) >= 0
417 const wanted = isUp ? UP_COLOR : DOWN_COLOR
418 const shown = look.tone === null ? wanted : drawnFor(look.tone, clearOn(look.tone, wanted, 3))
419
420 return (
421 <Box gap={1} flexShrink={0}>
422 {words(quote.symbol, { isBold: true })}
423 {words(priceText(quote.price), { isDim: isStale(quote, at) })}
424 {words(changeText(quote.changePercent), plan.isColored ? { color: usesFont ? (look.tone === null ? wanted : clearOn(look.tone, wanted, 3)) : shown } : {})}
425 </Box>
426 )
427 }
428
429 if (id === 'cache') {
430 return (
431 words(cacheNote(last, at), { isDim: true })
432 )
433 }
434
435 if (id === 'context') {
436 return (
437 words(contextNote(size, context), { isDim: true })
438 )
439 }
440
441 return (
442 words(made === null ? 'last turn: none yet' : receiptNote(made), { isDim: true })
443 )
444 }
445
446 const shortcut = (one: Shortcut) => (
447 <Button key={`shortcut-${one.id}`} label={one.label} onPress={() => void offer($, one.text)} />
448 )
449
450 const zone = (row: number, where: BarZone) => [
451 ...BAR_ITEMS.filter(
452 ([id]) =>
453 chosen.bar[id]?.isShown === true && chosen.bar[id].row === row && chosen.bar[id].zone === where,
454 ).map(([id]) => piece(id)),
455 ...chosen.shortcuts
456 .filter(one => one.spot.isShown && one.spot.row === row && one.spot.zone === where)
457 .map(shortcut),
458 ]
459
460 return (
461 <Box flexDirection="column" gap={rows.length > 1 ? 1 : 0} width={columns} {...frame}>
462 {handoffState === 'idle' && context !== null && context >= HANDOFF_PERCENT && (
463 <Box gap={1} alignItems="center" flexWrap="wrap">
464 {words(`This conversation is ${context}% full.`)}
465 <Button key="handoff-arm" label={HANDOFF_ARM} variant="primary" onPress={() => void update($, handoff, () => 'armed')} />
466 <Button key="handoff-dismiss" label="Not now" onPress={() => void update($, handoff, () => 'dismissed')} />
467 </Box>
468 )}
469 {handoffState === 'armed' && (
470 <Box gap={1} alignItems="center" flexWrap="wrap">
471 {words('Claude writes HANDOFF.md as soon as your next prompt is finished.')}
472 <Button key="handoff-cancel" label="Cancel" onPress={() => void update($, handoff, () => 'dismissed')} />
473 </Box>
474 )}
475 {rows.map(row => (
476 <Box width={edge === null ? columns : columns - FRAME_CELLS} alignItems="center">
477 <Box width={0} flexGrow={1} gap={2} alignItems="center">
478 {zone(row, 'left')}
479 </Box>
480 <Box flexShrink={0} gap={2} alignItems="center" justifyContent="center">
481 {zone(row, 'center')}
482 </Box>
483 <Box width={0} flexGrow={1} gap={2} alignItems="center" justifyContent="flex-end">
484 {zone(row, 'right')}
485 </Box>
486 </Box>
487 ))}
488 </Box>
489 )
490 })
491}
492hooks/features/bar.tsx 288 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { BarSpot, Prefs } from '../../types'
5import {
6 BAR_ITEMS,
7 DEFAULT_BAR,
8 DEFAULT_PREFS,
9 MAX_BARS,
10 MAX_SHORTCUTS,
11 PREFS_KEY,
12 PREFS_SHAPE,
13 SHORTCUT_SPOT,
14 ZONE_LABEL,
15 nextZone,
16 withBarCount,
17} from '../lib/defaults'
18import { makeParts } from '../lib/parts'
19import {
20 ITEM_SIZE,
21 READY_LAYOUTS,
22 ROW_CAPACITY,
23 TOOLBAR_PRESETS_KEY,
24 arranged,
25 layoutOf,
26 presetFrom,
27 rowLoad,
28 shortcutSize,
29 withReadyLayout,
30 withSpot,
31 withToolbarPreset,
32} from '../lib/toolbar'
33import type { ItemKey, ReadyLayout } from '../lib/toolbar'
34import { forStore } from '../lib/project'
35
36const barNote = atom({ plugin: 'clubhouse', key: 'barNote' } as const, null)
37const toolbarPresets = atom({ plugin: 'clubhouse', key: 'toolbarPresets' } as const, [])
38const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
39 shape: PREFS_SHAPE,
40})
41const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
42
43type ShortcutDraft = { label: string; text: string }
44
45const BLANK_SHORTCUT: ShortcutDraft = { label: '', text: '' }
46
47let draft: ShortcutDraft = BLANK_SHORTCUT
48
49async function keep($: EngineInterface, change: (held: Prefs) => Prefs): Promise<void> {
50 await update($, prefs, change)
51 await $.store.set(PREFS_KEY, forStore(await read($, prefs), await read($, sharedPalette)))
52}
53
54async function arrange($: EngineInterface, key: ItemKey, wish: 'toggle' | 'row'): Promise<void> {
55 const outcome = arranged(await read($, prefs), key, wish)
56 await keep($, () => outcome.prefs)
57 await update($, barNote, () => outcome.note)
58}
59
60async function useReady($: EngineInterface, layout: ReadyLayout): Promise<void> {
61 const hadOwn = (await read($, prefs)).shortcuts.some(one => one.spot.isShown)
62 await keep($, held => withReadyLayout(held, layout))
63 await update(
64 $,
65 barNote,
66 () => `The toolbar is now "${layout.name}".${hadOwn ? ' Your own buttons are off the toolbar, not deleted: add them back below.' : ''}`,
67 )
68}
69
70async function saveLayout($: EngineInterface, typed: string): Promise<void> {
71 if (typed.trim() === '') {
72 await update($, barNote, () => 'Type a name for the layout, then press Save this layout.')
73
74 return
75 }
76
77 const made = presetFrom(typed, await read($, prefs))
78 await update($, toolbarPresets, held => withToolbarPreset(held, made))
79 await $.store.set(TOOLBAR_PRESETS_KEY, await read($, toolbarPresets))
80 await update($, barNote, () => `Saved this layout as "${made.name}".`)
81}
82
83async function dropLayout($: EngineInterface, name: string): Promise<void> {
84 await update($, toolbarPresets, held => held.filter(one => one.name !== name))
85 await $.store.set(TOOLBAR_PRESETS_KEY, await read($, toolbarPresets))
86 await update($, barNote, () => `Deleted the layout "${name}".`)
87}
88
89async function addShortcut($: EngineInterface): Promise<void> {
90 const label = draft.label.trim()
91 const text = draft.text.trim()
92 const { shortcuts } = await read($, prefs)
93
94 if (label === '' || text === '') {
95 await update($, barNote, () => 'A button needs a label and the text it types. Press Enter in each box, then Add.')
96
97 return
98 }
99
100 if (shortcuts.length >= MAX_SHORTCUTS) {
101 await update($, barNote, () => `The toolbar holds up to ${MAX_SHORTCUTS} of your own buttons. Remove one first.`)
102
103 return
104 }
105
106 const id = `s${(await $.clock.now()).toString(36)}`
107 const held = await read($, prefs)
108 const outcome = arranged(
109 { ...held, shortcuts: [...held.shortcuts, { id, label, text, spot: { ...SHORTCUT_SPOT, isShown: false } }] },
110 { kind: 'shortcut', id },
111 'toggle',
112 )
113 await keep($, () => outcome.prefs)
114 draft = BLANK_SHORTCUT
115 await update($, barNote, () => outcome.note ?? `"${label}" is on the toolbar.`)
116}
117
118export function bar(on: On): void {
119 on('ui.render', { component: 'Pane', requestId: 'clubhouse-bar' }, async ($, e) => {
120 const elements = $.ui.resolve(e)
121 const { Box } = elements
122 const chosen = await read($, prefs)
123 const said = await read($, barNote)
124 const { card, frame, note, plain, title, Button, Input } = makeParts(elements, chosen, e.surface)
125 const hasBars = chosen.barCount > 1
126
127 const kept = await read($, toolbarPresets)
128 const controls = (prefix: string, spot: BarSpot, key: ItemKey) => [
129 <Button
130 key={`${prefix}-show`}
131 label={spot.isShown ? 'On the toolbar' : 'Add to toolbar'}
132 variant={spot.isShown ? 'primary' : 'secondary'}
133 onPress={() => void arrange($, key, 'toggle')}
134 />,
135 hasBars && <Button key={`${prefix}-row`} label={`Row ${spot.row}`} onPress={() => void arrange($, key, 'row')} />,
136 <Button
137 key={`${prefix}-zone`}
138 label={ZONE_LABEL[spot.zone]}
139 onPress={() => void keep($, held => withSpot(held, key, { ...spot, zone: nextZone(spot.zone) }))}
140 />,
141 ]
142 const rows = Array.from({ length: chosen.barCount }, (_, index) => index + 1)
143
144 return (
145 <Box flexDirection="column" gap={1} {...frame}>
146 {title('Toolbar')}
147 {note(
148 'The toolbar is the strip above the box you type in. Each item has buttons to add or remove it, pick its row, and place it left, center or right. /clubhouse toolbar opens this.',
149 )}
150
151 {card('Rows', [
152 plain(rows.map(row => `Row ${row}: ${rowLoad(chosen, row)} of ${ROW_CAPACITY} used`).join(' · ')),
153 note(
154 `Every item has a size, and a row holds ${ROW_CAPACITY}, so nothing overlaps: a few big items or many small ones. When a row is full, the next item goes on another row. You can have up to ${MAX_BARS} rows; row 1 is on top.`,
155 ),
156 <Box gap={1}>
157 {chosen.barCount < MAX_BARS && (
158 <Button
159 key="bar-add"
160 label="Add a row"
161 variant="primary"
162 onPress={() => void keep($, held => withBarCount(held, held.barCount + 1))}
163 />
164 )}
165 {hasBars && (
166 <Button
167 key="bar-remove"
168 label={`Remove row ${chosen.barCount}`}
169 onPress={() => void keep($, held => withBarCount(held, held.barCount - 1))}
170 />
171 )}
172 </Box>,
173 note('A row only shows once something is placed on it.'),
174 ])}
175
176 {card('Ready-made layouts', [
177 note(
178 'One press sets the whole toolbar. It replaces what is on the toolbar now, so save your own layout first (Saved layouts, at the bottom) if you want it back.',
179 ),
180 <Box flexDirection="column" gap={1}>
181 {READY_LAYOUTS.map(one => (
182 <Box flexDirection="column">
183 <Box>
184 <Button key={`ready-${one.name}`} label={one.name} onPress={() => void useReady($, one)} />
185 </Box>
186 {note(one.about)}
187 </Box>
188 ))}
189 </Box>,
190 ])}
191
192 {BAR_ITEMS.map(([id, title, about]) =>
193 card(title, [
194 note(`${about} Size ${ITEM_SIZE[id]}.`),
195 <Box gap={1} flexWrap="wrap">
196 {controls(`bar-${id}`, chosen.bar[id] ?? DEFAULT_BAR[id], { kind: 'item', id })}
197 </Box>,
198 ]),
199 )}
200
201 {chosen.shortcuts.map(one =>
202 card(`Your button: ${one.label}`, [
203 note(`Types: ${one.text} Size ${shortcutSize(one)}.`),
204 <Box gap={1} flexWrap="wrap">
205 {controls(`sc-${one.id}`, one.spot, { kind: 'shortcut', id: one.id })}
206 <Button
207 key={`sc-${one.id}-remove`}
208 label="Delete"
209 onPress={() =>
210 void keep($, held => ({
211 ...held,
212 shortcuts: held.shortcuts.filter(other => other.id !== one.id),
213 }))
214 }
215 />
216 </Box>,
217 ]),
218 )}
219
220 {Input !== null &&
221 card('Make your own button', [
222 note('A button that types a prompt or command into the prompt box for you. Press Enter in each box, then Add.'),
223 <Input
224 key="shortcut-label"
225 label="Label"
226 placeholder="Run tests"
227 onInput={typed => {
228 draft = { ...draft, label: typed }
229 }}
230 onSubmit={typed => {
231 draft = { ...draft, label: typed }
232 }}
233 />,
234 <Input
235 key="shortcut-text"
236 label="What it types"
237 placeholder="run the tests and fix what fails"
238 onInput={typed => {
239 draft = { ...draft, text: typed }
240 }}
241 onSubmit={typed => {
242 draft = { ...draft, text: typed }
243 }}
244 />,
245 <Box>
246 <Button key="shortcut-add" label="Add to toolbar" variant="primary" onPress={() => void addShortcut($)} />
247 </Box>,
248 ])}
249
250 {said !== null && plain(said)}
251
252 {card('Saved layouts', [
253 note('Keep the toolbar as it is now under a name, and switch back to it with one press. Example: one for work, one for game day.'),
254 Input !== null && (
255 <Input
256 key="layout-name"
257 label="Name"
258 placeholder="Game day"
259 submitLabel="Save this layout"
260 onSubmit={typed => void saveLayout($, typed)}
261 />
262 ),
263 ...kept.map(one => (
264 <Box gap={1} flexWrap="wrap">
265 <Button
266 key={`layout-${one.name}`}
267 label={`Use ${one.name}`}
268 variant="primary"
269 onPress={() => void keep($, held => ({ ...held, ...layoutOf(one) }))}
270 />
271 <Button key={`layout-delete-${one.name}`} label="Delete" onPress={() => void dropLayout($, one.name)} />
272 </Box>
273 )),
274 ])}
275
276 <Box>
277 <Button
278 key="bar-reset"
279 label="Reset the toolbar"
280 onPress={() => void keep($, held => ({ ...held, bar: DEFAULT_BAR, barCount: 1, shortcuts: [] }))}
281 />
282 </Box>
283 {note('New toolbar features built later show up here as items you can add.')}
284 </Box>
285 )
286 })
287}
288hooks/features/colors.tsx 689 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { Palette, PaletteSlot, Prefs } from '../../types'
5import {
6 HELPER_CONFIG,
7 HELPER_FRONT,
8 READ_APP_MODE,
9 START_HELPER,
10 appModeFrom,
11 backdropOf,
12 coversApp,
13 helperConfig,
14 inkOf,
15 swapsLightAndDark,
16} from '../lib/appColor'
17import { clawdBox, clawdSvg, wheelSvg } from '../lib/clawd'
18import { contrast, isLight, mix, normalizeHex, rgbString, shift, toHsl } from '../lib/color'
19import type { Hsl } from '../lib/color'
20import {
21 COLOR_PRESETS_KEY,
22 DEFAULT_COLORS_VIEW,
23 DEFAULT_PALETTE,
24 DEFAULT_PREFS,
25 PREFS_KEY,
26 PREFS_SHAPE,
27 isRecord,
28 resetLook,
29 withPreset,
30} from '../lib/defaults'
31import { makeParts } from '../lib/parts'
32import { WINDOW_TINT_UNDO, windowTintSnippet } from '../lib/windowTint'
33import {
34 BUILD_AGAIN,
35 BUILD_DONE,
36 BUILD_NEEDS_TOOLS,
37 BUILD_NO_FOLDER,
38 BUILD_SCRIPT,
39 BUILD_STARTED,
40 BUILD_TIMEOUT_MS,
41 BUILD_WHY,
42 FIND_COMPILER,
43 STOP_HELPER,
44 buildFailed,
45} from '../lib/build'
46import { MARKETPLACE_LIST, installedFolder, ownFolder } from '../lib/own'
47import { PROJECT_COLORS_KEY, asProjectColors, forStore, projectName, withoutProject } from '../lib/project'
48
49const colorPresets = atom({ plugin: 'clubhouse', key: 'colorPresets' } as const, [])
50const colorsView = atom({ plugin: 'clubhouse', key: 'colorsView' } as const, DEFAULT_COLORS_VIEW)
51const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
52 shape: PREFS_SHAPE,
53})
54const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
55const projectRoot = atom({ plugin: 'clubhouse', key: 'projectRoot' } as const, '')
56
57const THEME_SLUG = 'clubhouse'
58const THEME_REF = `custom:${THEME_SLUG}`
59const WHEEL_SIZE = 110
60const CLAWD_UNIT = 6
61const PRESET_NAME_CHARS = 24
62const COMFORTABLE_CONTRAST = 4.5
63const LOOK_MODEL = 'haiku'
64const LOOK_SYSTEM =
65 'You design three-color palettes. Reply with one JSON object and nothing else: ' +
66 '{"accent":"#rrggbb","clawd":"#rrggbb","background":"#rrggbb"}. ' +
67 'accent is for headings and highlights, clawd is the body color of a small mascot, ' +
68 'background is a panel background. Keep accent and clawd clearly visible on background.'
69
70const SLOTS: readonly (readonly [PaletteSlot, string, string])[] = [
71 ['background', 'Background', 'The color of the whole session. Text turns dark or light on its own to stay readable.'],
72 ['accent', 'Accent', 'Headings and highlights in the Clubhouse.'],
73 ['clawd', 'Clawd', 'The mascot in the usage meter.'],
74]
75
76const PRESETS: readonly (readonly [string, Palette])[] = [
77 ['Claude', { accent: '#d97757', clawd: '#d97757', background: '#262624', text: null }],
78 ['Plush', { accent: '#f0a35e', clawd: '#e8743b', background: '#3a2418', text: null }],
79 ['Forest', { accent: '#ffb81c', clawd: '#db7037', background: '#0b3d2c', text: null }],
80 ['Ocean', { accent: '#5cc8ff', clawd: '#e8743b', background: '#0d243d', text: null }],
81 ['Grape', { accent: '#c9a7ff', clawd: '#f08a5d', background: '#2a1b4a', text: null }],
82 ['Slate', { accent: '#ffd166', clawd: '#e8743b', background: '#3a4252', text: null }],
83 ['Night', { accent: '#d97757', clawd: '#e8743b', background: '#15171c', text: null }],
84 ['Sun', { accent: '#b45309', clawd: '#e8743b', background: '#fff3c4', text: null }],
85 ['Rose', { accent: '#c2255c', clawd: '#e8743b', background: '#fde8ef', text: null }],
86 ['Paper', { accent: '#c6613f', clawd: '#d97757', background: '#f6f3ea', text: null }],
87]
88
89const ANTHROPIC: readonly (readonly [string, string])[] = [
90 ['Clay', '#d97757'],
91 ['Clay Deep', '#c6613f'],
92 ['Slate Dark', '#141413'],
93 ['Slate Medium', '#3d3d3a'],
94 ['Cloud Dark', '#87867f'],
95 ['Cloud Medium', '#b0aea5'],
96 ['Stone', '#cccbc8'],
97 ['Oat Warm', '#e3dacc'],
98 ['Manilla', '#f5e3c7'],
99 ['Ivory Medium', '#f0eee6'],
100 ['Ivory Light', '#faf9f5'],
101]
102
103const LOOKS: readonly (readonly [string, string, Palette])[] = [
104 ['look-ivory', 'Anthropic ivory', { accent: '#c6613f', clawd: '#d97757', background: '#faf9f5', text: null }],
105 ['look-manilla', 'Anthropic manilla', { accent: '#c6613f', clawd: '#d97757', background: '#f5e3c7', text: null }],
106 ['look-slate', 'Anthropic slate', { accent: '#d97757', clawd: '#d97757', background: '#141413', text: null }],
107]
108
109const TINT_FILE = '.claude/clubhouse-window-tint.js'
110
111const NUDGES: readonly (readonly [string, string, Partial<Hsl>])[] = [
112 ['hue-back', '◀ hue', { hue: -15 }],
113 ['hue-on', 'hue ▶', { hue: 15 }],
114 ['lighter', 'lighter', { lightness: 6 }],
115 ['darker', 'darker', { lightness: -6 }],
116 ['vivid', 'bolder', { saturation: 10 }],
117 ['soft', 'softer', { saturation: -10 }],
118]
119
120function themeOverrides(palette: Palette, isLightBase: boolean): Record<string, string> {
121 const surface = isLightBase ? '#f0f0f0' : '#373737'
122 const { background } = palette
123 const fitsBase = background !== null && isLight(background) === isLightBase
124 const panel = fitsBase && background !== null ? background : mix(surface, palette.accent, 0.2)
125 const glow = mix(palette.accent, '#ffffff', 0.3)
126 const keys: Record<string, string> = {
127 claude: palette.accent,
128 claudeShimmer: glow,
129 permission: palette.accent,
130 permissionShimmer: glow,
131 suggestion: palette.accent,
132 promptBorder: palette.accent,
133 promptBorderShimmer: glow,
134 bashBorder: palette.accent,
135 rate_limit_fill: palette.accent,
136 briefLabelClaude: palette.accent,
137 clawd_body: palette.clawd,
138 userMessageBackground: panel,
139 userMessageBackgroundHover: mix(panel, isLightBase ? '#ffffff' : '#808080', 0.2),
140 composerSidebarBackground: panel,
141 bashMessageBackgroundColor: mix(panel, palette.accent, 0.1),
142 memoryBackgroundColor: mix(panel, palette.accent, 0.1),
143 }
144
145 return Object.fromEntries(Object.entries(keys).map(([key, hex]) => [key, rgbString(hex)]))
146}
147
148function reason(error: unknown): string {
149 return error instanceof Error ? error.message : 'unknown error'
150}
151
152function colorOf(held: Prefs, slot: PaletteSlot): string {
153 return held.palette[slot] ?? backdropOf(held)
154}
155
156async function appModeNow($: EngineInterface): Promise<Prefs['appMode'] | null> {
157 const ran = await $.process.run(['/bin/sh', '-c', READ_APP_MODE]).catch(() => null)
158
159 return ran === null ? null : appModeFrom(ran.stdout)
160}
161
162async function keep($: EngineInterface, change: (held: Prefs) => Prefs): Promise<void> {
163 const mode = await appModeNow($)
164 await update($, prefs, held => ({ ...change(held), appMode: mode ?? held.appMode }))
165 const chosen = await read($, prefs)
166 const shared = await read($, sharedPalette)
167 await $.store.set(PREFS_KEY, forStore(chosen, shared))
168
169 if (shared !== null) {
170 const projects = asProjectColors(await $.store.get(PROJECT_COLORS_KEY))
171 await $.store.set(PROJECT_COLORS_KEY, { ...projects, [await read($, projectRoot)]: chosen.palette })
172 }
173
174 const userFolder = await $.env.get('HOME')
175
176 if (userFolder === undefined) return
177 const id = await $.session.id().catch(() => '')
178
179 if (id !== '') {
180 await $.fs.write(`${userFolder}/${HELPER_FRONT}`, id).catch(() => undefined)
181 }
182
183 await $.fs.write(`${userFolder}/${HELPER_CONFIG}`, helperConfig(chosen)).catch(() => undefined)
184
185 if (coversApp(chosen)) {
186 await $.process.run(['/bin/sh', '-c', START_HELPER]).catch(() => undefined)
187 }
188}
189
190async function keepForProject($: EngineInterface, isOwn: boolean): Promise<void> {
191 const folder = await read($, projectRoot)
192 const projects = asProjectColors(await $.store.get(PROJECT_COLORS_KEY))
193
194 if (folder === '') return
195
196 if (isOwn) {
197 const held = await read($, prefs)
198 await update($, sharedPalette, () => held.palette)
199 await $.store.set(PROJECT_COLORS_KEY, { ...projects, [folder]: held.palette })
200 await say($, `${projectName(folder)} now keeps its own colors. What you pick here stays with this project.`)
201
202 return
203 }
204
205 const shared = await read($, sharedPalette)
206 await $.store.set(PROJECT_COLORS_KEY, withoutProject(projects, folder))
207 await update($, sharedPalette, () => null)
208 await keep($, held => ({ ...held, palette: shared ?? held.palette }))
209 await say($, `${projectName(folder)} is back on the shared colors.`)
210}
211
212async function copyFolder($: EngineInterface): Promise<string | null> {
213 const own = ownFolder(await $.env.get('CLAUDE_CODE_PLUGIN_DIRS'))
214
215 if (own !== null) return own
216 const settings = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
217
218 return installedFolder(await $.fs.read(`${settings}/${MARKETPLACE_LIST}`).catch(() => ''))
219}
220
221async function buildHelper($: EngineInterface): Promise<void> {
222 const tell = (helperNote: string, isBuilding: boolean) => update($, colorsView, view => ({ ...view, helperNote, isBuilding }))
223 const folder = await copyFolder($)
224
225 if ((await read($, colorsView)).isBuilding === true) return
226
227 if (folder === null) {
228 await tell(BUILD_NO_FOLDER, false)
229
230 return
231 }
232
233 const compiler = await $.process.run(FIND_COMPILER).catch(() => null)
234
235 if (compiler === null || compiler.exitCode !== 0) {
236 await tell(BUILD_NEEDS_TOOLS, false)
237
238 return
239 }
240
241 await tell(BUILD_STARTED, true)
242 const built = await $.process.run(['/bin/sh', `${folder}/${BUILD_SCRIPT}`], { timeoutMs: BUILD_TIMEOUT_MS }).catch(() => null)
243
244 if (built === null || built.exitCode !== 0) {
245 await tell(buildFailed(built === null ? '' : `${built.stdout}\n${built.stderr}`), false)
246
247 return
248 }
249
250 await $.process.run(STOP_HELPER).catch(() => undefined)
251 await keep($, held => ({ ...held, isHelperReady: true }))
252 await tell(BUILD_DONE, false)
253}
254
255async function say($: EngineInterface, note: string): Promise<void> {
256 await update($, colorsView, view => ({ ...view, note }))
257}
258
259async function savePreset($: EngineInterface, typed: string): Promise<void> {
260 const name = typed.trim().slice(0, PRESET_NAME_CHARS)
261
262 if (name === '') {
263 await say($, 'Type a name for the preset, then press Save current colors.')
264
265 return
266 }
267
268 const { palette } = await read($, prefs)
269 await update($, colorPresets, held => withPreset(held, { name, palette }))
270 await $.store.set(COLOR_PRESETS_KEY, await read($, colorPresets))
271 await say($, `Saved your colors as "${name}".`)
272}
273
274async function dropPreset($: EngineInterface, name: string): Promise<void> {
275 await update($, colorPresets, held => held.filter(one => one.name !== name))
276 await $.store.set(COLOR_PRESETS_KEY, await read($, colorPresets))
277 await say($, `Deleted the preset "${name}".`)
278}
279
280async function applyToApp($: EngineInterface): Promise<void> {
281 try {
282 const userFolder = await $.env.get('HOME')
283 const row = (await $.config.list()).find(one => one.key === 'theme')
284 const current = typeof row?.value === 'string' ? row.value : 'dark'
285 const chosen = await read($, prefs)
286 const previous = current === THEME_REF ? (chosen.previousTheme ?? 'dark') : current
287 const isLightBase = previous.startsWith('light')
288
289 if (userFolder === undefined) {
290 await say($, 'Could not find your home folder, so no theme was written.')
291
292 return
293 }
294
295 await $.fs.write(
296 `${userFolder}/.claude/themes/${THEME_SLUG}.json`,
297 `${JSON.stringify(
298 {
299 name: 'Claude Clubhouse',
300 base: isLightBase ? 'light' : 'dark',
301 overrides: themeOverrides(chosen.palette, isLightBase),
302 },
303 null,
304 2,
305 )}\n`,
306 )
307 await keep($, held => ({ ...held, previousTheme: previous }))
308
309 if (current === THEME_REF) {
310 await $.config.set({ key: 'theme', value: previous })
311 }
312
313 const set = await $.config.set({ key: 'theme', value: THEME_REF })
314 await say(
315 $,
316 set.deny === undefined
317 ? 'Applied to Claude Code. If nothing changed here, this app does not read the theme; Undo puts it back.'
318 : `The theme file was written, but switching to it was refused: ${set.deny}`,
319 )
320 } catch (error) {
321 await say($, `Could not apply the theme: ${reason(error)}`)
322 }
323}
324
325async function undoApp($: EngineInterface): Promise<void> {
326 try {
327 const { previousTheme } = await read($, prefs)
328
329 if (previousTheme === null) {
330 await say($, 'Nothing to undo: the Clubhouse has not changed your theme.')
331
332 return
333 }
334
335 const set = await $.config.set({ key: 'theme', value: previousTheme })
336 await say(
337 $,
338 set.deny === undefined
339 ? `Theme is back to ${previousTheme}.`
340 : `Could not switch back: ${set.deny}`,
341 )
342 } catch (error) {
343 await say($, `Could not undo: ${reason(error)}`)
344 }
345}
346
347async function describeLook($: EngineInterface, wish: string): Promise<void> {
348 const asked = wish.trim()
349
350 if (asked === '') return
351 await say($, `Asking for a "${asked}" look…`)
352
353 try {
354 const reply = await $.model.complete({
355 model: LOOK_MODEL,
356 system: LOOK_SYSTEM,
357 prompt: asked,
358 maxTokens: 120,
359 })
360
361 if (!reply.isAnswered) {
362 await say($, `The model did not answer (${reply.reason}).`)
363
364 return
365 }
366
367 const found: unknown = JSON.parse(reply.text.match(/\{[^{}]*\}/)?.[0] ?? 'null')
368 const pick = (key: string) => {
369 const value = isRecord(found) ? found[key] : null
370
371 return typeof value === 'string' ? normalizeHex(value) : null
372 }
373 const accent = pick('accent')
374 const clawd = pick('clawd')
375 const background = pick('background')
376
377 if (accent === null || clawd === null || background === null) {
378 await say($, 'The model answered, but not with three colors. Try wording it differently.')
379
380 return
381 }
382
383 await keep($, held => ({ ...held, palette: { accent, clawd, background, text: null } }))
384 await say($, `Set to accent ${accent}, Clawd ${clawd}, background ${background}.`)
385 } catch (error) {
386 await say($, `Could not build that look: ${reason(error)}`)
387 }
388}
389
390async function shareWindowTint($: EngineInterface, isUndo: boolean): Promise<void> {
391 try {
392 const { background } = (await read($, prefs)).palette
393
394 if (!isUndo && background === null) {
395 await say($, 'Pick a background color first: press Background above, then choose a color.')
396
397 return
398 }
399
400 const text = isUndo || background === null ? WINDOW_TINT_UNDO : windowTintSnippet(background)
401 const isCopied =
402 (await $.ui.copy({ text }).then(
403 copied => copied.isCopied,
404 () => false,
405 )) ||
406 (await $.process.run(['pbcopy'], { stdin: text }).then(
407 ran => ran.exitCode === 0,
408 () => false,
409 ))
410
411 if (isCopied) {
412 await say(
413 $,
414 isUndo
415 ? 'Undo copied. Paste it in the Console window and press Return.'
416 : 'App colors copied. Paste them in the Console window and press Return.',
417 )
418
419 return
420 }
421
422 const userFolder = await $.env.get('HOME')
423
424 if (userFolder === undefined) {
425 await say($, 'Could not reach the clipboard or your home folder.')
426
427 return
428 }
429
430 await $.fs.write(`${userFolder}/${TINT_FILE}`, `${text}\n`)
431 await say($, `Could not reach the clipboard, so it is saved in ~/${TINT_FILE}. Open that file and copy its one line.`)
432 } catch (error) {
433 await say($, `Could not prepare the app colors: ${reason(error)}`)
434 }
435}
436
437async function takeHex($: EngineInterface, slot: PaletteSlot, typed: string): Promise<void> {
438 const hex = normalizeHex(typed)
439
440 if (hex === null) {
441 await say($, `"${typed}" is not a hex color. Try something like #d97757.`)
442
443 return
444 }
445
446 await keep($, held => ({ ...held, palette: { ...held.palette, [slot]: hex } }))
447 await say($, `${slot} set to ${hex}.`)
448}
449
450export function colors(on: On): void {
451 on('ui.render', { component: 'Pane', requestId: 'clubhouse-colors' }, async ($, e) => {
452 const elements = $.ui.resolve(e)
453 const { Box, Text } = elements
454 const chosen = await read($, prefs)
455 const view = await read($, colorsView)
456 const slot = view.slot
457 const current = colorOf(chosen, slot)
458 const { look, ink, frame, note, plain, title, heading, picture, swatch, Button, Input } = makeParts(
459 elements,
460 chosen,
461 e.surface,
462 )
463 const setSlot = (hex: string | null) =>
464 void keep($, held => ({ ...held, palette: { ...held.palette, [slot]: hex } }))
465 const nudge = (change: Partial<Hsl>) =>
466 void keep($, held => ({
467 ...held,
468 palette: { ...held.palette, [slot]: shift(colorOf(held, slot), change) },
469 }))
470 const isHardToRead =
471 coversApp(chosen) && contrast(backdropOf(chosen), inkOf(chosen)) < COMFORTABLE_CONTRAST
472 const mine = await read($, colorPresets)
473 const folder = await read($, projectRoot)
474 const isOwn = (await read($, sharedPalette)) !== null
475
476 return (
477 <Box flexDirection="column" gap={1} {...frame}>
478 {title('Colors')}
479 {note(
480 'Pick the colors of everything the Clubhouse draws. Choose what to color, then nudge it, pick a preset, type a hex code or describe a look. /clubhouse colors opens this.',
481 )}
482
483 <Box gap={1} flexWrap="wrap">
484 <Button key="reset-colors" label="Reset all to default" onPress={() => void keep($, resetLook)} />
485 <Button
486 key="sidebar"
487 label={chosen.coversSidebar === true ? 'Sidebar: your color too' : 'Sidebar: the app\'s own look'}
488 variant={chosen.coversSidebar === true ? 'primary' : 'secondary'}
489 onPress={() => void keep($, held => ({ ...held, coversSidebar: held.coversSidebar !== true }))}
490 />
491 {folder !== '' && (
492 <Button
493 key="project-colors"
494 label={isOwn ? `${projectName(folder)}: its own colors` : `${projectName(folder)}: shared colors`}
495 variant={isOwn ? 'primary' : 'secondary'}
496 onPress={() => void keepForProject($, !isOwn)}
497 />
498 )}
499 </Box>
500 {note('The Sidebar button extends your color over the list of sessions on the left, so the whole Claude window matches.')}
501 {folder !== '' &&
502 note(
503 isOwn
504 ? `${projectName(folder)} keeps its own colors: what you pick here stays with this project, and the window takes these colors when you open or use one of its sessions.`
505 : `Press the ${projectName(folder)} button to give this project its own colors. Until then it uses the shared ones.`,
506 )}
507 {note(
508 !chosen.isHelperReady
509 ? 'The helper that colors the whole window is not built on this Mac yet, so the Background only colors the Clubhouse and the conversation rows. Press Build the helper: it takes under a minute and you only do it once.'
510 : 'Your Background is the color of the whole session: the conversation, the toolbar, the text box and the Clubhouse rooms. These colors are the same in every session, unless a project keeps its own.',
511 )}
512 <Box gap={1} flexWrap="wrap">
513 <Button
514 key="build-helper"
515 label={view.isBuilding === true ? 'Building\u2026' : chosen.isHelperReady ? 'Rebuild the helper' : 'Build the helper'}
516 variant={chosen.isHelperReady ? 'secondary' : 'primary'}
517 onPress={() => void buildHelper($)}
518 />
519 <Button
520 key="build-why"
521 label={view.isWhyOpen === true ? '▾ Why is this needed?' : '▸ Why is this needed?'}
522 onPress={() => void update($, colorsView, held => ({ ...held, isWhyOpen: held.isWhyOpen !== true }))}
523 />
524 </Box>
525 {(view.helperNote ?? null) !== null && plain(view.helperNote ?? '')}
526 {view.isWhyOpen === true && note(BUILD_WHY)}
527 {chosen.isHelperReady && note(BUILD_AGAIN)}
528 {swapsLightAndDark(chosen) &&
529 note(
530 `This is a ${chosen.appMode === 'dark' ? 'light' : 'dark'} color on a ${chosen.appMode} app, so the helper swaps light and dark across the session to keep text readable. Pictures in the conversation swap too. Switching the Claude app itself to ${chosen.appMode === 'dark' ? 'light' : 'dark'} mode avoids that, and the Clubhouse notices on its own.`,
531 )}
532 {isHardToRead &&
533 note(
534 'On a mid-bright background like this, neither dark nor light text stands out strongly, and Clawd and the usage bar take lighter shades of their colors. A darker or lighter background reads best.',
535 )}
536
537 {heading('What to color')}
538 {SLOTS.map(([id, label, about]) => (
539 <Box flexDirection="column">
540 <Box gap={1} alignItems="center">
541 <Button
542 key={`slot-${id}`}
543 label={label}
544 variant={slot === id ? 'primary' : 'secondary'}
545 onPress={() => void update($, colorsView, held => ({ ...held, slot: id }))}
546 />
547 {swatch(colorOf(chosen, id))}
548 </Box>
549 {note(about)}
550 {Input === null ? (
551 <Text {...ink}>{chosen.palette[id] ?? 'app default'}</Text>
552 ) : (
553 <Input
554 key={`hex-${id}`}
555 label="Code"
556 placeholder="app default"
557 value={chosen.palette[id] ?? ''}
558 submitLabel="Confirm"
559 onSubmit={typed => void takeHex($, id, typed)}
560 />
561 )}
562 <Box justifyContent="flex-end">
563 <Button
564 key={`reset-${id}`}
565 label="Reset"
566 onPress={() =>
567 void keep($, held => ({ ...held, palette: { ...held.palette, [id]: DEFAULT_PALETTE[id] } }))
568 }
569 />
570 </Box>
571 </Box>
572 ))}
573
574 {heading(`Adjust ${slot}`)}
575 <Box gap={2} alignItems="center">
576 {picture(
577 wheelSvg({ hue: toHsl(current).hue, color: current, size: WHEEL_SIZE }),
578 `Color wheel showing ${current}`,
579 WHEEL_SIZE,
580 WHEEL_SIZE,
581 )}
582 {picture(
583 clawdSvg('happy', look.clawd, CLAWD_UNIT),
584 'Clawd in the chosen color',
585 clawdBox(CLAWD_UNIT).width,
586 clawdBox(CLAWD_UNIT).height,
587 )}
588 </Box>
589 <Box gap={1} flexWrap="wrap">
590 {NUDGES.map(([key, label, change]) => (
591 <Button key={key} label={label} onPress={() => nudge(change)} />
592 ))}
593 </Box>
594
595 {heading('Presets')}
596 {note('Each preset sets all three colors at once.')}
597 <Box gap={1} flexWrap="wrap">
598 {PRESETS.map(([name, palette]) => (
599 <Button key={`preset-${name}`} label={name} onPress={() => void keep($, held => ({ ...held, palette }))} />
600 ))}
601 {LOOKS.map(([key, label, palette]) => (
602 <Button key={key} label={label} onPress={() => void keep($, held => ({ ...held, palette }))} />
603 ))}
604 </Box>
605
606 {heading('Your presets')}
607 {note('Found colors you like? Give them a name and save them. A preset keeps all three colors as they are right now.')}
608 {Input !== null && (
609 <Input
610 key="preset-name"
611 label="Name"
612 placeholder="Game day"
613 submitLabel="Save current colors"
614 onSubmit={typed => void savePreset($, typed)}
615 />
616 )}
617 {mine.map(one => (
618 <Box gap={1} flexWrap="wrap" alignItems="center">
619 <Button
620 key={`mine-${one.name}`}
621 label={one.name}
622 variant="primary"
623 onPress={() => void keep($, held => ({ ...held, palette: one.palette }))}
624 />
625 {swatch(one.palette.background ?? backdropOf(chosen))}
626 {swatch(one.palette.accent)}
627 {swatch(one.palette.clawd)}
628 <Button key={`mine-delete-${one.name}`} label="Delete" onPress={() => void dropPreset($, one.name)} />
629 </Box>
630 ))}
631
632 {heading(`One color for ${slot}`)}
633 {note("Anthropic's own palette: warm ivory and oat neutrals with one clay accent. A press changes only the color chosen under What to color.")}
634 <Box gap={1} flexWrap="wrap">
635 {ANTHROPIC.map(([name, hex]) => (
636 <Button key={`anthropic-${name}`} label={name} onPress={() => setSlot(hex)} />
637 ))}
638 {slot === 'background' && <Button key="preset-none" label="None" onPress={() => setSlot(null)} />}
639 </Box>
640
641 {Input !== null && (
642 <Box flexDirection="column" gap={1}>
643 {heading('Describe a look')}
644 {note('A small model picks all three colors from your words. Costs a few tokens.')}
645 <Input
646 key="look"
647 label="Look"
648 placeholder="cozy autumn cabin"
649 submitLabel="Make it"
650 onSubmit={wish => void describeLook($, wish)}
651 />
652 </Box>
653 )}
654
655 {heading('How the session gets its color')}
656 {note(`Claude Code can only paint its own rows and panels, so the Clubhouse runs a small helper on your Mac. Wherever the session shows the app's plain background, the helper swaps in your Background color, and it moves text, icons and borders to your Text color. It notices on its own whether the Claude app is in dark or light mode (${chosen.appMode} right now), and it stops when you reset, switch the Clubhouse off or quit Claude.`)}
657
658 <Box>
659 <Button
660 key="advanced"
661 label={view.isAdvancedOpen ? '▾ Advanced' : '▸ Advanced'}
662 onPress={() => void update($, colorsView, held => ({ ...held, isAdvancedOpen: !held.isAdvancedOpen }))}
663 />
664 </Box>
665 {view.isAdvancedOpen && (
666 <Box flexDirection="column" gap={1}>
667 {heading('Repaint the app itself')}
668 {note('The Claude app has a Developer Mode that lets you change its real colors by hand. It lasts until the app restarts, then you do steps 2 and 3 again.')}
669 {note('1. Look at the very top of your screen, the strip that starts with the Apple logo. Click Help, then Troubleshooting, then Enable Developer Mode, and confirm. You only do this once.')}
670 {note('2. Hold Option and Command and press I. A new window opens. Click the word Console near its top.')}
671 {note('3. Press Copy app colors below. Click in the big empty area of that new window, hold Command and press V, then press Return. If it asks, type the words allow pasting, press Return, and paste again.')}
672 <Box gap={1} flexWrap="wrap">
673 <Button key="window-tint" label="Copy app colors" onPress={() => void shareWindowTint($, false)} />
674 <Button key="window-undo" label="Copy undo" onPress={() => void shareWindowTint($, true)} />
675 </Box>
676 {heading('Claude Code in a terminal')}
677 {note('In a terminal, Claude Code draws everything itself, so this theme recolors its accents, borders and panels there.')}
678 <Box gap={1} flexWrap="wrap">
679 <Button key="apply-app" label="Apply to Claude Code theme" onPress={() => void applyToApp($)} />
680 <Button key="undo-app" label="Undo" onPress={() => void undoApp($)} />
681 </Box>
682 </Box>
683 )}
684 {view.note !== null && plain(view.note)}
685 </Box>
686 )
687 })
688}
689hooks/features/commands.tsx 541 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { HiddenPlan, Prefs } from '../../types'
5import { HELPER_CONFIG, START_HELPER, coversApp, helperConfig } from '../lib/appColor'
6import { groupCommands, withoutHiddenSkills } from '../lib/commands'
7import type { ListedCommand } from '../lib/commands'
8import {
9 COMMANDS_KEY,
10 DEFAULT_COMMANDS_VIEW,
11 DEFAULT_HIDDEN_PLAN,
12 DEFAULT_PREFS,
13 HIDDEN_KEY,
14 HIDDEN_PLAN_KEY,
15 HOME_PANE,
16 PREFS_KEY,
17 PREFS_SHAPE,
18 ROOMS,
19 TODO_PANE,
20 resetLook,
21 topCommands,
22} from '../lib/defaults'
23import { makeParts } from '../lib/parts'
24import {
25 MAIN_BRANCHES,
26 SHIP_CANCEL,
27 SHIP_IT,
28 nextVersion,
29 notesRequest,
30 plainNotes,
31 shipQuestion,
32 subjectsOf,
33} from '../lib/ship'
34import { forStore } from '../lib/project'
35import {
36 BUILD_DONE,
37 BUILD_NEEDS_TOOLS,
38 BUILD_NO_FOLDER,
39 BUILD_SCRIPT,
40 BUILD_TIMEOUT_MS,
41 FIND_COMPILER,
42 STOP_HELPER,
43 buildFailed,
44} from '../lib/build'
45import { MARKETPLACE_LIST, installedFolder, ownFolder } from '../lib/own'
46import { taskProblem, taskWish, withTask } from '../lib/todo'
47
48const commandStats = atom({ plugin: 'clubhouse', key: 'commandStats' } as const, {})
49const commandsView = atom({ plugin: 'clubhouse', key: 'commandsView' } as const, DEFAULT_COMMANDS_VIEW)
50const hiddenCommands = atom({ plugin: 'clubhouse', key: 'hiddenCommands' } as const, [])
51const hiddenPlan = atom({ plugin: 'clubhouse', key: 'hiddenPlan' } as const, DEFAULT_HIDDEN_PLAN)
52const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
53 shape: PREFS_SHAPE,
54})
55const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
56const pulse = atom({ plugin: 'clubhouse', key: 'pulse' } as const, 0)
57const todos = atom({ plugin: 'clubhouse', key: 'todos' } as const, [])
58
59const TOP_SIZE = 5
60const MATCH_SIZE = 40
61const ABOUT_CHARS = 140
62const INTERNAL_PREFIX = '__'
63
64let presetName = ''
65
66async function keep($: EngineInterface, change: (held: Prefs) => Prefs): Promise<void> {
67 await update($, prefs, change)
68 const chosen = await read($, prefs)
69 await $.store.set(PREFS_KEY, forStore(chosen, await read($, sharedPalette)))
70 const userFolder = await $.env.get('HOME')
71
72 if (userFolder === undefined) return
73 await $.fs.write(`${userFolder}/${HELPER_CONFIG}`, helperConfig(chosen)).catch(() => undefined)
74
75 if (coversApp(chosen)) {
76 await $.process.run(['/bin/sh', '-c', START_HELPER]).catch(() => undefined)
77 }
78}
79
80async function count($: EngineInterface, name: string): Promise<void> {
81 try {
82 const at = await $.clock.now()
83 await update($, commandStats, stats => ({
84 ...stats,
85 [name]: { count: (stats[name]?.count ?? 0) + 1, lastAt: at },
86 }))
87 await $.store.set(COMMANDS_KEY, await read($, commandStats))
88 } catch {
89 return
90 }
91}
92
93async function offer($: EngineInterface, name: string): Promise<void> {
94 try {
95 const { isFilled } = await $.prompt.fill({ text: `/${name} ` })
96 await update($, commandsView, view => ({
97 ...view,
98 note: isFilled
99 ? `/${name} is in the prompt box. Press Enter to run it.`
100 : `The prompt box is busy. Type /${name} yourself.`,
101 }))
102 } catch (error) {
103 const why = error instanceof Error ? error.message : 'unknown error'
104 await update($, commandsView, view => ({
105 ...view,
106 note: `Could not fill the prompt box (${why}). Type /${name} yourself.`,
107 }))
108 }
109}
110
111async function visit($: EngineInterface, id: string, title: string): Promise<void> {
112 await $.ui.open({ id, title, focus: true, closeOnEscape: true })
113 await update($, pulse, beat => beat + 1)
114}
115
116async function jot($: EngineInterface, task: string): Promise<string> {
117 const problem = taskProblem(task, await read($, todos))
118
119 if (problem !== null) return problem
120 await update($, todos, held => withTask(held, task))
121
122 return `Added to your to-do list: ${task}`
123}
124
125async function setHidden(
126 $: EngineInterface,
127 change: (hidden: string[]) => string[],
128 note: string,
129): Promise<void> {
130 await update($, hiddenCommands, change)
131 await $.store.set(HIDDEN_KEY, await read($, hiddenCommands))
132 await update($, commandsView, view => ({ ...view, note }))
133 $.ui.invalidate('command.describe')
134 $.ui.invalidate('prompt.attachment')
135}
136
137async function setPlan(
138 $: EngineInterface,
139 change: (plan: HiddenPlan) => HiddenPlan,
140 note: string,
141): Promise<void> {
142 await update($, hiddenPlan, change)
143 await $.store.set(HIDDEN_PLAN_KEY, await read($, hiddenPlan))
144 await update($, commandsView, view => ({ ...view, note }))
145}
146
147async function savePreset($: EngineInterface): Promise<void> {
148 const name = presetName.trim()
149 const hidden = await read($, hiddenCommands)
150
151 if (name === '') {
152 await update($, commandsView, view => ({
153 ...view,
154 note: 'Type a name for the preset, press Enter, then press Save as preset.',
155 }))
156
157 return
158 }
159
160 await setPlan(
161 $,
162 plan => ({ ...plan, presets: { ...plan.presets, [name]: hidden } }),
163 `Saved "${name}" with ${hidden.length} hidden.`,
164 )
165 presetName = ''
166}
167
168async function git($: EngineInterface, args: readonly string[]): Promise<{ isDone: boolean; out: string }> {
169 const ran = await $.process.run(['git', ...args]).catch(() => null)
170
171 return { isDone: ran !== null && ran.exitCode === 0, out: (ran?.stdout ?? '').trim() }
172}
173
174async function ship($: EngineInterface, wish: string): Promise<string> {
175 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
176
177 if (!branch.isDone) return 'This session\'s folder is not a git repository, so there is nothing to ship.'
178
179 if (!MAIN_BRANCHES.includes(branch.out)) {
180 return `You are on the branch ${branch.out}. Releases go out from main: ask Claude to merge it, then run /ship again.`
181 }
182
183 if ((await git($, ['status', '--porcelain'])).out !== '') {
184 return 'There are changes that are not committed yet. Commit them first, then run /ship again.'
185 }
186
187 const last = await git($, ['describe', '--tags', '--abbrev=0'])
188 const version = nextVersion(last.isDone ? last.out : null, wish)
189
190 if (version === null) return `"${wish}" is not a version. Use /ship, /ship minor, /ship major, or a number like /ship v1.2.0.`
191 const log = await git($, ['log', '--pretty=%s', '-n', '60', last.isDone ? `${last.out}..HEAD` : 'HEAD'])
192 const subjects = subjectsOf(log.out)
193
194 if (subjects.length === 0) return `Nothing has changed since ${last.isDone ? last.out : 'the start'}, so there is nothing to ship.`
195 const reply = await $.model.complete(notesRequest(subjects)).catch(() => null)
196 const notes = reply !== null && reply.isAnswered && reply.text.trim() !== '' ? reply.text.trim() : plainNotes(subjects)
197 const answer = await $.ui
198 .ask(shipQuestion(version, branch.out, notes), { header: 'Ship', options: [SHIP_IT, SHIP_CANCEL] })
199 .catch(() => SHIP_CANCEL)
200
201 if (answer !== SHIP_IT) return 'Nothing was shipped.'
202 if (!(await git($, ['tag', version])).isDone) return `Could not make the tag ${version}. It may already exist.`
203
204 if (!(await git($, ['push', 'origin', branch.out, version])).isDone) {
205 await git($, ['tag', '-d', version])
206
207 return `Could not push to GitHub, so ${version} was not shipped and the tag was removed again.`
208 }
209
210 const made = await $.process
211 .run(['gh', 'release', 'create', version, '--title', version, '--notes', notes])
212 .catch(() => null)
213
214 return made !== null && made.exitCode === 0
215 ? `Shipped ${version}: ${made.stdout.trim()}`
216 : `${version} is tagged and pushed, but the GitHub release could not be created. Run: gh release create ${version}`
217}
218
219async function copyFolder($: EngineInterface): Promise<string | null> {
220 const own = ownFolder(await $.env.get('CLAUDE_CODE_PLUGIN_DIRS'))
221
222 if (own !== null) return own
223 const settings = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
224
225 return installedFolder(await $.fs.read(`${settings}/${MARKETPLACE_LIST}`).catch(() => ''))
226}
227
228async function build($: EngineInterface): Promise<string> {
229 const folder = await copyFolder($)
230
231 if (folder === null) return BUILD_NO_FOLDER
232 const compiler = await $.process.run(FIND_COMPILER).catch(() => null)
233
234 if (compiler === null || compiler.exitCode !== 0) return BUILD_NEEDS_TOOLS
235 const built = await $.process.run(['/bin/sh', `${folder}/${BUILD_SCRIPT}`], { timeoutMs: BUILD_TIMEOUT_MS }).catch(() => null)
236
237 if (built === null || built.exitCode !== 0) {
238 return buildFailed(built === null ? '' : `${built.stdout}\n${built.stderr}`)
239 }
240
241 await $.process.run(STOP_HELPER).catch(() => undefined)
242 await keep($, held => ({ ...held, isHelperReady: true }))
243
244 return BUILD_DONE
245}
246
247export function commands(on: On): void {
248 on('command.run', async ($, e, next) => {
249 await count($, e.command)
250
251 if (e.command === 'ship') {
252 return { text: await ship($, e.args) }
253 }
254
255 if (e.command !== 'clubhouse') {
256 return next(e)
257 }
258
259 const wish = e.args.trim().toLowerCase()
260
261 if (wish === 'ship' || wish.startsWith('ship ')) {
262 return { text: await ship($, wish.slice('ship'.length)) }
263 }
264
265 if (wish === 'on' || wish === 'off') {
266 await keep($, held => ({ ...held, isEnabled: wish === 'on' }))
267
268 return {
269 text:
270 wish === 'on'
271 ? 'Clubhouse is on.'
272 : 'Clubhouse is off. Type /clubhouse on to bring it back.',
273 }
274 }
275
276 if (wish === 'build') {
277 return { text: await build($) }
278 }
279
280 if (wish === 'color reset' || wish === 'colors reset') {
281 await keep($, resetLook)
282
283 return { text: 'Clubhouse colors are back to their defaults.' }
284 }
285
286 const task = taskWish(e.args)
287
288 if (task !== null && task !== '') {
289 const said = await jot($, task)
290 await visit($, TODO_PANE, 'To-do')
291
292 return { text: said }
293 }
294
295 const alias = wish === 'hq' ? 'agents' : wish === 'bar' ? 'toolbar' : task === '' ? 'to-do' : wish
296 const room = ROOMS.find(one => one.word === alias)
297
298 if (room !== undefined) {
299 await visit($, room.id, room.title)
300
301 return { text: `${room.title} opened.` }
302 }
303
304 await visit($, HOME_PANE, 'Clubhouse')
305
306 return { text: 'Clubhouse opened.' }
307 })
308
309 on('command.describe', async ($, e, next) => {
310 const hidden = await read($, hiddenCommands)
311
312 return hidden.includes(e.command) ? next({ ...e, isHidden: true }) : next(e)
313 })
314
315 on('prompt.attachment', { type: 'skill_listing' }, async ($, e, next) => {
316 const hidden = await read($, hiddenCommands)
317
318 return next({ ...e, text: withoutHiddenSkills(e.text, hidden) })
319 })
320
321 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
322 const hidden = await read($, hiddenCommands)
323
324 if (hidden.includes(e.skill)) {
325 return { deny: `The user hid the skill "${e.skill}" in Claude Clubhouse. Do the task without it.` }
326 }
327
328 return next(e)
329 })
330
331 on('ui.render', { component: 'Pane', requestId: 'clubhouse-commands' }, async ($, e) => {
332 const elements = $.ui.resolve(e)
333 const { Box } = elements
334 const chosen = await read($, prefs)
335 const stats = await read($, commandStats)
336 const view = await read($, commandsView)
337 const hidden = await read($, hiddenCommands)
338 const plan = await read($, hiddenPlan)
339 const all: ListedCommand[] = (await $.command.list()).filter(one => !one.name.startsWith(INTERNAL_PREFIX))
340 const groups = groupCommands(all, hidden)
341 const wanted = view.filter.trim().toLowerCase().replace(/^\//, '')
342 const matching = all.filter(
343 one =>
344 !hidden.includes(one.name) &&
345 (one.name.toLowerCase().includes(wanted) || one.description.toLowerCase().includes(wanted)),
346 )
347 const { card, frame, note, plain, title, Button, Input } = makeParts(elements, chosen, e.surface)
348
349 const setFilter = (typed: string) =>
350 void update($, commandsView, held => ({ ...held, filter: typed }))
351 const toggleGroup = (id: string) =>
352 void update($, commandsView, held => ({
353 ...held,
354 open: held.open.includes(id) ? held.open.filter(one => one !== id) : [...held.open, id],
355 }))
356 const hide = (names: readonly string[], label: string) =>
357 void setHidden(
358 $,
359 held => [...new Set([...held, ...names])],
360 `${label} hidden. Claude no longer sees or uses ${names.length === 1 ? 'it' : 'them'}; undo under Hidden skills.`,
361 )
362 const unhide = (name: string) =>
363 void setHidden($, held => held.filter(one => one !== name), `/${name} is back.`)
364
365 const entry = (one: ListedCommand, canHide: boolean) => (
366 <Box flexDirection="column">
367 <Box gap={1}>
368 <Button key={`run-${one.name}`} label={`/${one.name}`} onPress={() => void offer($, one.name)} />
369 {canHide && (
370 <Button key={`hide-${one.name}`} label="Hide" onPress={() => hide([one.name], `/${one.name}`)} />
371 )}
372 </Box>
373 {one.description !== '' && note(one.description.slice(0, ABOUT_CHARS))}
374 </Box>
375 )
376
377 return (
378 <Box flexDirection="column" gap={1} {...frame}>
379 {title('Commands')}
380 {note(
381 'Everything you can type after a slash, sorted into groups. Click a group to open it and a command to put it in the prompt box. /clubhouse commands opens this.',
382 )}
383
384 {card('Most used and most recent', [
385 <Box gap={1} flexWrap="wrap">
386 {topCommands(stats, TOP_SIZE).map(name => (
387 <Button key={`top-${name}`} label={`/${name}`} onPress={() => void offer($, name)} />
388 ))}
389 </Box>,
390 ])}
391
392 {card('About hiding', [
393 note('Hide puts a skill, or a whole group, out of the way. It leaves the slash menu, Claude is no longer told it exists (which saves a little context every session), and Claude is refused if it tries to use it.'),
394 note('Nothing is deleted from your computer, and it stays hidden in every session until you show it again under Hidden skills at the bottom.'),
395 note('Claude in a session already under way has seen the full list, so for Claude the change starts with the next session or after /clear.'),
396 note('To remove a whole plugin and everything it adds, ask Claude to turn that plugin off.'),
397 ])}
398
399 {Input !== null && (
400 <Input
401 key="filter"
402 label="Find"
403 placeholder="part of a name or what it does"
404 submitLabel="Find"
405 onInput={setFilter}
406 onSubmit={setFilter}
407 />
408 )}
409 {view.note !== null && plain(view.note)}
410
411 {wanted !== '' &&
412 card(
413 `Matching "${wanted}" (${matching.length})`,
414 <Box flexDirection="column" gap={1}>
415 {matching.slice(0, MATCH_SIZE).map(one => entry(one, one.source !== 'builtin'))}
416 {matching.length > MATCH_SIZE && note(`${matching.length - MATCH_SIZE} more. Type more to narrow it.`)}
417 </Box>,
418 )}
419
420 {wanted === '' &&
421 groups.map(group => {
422 const isOpen = view.open.includes(group.id)
423
424 return (
425 <Box flexDirection="column" gap={1}>
426 <Box gap={1}>
427 <Button
428 key={`group-${group.id}`}
429 label={`${isOpen ? '▾' : '▸'} ${group.title} (${group.commands.length})`}
430 variant={isOpen ? 'primary' : 'secondary'}
431 onPress={() => toggleGroup(group.id)}
432 />
433 {isOpen && group.canHide && (
434 <Button
435 key={`hideall-${group.id}`}
436 label="Hide all"
437 onPress={() =>
438 hide(
439 group.commands.map(one => one.name),
440 `All ${group.commands.length} in ${group.title}`,
441 )
442 }
443 />
444 )}
445 </Box>
446 {isOpen &&
447 card(
448 group.title,
449 <Box flexDirection="column" gap={1}>
450 {group.commands.map(one => entry(one, group.canHide))}
451 </Box>,
452 )}
453 </Box>
454 )
455 })}
456
457 {wanted === '' &&
458 card(`Hidden skills (${hidden.length})`, [
459 note('Hidden in every session: gone from the slash menu, and Claude is not told about them and cannot use them.'),
460 hidden.length > 0 && (
461 <Box gap={1} flexWrap="wrap">
462 {hidden.map(name => (
463 <Button key={`unhide-${name}`} label={`Show /${name}`} onPress={() => unhide(name)} />
464 ))}
465 <Button
466 key="unhide-all"
467 label="Show all"
468 onPress={() => void setHidden($, () => [], 'Everything is showing again.')}
469 />
470 </Box>
471 ),
472 ])}
473
474 {wanted === '' &&
475 card('Presets', [
476 note('A preset is a saved list of what to hide. Save the list you have now under a name, and switch between presets whenever you like. Turn on "every session" for one and each new session starts with that list.'),
477 Input !== null && (
478 <Input
479 key="preset-name"
480 label="Name"
481 placeholder="coding"
482 submitLabel="Set"
483 onInput={typed => {
484 presetName = typed
485 }}
486 onSubmit={typed => {
487 presetName = typed
488 }}
489 />
490 ),
491 <Box>
492 <Button key="preset-save" label="Save as preset" variant="primary" onPress={() => void savePreset($)} />
493 </Box>,
494 ...Object.entries(plan.presets).map(([name, names]) => (
495 <Box flexDirection="column">
496 {plain(`${name} (${names.length} hidden)`)}
497 <Box gap={1} flexWrap="wrap">
498 <Button
499 key={`preset-use-${name}`}
500 label="Use now"
501 onPress={() => void setHidden($, () => names, `Now using "${name}".`)}
502 />
503 <Button
504 key={`preset-start-${name}`}
505 label={plan.startWith === name ? 'Every session: on' : 'Every session: off'}
506 variant={plan.startWith === name ? 'primary' : 'secondary'}
507 onPress={() =>
508 void setPlan(
509 $,
510 held => ({ ...held, startWith: held.startWith === name ? null : name }),
511 plan.startWith === name
512 ? `New sessions no longer start with "${name}".`
513 : `Every new session will start with "${name}".`,
514 )
515 }
516 />
517 <Button
518 key={`preset-delete-${name}`}
519 label="Delete"
520 onPress={() =>
521 void setPlan(
522 $,
523 held => ({
524 presets: Object.fromEntries(
525 Object.entries(held.presets).filter(([other]) => other !== name),
526 ),
527 startWith: held.startWith === name ? null : held.startWith,
528 }),
529 `Deleted "${name}".`,
530 )
531 }
532 />
533 </Box>
534 </Box>
535 )),
536 ])}
537 </Box>
538 )
539 })
540}
541hooks/features/home.tsx 225 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { HomeTab, Prefs } from '../../types'
5import { HELPER_CONFIG, START_HELPER, coversApp, helperConfig } from '../lib/appColor'
6import { DEFAULT_PREFS, PREFS_KEY, PREFS_SHAPE, ROOMS, topCommands } from '../lib/defaults'
7import { cacheNote, receiptNote } from '../lib/format'
8import { homeIconSvg } from '../lib/icon'
9import { makeParts } from '../lib/parts'
10import { forStore } from '../lib/project'
11import { ownFolder, shortFolder } from '../lib/own'
12
13const commandStats = atom({ plugin: 'clubhouse', key: 'commandStats' } as const, {})
14const contextPercent = atom({ plugin: 'clubhouse', key: 'contextPercent' } as const, null)
15const lastReplyAt = atom({ plugin: 'clubhouse', key: 'lastReplyAt' } as const, null)
16const limits = atom({ plugin: 'clubhouse', key: 'limits' } as const, [])
17const now = atom({ plugin: 'clubhouse', key: 'now' } as const, 0)
18const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
19 shape: PREFS_SHAPE,
20})
21const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
22const pulse = atom({ plugin: 'clubhouse', key: 'pulse' } as const, 0)
23const receipt = atom({ plugin: 'clubhouse', key: 'receipt' } as const, null)
24const tab = atom({ plugin: 'clubhouse', key: 'tab' } as const, 'home')
25
26const METER_WIDTH = 280
27const METER_HEIGHT = 40
28const LOGO_SIZE = 40
29const TOP_SIZE = 5
30
31const TABS: readonly (readonly [HomeTab, string])[] = [
32 ['home', 'Home'],
33 ['more', 'More'],
34]
35
36const PLANNED: readonly (readonly [string, string])[] = [
37]
38
39async function keep($: EngineInterface, change: (held: Prefs) => Prefs): Promise<void> {
40 await update($, prefs, change)
41 const chosen = await read($, prefs)
42 await $.store.set(PREFS_KEY, forStore(chosen, await read($, sharedPalette)))
43 const userFolder = await $.env.get('HOME')
44
45 if (userFolder === undefined) return
46 await $.fs.write(`${userFolder}/${HELPER_CONFIG}`, helperConfig(chosen)).catch(() => undefined)
47
48 if (coversApp(chosen)) {
49 await $.process.run(['/bin/sh', '-c', START_HELPER]).catch(() => undefined)
50 }
51}
52
53async function visit($: EngineInterface, id: string, title: string, isOpen: boolean): Promise<void> {
54 if (isOpen) {
55 await $.ui.close({ id })
56 } else {
57 await $.ui.open({ id, title, focus: true, closeOnEscape: true })
58 }
59
60 await update($, pulse, beat => beat + 1)
61}
62
63async function offer($: EngineInterface, name: string): Promise<void> {
64 try {
65 const { isFilled } = await $.prompt.fill({ text: `/${name} ` })
66
67 if (!isFilled) {
68 $.ui.toast(`The prompt box is busy. Type /${name} yourself.`)
69 }
70 } catch {
71 $.ui.toast(`Type /${name} in the prompt box`)
72 }
73}
74
75export function home(on: On): void {
76 on('ui.render', { component: 'Pane', requestId: 'clubhouse' }, async ($, e) => {
77 const elements = $.ui.resolve(e)
78 const { Box, Text } = elements
79 const chosen = await read($, prefs)
80 const shown = await read($, tab)
81 const at = await read($, now)
82 await read($, pulse)
83 const { ink, frame, note, title, card, meter, look, picture, Button } = makeParts(elements, chosen, e.surface)
84 const folder = ownFolder(await $.env.get('CLAUDE_CODE_PLUGIN_DIRS'))
85 const copy = folder === null ? null : shortFolder(folder, await $.env.get('HOME'))
86
87 const header = (
88 <Box flexDirection="column" gap={1}>
89 <Box gap={1} alignItems="center">
90 {picture(homeIconSvg({ size: LOGO_SIZE, accent: look.accent }), 'Claude Clubhouse', LOGO_SIZE, LOGO_SIZE)}
91 {title('Claude Clubhouse')}
92 <Button
93 key="power"
94 label={chosen.isEnabled ? 'On' : 'Off'}
95 variant={chosen.isEnabled ? 'primary' : 'secondary'}
96 onPress={() => void keep($, held => ({ ...held, isEnabled: !held.isEnabled }))}
97 />
98 </Box>
99 <Box gap={1} flexWrap="wrap">
100 {TABS.map(([id, label]) => (
101 <Button
102 key={`tab-${id}`}
103 label={label}
104 variant={shown === id ? 'primary' : 'secondary'}
105 onPress={() => void update($, tab, () => id)}
106 />
107 ))}
108 {ROOMS.filter(room => room.isLook === true).map(room => (
109 <Button key={`room-${room.word}`} label={room.title} onPress={() => void visit($, room.id, room.title, false)} />
110 ))}
111 </Box>
112 <Box flexDirection="column">
113 <Box>
114 <Button
115 key="safeguard"
116 label={chosen.warnsSafeguards === true ? 'Safeguard warning: on' : 'Safeguard warning: off'}
117 variant={chosen.warnsSafeguards === true ? 'primary' : 'secondary'}
118 onPress={() => void keep($, held => ({ ...held, warnsSafeguards: held.warnsSafeguards !== true }))}
119 />
120 </Box>
121 {note(
122 'When on, each prompt is checked before it is sent. If it looks likely to set off a safety filter (which can stop it or hand it to a more restricted model), you are told why and can edit it or send it anyway. A small model makes the guess, so it adds about a second and a few tokens per prompt.',
123 )}
124 </Box>
125 </Box>
126 )
127
128 const main = async () => {
129 const list = await read($, limits)
130 const context = await read($, contextPercent)
131 const made = await read($, receipt)
132 const last = await read($, lastReplyAt)
133 const stats = await read($, commandStats)
134 const openIds = (await $.ui.panes()).map(one => one.id)
135
136 return (
137 <Box flexDirection="column" gap={1}>
138 {card(
139 'Rooms',
140 <Box flexDirection="column" gap={1}>
141 {ROOMS.filter(room => room.isLook !== true).map(room => {
142 const isOpen = openIds.includes(room.id)
143
144 return (
145 <Box flexDirection="column">
146 <Box gap={1}>
147 <Button
148 key={`room-${room.word}`}
149 label={room.title}
150 variant={isOpen ? 'primary' : 'secondary'}
151 onPress={() => void visit($, room.id, room.title, false)}
152 />
153 {isOpen && (
154 <Button
155 key={`close-${room.word}`}
156 label="Close"
157 onPress={() => void visit($, room.id, room.title, true)}
158 />
159 )}
160 </Box>
161 {note(room.about)}
162 </Box>
163 )
164 })}
165 </Box>,
166 )}
167
168 {card('Quick commands', [
169 <Box gap={1} flexWrap="wrap">
170 {topCommands(stats, TOP_SIZE).map(name => (
171 <Button key={`top-${name}`} label={`/${name}`} onPress={() => void offer($, name)} />
172 ))}
173 </Box>,
174 note('Your most used and most recent. A click puts one in the prompt box.'),
175 ])}
176
177 {card('Usage', [
178 meter({ kind: 'five_hour', limits: list, at, width: METER_WIDTH, height: METER_HEIGHT }),
179 meter({ kind: 'seven_day', limits: list, at, width: METER_WIDTH, height: METER_HEIGHT }),
180 note(
181 `${context === null ? 'Context: no reading yet' : `Context ${context}% full`} · ${cacheNote(last, at)}`,
182 ),
183 made !== null && note(receiptNote(made)),
184 ])}
185 </Box>
186 )
187 }
188
189 const more = () => (
190 <Box flexDirection="column" gap={1}>
191 {card('How to get here', [
192 note('Click the arrow beside the house on the toolbar, or type /clubhouse. Esc closes this screen.'),
193 note('/clubhouse off hides everything the Clubhouse adds; /clubhouse on brings it back.'),
194 note('/ship releases what is on main: it works out the next version, drafts the notes, asks you, then tags, pushes and publishes on GitHub. /ship minor or /ship v1.2.0 picks the number.'),
195 note(`A room opens directly with /clubhouse and its word: ${ROOMS.map(room => room.word).join(', ')}.`),
196 ])}
197 {card('Changing the Clubhouse', [
198 note(
199 `Tell Claude in any session: "in the clubhouse, ..." and it edits your own copy${copy === null ? '' : ` in ${copy}`}. The changes stay on this computer and show up on their own.`,
200 ),
201 ])}
202 {PLANNED.length > 0 &&
203 card(
204 'Coming next',
205 <Box flexDirection="column" gap={1}>
206 {PLANNED.map(([name, about]) => (
207 <Box flexDirection="column">
208 <Text {...ink}>{name}</Text>
209 {note(about)}
210 </Box>
211 ))}
212 </Box>,
213 )}
214 </Box>
215 )
216
217 return (
218 <Box flexDirection="column" gap={1} {...frame}>
219 {header}
220 {shown === 'more' ? more() : await main()}
221 </Box>
222 )
223 })
224}
225hooks/features/fonts.tsx 176 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { FontChoice, Prefs } from '../../types'
5import { DEFAULT_PREFS, PREFS_KEY, PREFS_SHAPE } from '../lib/defaults'
6import {
7 DEFAULT_FONT,
8 DESIGN_FILE_CHARS,
9 FONTS,
10 FONT_PRESETS_KEY,
11 designRequest,
12 fontFrom,
13 fontRequest,
14 withFontPreset,
15} from '../lib/fonts'
16import { makeParts } from '../lib/parts'
17import { forStore } from '../lib/project'
18
19const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
20 shape: PREFS_SHAPE,
21})
22const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
23const fontPresets = atom({ plugin: 'clubhouse', key: 'fontPresets' } as const, [])
24const fontsView = atom({ plugin: 'clubhouse', key: 'fontsView' } as const, { note: null })
25
26async function say($: EngineInterface, note: string): Promise<void> {
27 await update($, fontsView, () => ({ note }))
28}
29
30async function keep($: EngineInterface, change: (held: Prefs) => Prefs): Promise<void> {
31 await update($, prefs, change)
32 await $.store.set(PREFS_KEY, forStore(await read($, prefs), await read($, sharedPalette)))
33}
34
35async function setFont($: EngineInterface, font: FontChoice, note: string): Promise<void> {
36 await keep($, held => ({ ...held, font }))
37 await say($, note)
38}
39
40async function savePreset($: EngineInterface, name: string, font: FontChoice): Promise<void> {
41 await update($, fontPresets, held => withFontPreset(held, name, font))
42 await $.store.set(FONT_PRESETS_KEY, await read($, fontPresets))
43}
44
45async function describe($: EngineInterface, wish: string): Promise<void> {
46 if (wish.trim() === '') return
47 await say($, `Finding a font for "${wish.trim()}"…`)
48 const reply = await $.model.complete(fontRequest(wish)).catch(() => null)
49 const font = reply !== null && reply.isAnswered ? fontFrom(reply.text) : null
50
51 await (font === null
52 ? say($, 'The model did not come back with a font from the list. Try other words.')
53 : setFont($, font, `Headings are now in ${font.name}.`))
54}
55
56async function fromDesignFile($: EngineInterface, typed: string): Promise<void> {
57 const userFolder = await $.env.get('HOME')
58 const path = typed.trim().replace(/^~(?=\/)/, userFolder ?? '~')
59
60 if (path === '') return
61 const file = await $.fs.read(path).catch(() => null)
62
63 if (typeof file !== 'string' || file.trim() === '') {
64 await say($, `Could not read ${typed.trim()}. Type the full path to a design file, like ~/Downloads/DESIGN.md.`)
65
66 return
67 }
68
69 await say($, 'Reading the design file…')
70 const reply = await $.model.complete(designRequest(file.slice(0, DESIGN_FILE_CHARS))).catch(() => null)
71 const font = reply !== null && reply.isAnswered ? fontFrom(reply.text) : null
72
73 if (font === null) {
74 await say($, 'No heading font could be worked out from that file.')
75
76 return
77 }
78
79 const name = path.split('/').at(-1) ?? 'Design file'
80 await savePreset($, name, font)
81 await setFont($, font, `Headings are now in ${font.name}, the closest installed match, saved as the preset "${name}".`)
82}
83
84export function fontsRoom(on: On): void {
85 on('ui.render', { component: 'Pane', requestId: 'clubhouse-fonts' }, async ($, e) => {
86 const elements = $.ui.resolve(e)
87 const { Box } = elements
88 const chosen = await read($, prefs)
89 const kept = await read($, fontPresets)
90 const view = await read($, fontsView)
91 const current = chosen.font ?? DEFAULT_FONT
92 const { frame, note, plain, title, card, heading, Button, Input } = makeParts(elements, chosen, e.surface)
93
94 return (
95 <Box flexDirection="column" gap={1} {...frame}>
96 {title('Fonts')}
97 {note(
98 'Pick the font of the Clubhouse headings, like the word Fonts above, and of the text on the toolbar. Buttons, the live score and all other text are drawn by the Claude app in its own font, which the Clubhouse cannot change. /clubhouse fonts opens this.',
99 )}
100 {card('Heading font', [
101 plain(`Now: ${current.name}`),
102 <Box gap={1} flexWrap="wrap">
103 {FONTS.map(font => (
104 <Button
105 key={`font-${font.name}`}
106 label={font.name}
107 variant={font.name === current.name ? 'primary' : 'secondary'}
108 onPress={() => void setFont($, font, `Headings are now in ${font.name}.`)}
109 />
110 ))}
111 </Box>,
112 note('These are fonts already on your Mac. Anthropic Serif is the default.'),
113 ])}
114 {heading('The quick brown fox')}
115 {Input !== null &&
116 card('Describe a font', [
117 note('Say what you want the headings to feel like, or name a font. A small model picks the closest font on your Mac. Costs a few tokens.'),
118 <Input
119 key="font-wish"
120 label="Font"
121 placeholder="friendly and rounded"
122 submitLabel="Find it"
123 onSubmit={wish => void describe($, wish)}
124 />,
125 ])}
126 {Input !== null &&
127 card('From a design file', [
128 note('Give the path to a design guide, like the DESIGN.md you used before. The heading font it names is matched to the closest font on your Mac and saved as a preset.'),
129 <Input
130 key="font-file"
131 label="File"
132 placeholder="~/Downloads/DESIGN.md"
133 submitLabel="Use it"
134 onSubmit={typed => void fromDesignFile($, typed)}
135 />,
136 ])}
137 {card('Your font presets', [
138 Input !== null && (
139 <Input
140 key="font-preset-name"
141 label="Name"
142 placeholder="Game day"
143 submitLabel="Save current font"
144 onSubmit={name =>
145 void (name.trim() === ''
146 ? say($, 'Type a name for the preset, then press Save current font.')
147 : savePreset($, name, current).then(() => say($, `Saved ${current.name} as "${name.trim()}".`)))
148 }
149 />
150 ),
151 ...kept.map(one => (
152 <Box gap={1} flexWrap="wrap">
153 <Button
154 key={`font-preset-${one.name}`}
155 label={`${one.name} (${one.font.name})`}
156 variant="primary"
157 onPress={() => void setFont($, one.font, `Headings are now in ${one.font.name}.`)}
158 />
159 <Button
160 key={`font-preset-delete-${one.name}`}
161 label="Delete"
162 onPress={() =>
163 void update($, fontPresets, held => held.filter(other => other.name !== one.name))
164 .then(async () => $.store.set(FONT_PRESETS_KEY, await read($, fontPresets)))
165 .then(() => say($, `Deleted the preset "${one.name}".`))
166 }
167 />
168 </Box>
169 )),
170 ])}
171 {view.note !== null && plain(view.note)}
172 </Box>
173 )
174 })
175}
176hooks/features/watch.tsx 274 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { SavedWatch, Watch, WatchView } from '../../types'
5import { DEFAULT_PREFS, PREFS_SHAPE } from '../lib/defaults'
6import { formatSpan } from '../lib/format'
7import { makeParts } from '../lib/parts'
8import { blanksOf } from '../lib/recipes'
9import {
10 CHECK_COUNTS,
11 DEFAULT_WATCH_VIEW,
12 INTERVALS,
13 LOW_USAGE_PERCENT,
14 SAVED_WATCHES_KEY,
15 TRIGGERS,
16 TRIGGER_LABEL,
17 WAKES_BEFORE_STOPPING,
18 aboutOf,
19 nextOf,
20 savedFrom,
21 statusOf,
22 titleOf,
23 watchFrom,
24 watchProblem,
25 withEntry,
26 withSaved,
27} from '../lib/watch'
28import type { WatchDraft } from '../lib/watch'
29
30const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
31 shape: PREFS_SHAPE,
32})
33const now = atom({ plugin: 'clubhouse', key: 'now' } as const, 0)
34const recipes = atom({ plugin: 'clubhouse', key: 'recipes' } as const, [])
35const watches = atom({ plugin: 'clubhouse', key: 'watches' } as const, [])
36const watchLog = atom({ plugin: 'clubhouse', key: 'watchLog' } as const, [])
37const watchView = atom({ plugin: 'clubhouse', key: 'watchView' } as const, DEFAULT_WATCH_VIEW)
38const savedWatches = atom({ plugin: 'clubhouse', key: 'savedWatches' } as const, [])
39
40const BLANK_DRAFT: WatchDraft = { name: '', task: '', command: '' }
41const MINUTE_MS = 60_000
42
43let draft: WatchDraft = BLANK_DRAFT
44
45async function say($: EngineInterface, note: string): Promise<void> {
46 await update($, watchView, view => ({ ...view, note }))
47}
48
49async function begin($: EngineInterface, from: SavedWatch): Promise<boolean> {
50 const problem = watchProblem(from, await read($, watches))
51
52 if (problem !== null) {
53 await say($, problem)
54
55 return false
56 }
57
58 const at = await $.clock.now()
59 const made = watchFrom(from, from, at)
60 await update($, watches, held => [...held, made])
61 await update($, watchLog, held => withEntry(held, { at, text: `Started · ${titleOf(made)}` }))
62 await say(
63 $,
64 `Watching. The first check is in ${made.everyMinutes} minutes. On the watch below, Check now tests it straight away and End watch cancels it.`,
65 )
66
67 return true
68}
69
70async function start($: EngineInterface): Promise<void> {
71 const typed = draft
72
73 if (await begin($, { ...savedFrom(typed, await read($, watchView)), name: typed.name })) {
74 draft = BLANK_DRAFT
75 }
76}
77
78async function save($: EngineInterface): Promise<void> {
79 const problem = watchProblem(draft, [])
80
81 if (problem !== null) {
82 await say($, problem)
83
84 return
85 }
86
87 const kept = savedFrom(draft, await read($, watchView))
88 await update($, savedWatches, held => withSaved(held, kept))
89 await $.store.set(SAVED_WATCHES_KEY, await read($, savedWatches))
90 await say($, `Saved "${kept.name}". Start it from Saved watches whenever you want, in any session.`)
91}
92
93async function forget($: EngineInterface, name: string): Promise<void> {
94 await update($, savedWatches, held => held.filter(one => one.name !== name))
95 await $.store.set(SAVED_WATCHES_KEY, await read($, savedWatches))
96 await say($, `Deleted the saved watch "${name}".`)
97}
98
99async function stop($: EngineInterface, watch: Watch): Promise<void> {
100 const at = await $.clock.now()
101 await update($, watches, held => held.filter(one => one.id !== watch.id))
102 await update($, watchLog, held => withEntry(held, { at, text: `Ended · ${titleOf(watch)}` }))
103 await say($, `Ended the watch "${titleOf(watch)}".`)
104}
105
106async function checkNow($: EngineInterface, id: string): Promise<void> {
107 await update($, watches, held => held.map(one => (one.id === id ? { ...one, nextAt: 0 } : one)))
108 await say($, 'Checking in the next few seconds.')
109}
110
111export function nightWatch(on: On): void {
112 on('ui.render', { component: 'Pane', requestId: 'clubhouse-watch' }, async ($, e) => {
113 const elements = $.ui.resolve(e)
114 const { Box } = elements
115 const chosen = await read($, prefs)
116 const view = await read($, watchView)
117 const held = await read($, watches)
118 const log = await read($, watchLog)
119 const kept = await read($, savedWatches)
120 const at = await read($, now)
121 const ready = (await read($, recipes)).filter(one => blanksOf(one.command).length === 0)
122 const { frame, note, plain, title, card, Button, Input } = makeParts(elements, chosen, e.surface)
123 const choose = (change: (shown: WatchView) => WatchView) => void update($, watchView, change)
124 const ago = (then: number) => (at - then < MINUTE_MS ? 'just now' : `${formatSpan(at - then)} ago`)
125
126 const form =
127 Input === null
128 ? card('New watch', [
129 note('Setting a watch needs a text box, which this screen does not have. Use the desktop app or a terminal.'),
130 ])
131 : card('New watch', [
132 note('Press Enter in each box to set it, then Start.'),
133 <Input
134 key="watch-name"
135 label="Name (optional)"
136 placeholder="Server check"
137 value={draft.name}
138 onInput={typed => {
139 draft = { ...draft, name: typed }
140 }}
141 onSubmit={typed => {
142 draft = { ...draft, name: typed }
143 }}
144 />,
145 <Input
146 key="watch-task"
147 label="Tell Claude"
148 placeholder="Check the build is still going; restart it if it stopped"
149 value={draft.task}
150 onInput={typed => {
151 draft = { ...draft, task: typed }
152 }}
153 onSubmit={typed => {
154 draft = { ...draft, task: typed }
155 }}
156 />,
157 <Input
158 key="watch-command"
159 label="Check command (optional)"
160 placeholder="pgrep -f my-script"
161 value={draft.command}
162 onInput={typed => {
163 draft = { ...draft, command: typed }
164 }}
165 onSubmit={typed => {
166 draft = { ...draft, command: typed }
167 }}
168 />,
169 note(
170 'With no check command, Claude is woken at every check. With one, the command runs first, which costs nothing, and Claude is only woken when the command says something is wrong.',
171 ),
172 ready.length === 0 ? null : (
173 <Box gap={1} flexWrap="wrap">
174 {ready.map(one => (
175 <Button
176 key={`watch-recipe-${one.name}`}
177 label={`Use recipe ${one.name}`}
178 onPress={() => {
179 draft = { ...draft, command: one.command }
180 void say($, `The check command is now your recipe ${one.name}: ${one.command}`)
181 }}
182 />
183 ))}
184 </Box>
185 ),
186 <Box gap={1} flexWrap="wrap">
187 <Button
188 key="watch-every"
189 label={`Every ${view.everyMinutes} min`}
190 onPress={() => choose(shown => ({ ...shown, everyMinutes: nextOf(INTERVALS, shown.everyMinutes) }))}
191 />
192 <Button
193 key="watch-count"
194 label={`${view.maxChecks} checks`}
195 onPress={() => choose(shown => ({ ...shown, maxChecks: nextOf(CHECK_COUNTS, shown.maxChecks) }))}
196 />
197 </Box>,
198 note(`That watches for about ${formatSpan(view.everyMinutes * view.maxChecks * MINUTE_MS)}, then stops.`),
199 <Box gap={1} flexWrap="wrap">
200 <Button
201 key="watch-trigger"
202 label={`When ${TRIGGER_LABEL[view.trigger]}`}
203 onPress={() => choose(shown => ({ ...shown, trigger: nextOf(TRIGGERS, shown.trigger) }))}
204 />
205 <Button
206 key="watch-quiet"
207 label={view.isQuiet ? 'Just note it here' : 'Wake Claude'}
208 onPress={() => choose(shown => ({ ...shown, isQuiet: !shown.isQuiet }))}
209 />
210 </Box>,
211 note('These two only matter with a check command: what counts as something wrong, and what happens then.'),
212 <Box gap={1} flexWrap="wrap">
213 <Button key="watch-start" label="Start watch" variant="primary" onPress={() => void start($)} />
214 <Button key="watch-save" label="Save for later" onPress={() => void save($)} />
215 </Box>,
216 note('Save for later keeps this watch, with these settings, to start with one press in any session.'),
217 ])
218
219 const savedList =
220 kept.length === 0
221 ? null
222 : card(
223 'Saved watches',
224 kept.map(one => (
225 <Box flexDirection="column">
226 <Box gap={1} flexWrap="wrap">
227 <Button
228 key={`watch-saved-start-${one.name}`}
229 label={`Start ${one.name}`}
230 variant="primary"
231 onPress={() => void begin($, one)}
232 />
233 <Button key={`watch-saved-delete-${one.name}`} label="Delete" onPress={() => void forget($, one.name)} />
234 </Box>
235 {one.task === '' ? null : note(one.task)}
236 {note(`${aboutOf(one)} ${one.maxChecks} checks.`)}
237 </Box>
238 )),
239 )
240
241 const running = (one: Watch) =>
242 card(`Running: ${titleOf(one)}`, [
243 one.task === '' ? null : plain(one.task),
244 note(aboutOf(one)),
245 plain(statusOf(one, at)),
246 <Box gap={1} flexWrap="wrap">
247 <Button key={`watch-now-${one.id}`} label="Check now" onPress={() => void checkNow($, one.id)} />
248 <Button key={`watch-stop-${one.id}`} label="End watch" onPress={() => void stop($, one)} />
249 </Box>,
250 ])
251
252 return (
253 <Box flexDirection="column" gap={1} {...frame}>
254 {title('Night watch')}
255 {note(
256 'Checks on things while you are away. Every so often it wakes Claude with your instruction, or runs a command first and only wakes Claude when something is wrong. /clubhouse watch opens this.',
257 )}
258 {view.note !== null && plain(view.note)}
259 {held.map(running)}
260 {savedList}
261 {form}
262 {log.length > 0 &&
263 card(
264 'What happened',
265 log.map(entry => note(`${ago(entry.at)} · ${entry.text}`)),
266 )}
267 {note(
268 `A watch belongs to this session, so keep the Claude app open and your Mac awake. Waking Claude is a full turn and uses your limit: a watch will not wake Claude when under ${LOW_USAGE_PERCENT}% of your 5-hour limit is left, and one with a check command stops itself after ${WAKES_BEFORE_STOPPING} wake-ups in a row. In a permission mode that asks, Claude waits for you at the first thing that needs approval.`,
269 )}
270 </Box>
271 )
272 })
273}
274hooks/features/weather.tsx 167 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { Forecast, Place, WeatherPlan } from '../../types'
5import { backdropOf } from '../lib/appColor'
6import { DEFAULT_PREFS, PREFS_KEY, PREFS_SHAPE } from '../lib/defaults'
7import { makeParts } from '../lib/parts'
8import { arranged } from '../lib/toolbar'
9import {
10 DEFAULT_WEATHER,
11 KIND_WORD,
12 LOCATE_URL,
13 WEATHER_KEY,
14 dayName,
15 forecastFrom,
16 forecastUrl,
17 placeFrom,
18 placesFrom,
19 searchUrl,
20 weatherSvg,
21} from '../lib/weather'
22import { forStore } from '../lib/project'
23
24const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
25 shape: PREFS_SHAPE,
26})
27const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
28const weather = atom({ plugin: 'clubhouse', key: 'weather' } as const, DEFAULT_WEATHER)
29const forecast = atom({ plugin: 'clubhouse', key: 'forecast' } as const, null)
30const weatherView = atom({ plugin: 'clubhouse', key: 'weatherView' } as const, { note: null, hits: [] })
31
32const DAY_ICON = 26
33
34async function say($: EngineInterface, note: string | null): Promise<void> {
35 await update($, weatherView, view => ({ ...view, note }))
36}
37
38async function load($: EngineInterface): Promise<void> {
39 const plan = await read($, weather)
40
41 if (plan.place === null) return
42 const page = await $.http.fetch(forecastUrl(plan)).catch(() => null)
43 const found = page !== null && page.ok ? forecastFrom(page.text, await $.clock.now()) : null
44
45 await (found === null
46 ? say($, 'The weather service could not be reached. Try Refresh in a moment.')
47 : update($, forecast, () => found))
48}
49
50async function keep($: EngineInterface, change: (held: WeatherPlan) => WeatherPlan): Promise<void> {
51 await update($, weather, change)
52 await $.store.set(WEATHER_KEY, await read($, weather))
53 await load($)
54}
55
56async function setPlace($: EngineInterface, place: Place): Promise<void> {
57 await update($, weatherView, () => ({ note: `Showing the weather for ${place.name}.`, hits: [] }))
58 await keep($, held => ({ ...held, place }))
59}
60
61async function locate($: EngineInterface): Promise<void> {
62 await say($, 'Finding where you are…')
63 const page = await $.http.fetch(LOCATE_URL).catch(() => null)
64 const place = page !== null && page.ok ? placeFrom(page.text) : null
65
66 await (place === null
67 ? say($, 'Your location could not be worked out. Type your town in the box instead.')
68 : setPlace($, place))
69}
70
71async function search($: EngineInterface, asked: string): Promise<void> {
72 if (asked.trim() === '') return
73 const page = await $.http.fetch(searchUrl(asked)).catch(() => null)
74 const hits = page !== null && page.ok ? placesFrom(page.text) : []
75 await update($, weatherView, () => ({
76 hits,
77 note: hits.length === 0 ? `No place found for "${asked.trim()}".` : 'Pick your place below.',
78 }))
79}
80
81async function showOnToolbar($: EngineInterface): Promise<void> {
82 const outcome = arranged(await read($, prefs), { kind: 'item', id: 'weather' }, 'toggle')
83 await update($, prefs, () => outcome.prefs)
84 await $.store.set(PREFS_KEY, forStore(outcome.prefs, await read($, sharedPalette)))
85 await say($, outcome.note)
86}
87
88export function weatherRoom(on: On): void {
89 on('ui.render', { component: 'Pane', requestId: 'clubhouse-weather' }, async ($, e) => {
90 const elements = $.ui.resolve(e)
91 const { Box } = elements
92 const chosen = await read($, prefs)
93 const plan = await read($, weather)
94 const sky: Forecast | null = await read($, forecast)
95 const view = await read($, weatherView)
96 const { look, frame, note, plain, title, card, picture, Button, Input } = makeParts(elements, chosen, e.surface)
97 const mark = plan.unit === 'c' ? '°C' : '°F'
98 const isShown = chosen.bar.weather?.isShown === true
99
100 return (
101 <Box flexDirection="column" gap={1} {...frame}>
102 {title('Weather')}
103 {note(
104 'The weather where you are: the temperature, a picture of the sky and the chance of rain on the toolbar, and the next 7 days here. /clubhouse weather opens this.',
105 )}
106 {card('Where', [
107 plain(plan.place === null ? 'No place set yet.' : plan.place.name),
108 <Box gap={1} flexWrap="wrap">
109 <Button key="weather-locate" label="Use my location" variant="primary" onPress={() => void locate($)} />
110 <Button
111 key="weather-unit"
112 label={plan.unit === 'c' ? 'Celsius' : 'Fahrenheit'}
113 onPress={() => void keep($, held => ({ ...held, unit: held.unit === 'c' ? 'f' : 'c' }))}
114 />
115 <Button key="weather-refresh" label="Refresh" onPress={() => void load($)} />
116 <Button
117 key="weather-show"
118 label={isShown ? 'On the toolbar' : 'Add to toolbar'}
119 variant={isShown ? 'primary' : 'secondary'}
120 onPress={() => void showOnToolbar($)}
121 />
122 </Box>,
123 Input !== null && (
124 <Input
125 key="weather-search"
126 label="Or type a town"
127 placeholder="Austin"
128 submitLabel="Find"
129 onSubmit={asked => void search($, asked)}
130 />
131 ),
132 ...view.hits.map(hit => (
133 <Box>
134 <Button key={`weather-place-${hit.name}`} label={hit.name} onPress={() => void setPlace($, hit)} />
135 </Box>
136 )),
137 note('"Use my location" asks a lookup service where your internet connection is, which is usually right to the nearest town. Typing a town is exact.'),
138 ])}
139 {view.note !== null && plain(view.note)}
140 {sky !== null &&
141 card('Now', [
142 <Box gap={1} alignItems="center">
143 {picture(weatherSvg(sky.kind, look.ink, backdropOf(chosen), DAY_ICON + 8), KIND_WORD[sky.kind], DAY_ICON + 8, DAY_ICON + 8)}
144 {plain(
145 `${sky.temp}${mark} · ${KIND_WORD[sky.kind]}${sky.isWet || sky.chance === null ? '' : ` · ${sky.chance}% chance of rain today`}`,
146 )}
147 </Box>,
148 ])}
149 {sky !== null &&
150 sky.days.length > 0 &&
151 card(
152 'Next 7 days',
153 sky.days.map(day => (
154 <Box gap={1} alignItems="center">
155 {picture(weatherSvg(day.kind, look.ink, backdropOf(chosen), DAY_ICON), KIND_WORD[day.kind], DAY_ICON, DAY_ICON)}
156 {plain(
157 `${dayName(day.date)} · ${day.high}° / ${day.low}° · ${KIND_WORD[day.kind]}${day.chance === null ? '' : ` · ${day.chance}% rain`}`,
158 )}
159 </Box>
160 )),
161 )}
162 {note('Forecasts come from Open-Meteo, a free weather service, and refresh every 15 minutes.')}
163 </Box>
164 )
165 })
166}
167hooks/features/sports.tsx 163 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { Game, SportsPlan } from '../../types'
5import { DEFAULT_PREFS, PREFS_KEY, PREFS_SHAPE } from '../lib/defaults'
6import { makeParts } from '../lib/parts'
7import {
8 DEFAULT_SPORTS,
9 LEAGUES,
10 SPORTS_KEY,
11 dayOf,
12 daysToFetch,
13 gameLine,
14 gamesFrom,
15 leagueOf,
16 listed,
17 scoreboardUrl,
18} from '../lib/sports'
19import { FEED_HEADERS } from '../lib/ticker'
20import { arranged } from '../lib/toolbar'
21import { forStore } from '../lib/project'
22
23const prefs = atom({ plugin: 'clubhouse', key: 'prefs' } as const, DEFAULT_PREFS, {
24 shape: PREFS_SHAPE,
25})
26const sharedPalette = atom({ plugin: 'clubhouse', key: 'sharedPalette' } as const, null)
27const sports = atom({ plugin: 'clubhouse', key: 'sports' } as const, DEFAULT_SPORTS)
28const games = atom({ plugin: 'clubhouse', key: 'games' } as const, [])
29const liveGame = atom({ plugin: 'clubhouse', key: 'liveGame' } as const, null)
30const sportsView = atom({ plugin: 'clubhouse', key: 'sportsView' } as const, { note: null, isLoading: false })
31const sportsCheckedAt = atom({ plugin: 'clubhouse', key: 'sportsCheckedAt' } as const, 0)
32
33const GAMES_SHOWN = 24
34
35async function keep($: EngineInterface, change: (held: SportsPlan) => SportsPlan): Promise<void> {
36 await update($, sports, change)
37 await $.store.set(SPORTS_KEY, await read($, sports))
38}
39
40async function loadGames($: EngineInterface, leagueId: string): Promise<void> {
41 const league = leagueOf(leagueId)
42 const at = await $.clock.now()
43 await update($, sportsView, () => ({ note: `Getting ${league.label} games…`, isLoading: true }))
44 const found: Game[] = []
45 let hasAnswered = false
46
47 for (const day of daysToFetch(league, at)) {
48 const page = await $.http.fetch(scoreboardUrl(league, day), { headers: FEED_HEADERS }).catch(() => null)
49
50 if (page !== null && page.ok) {
51 hasAnswered = true
52 found.push(...gamesFrom(page.text, league))
53 }
54 }
55
56 const shown = listed(found, at)
57 await update($, games, () => shown)
58 await update($, sportsView, () => ({
59 isLoading: false,
60 note: !hasAnswered
61 ? 'The score feed could not be reached. Try Refresh in a moment.'
62 : shown.length === 0
63 ? `No ${league.label} games on now or in the next 7 days.`
64 : null,
65 }))
66}
67
68async function pickLeague($: EngineInterface, leagueId: string): Promise<void> {
69 await keep($, held => ({ ...held, league: leagueId }))
70 await loadGames($, leagueId)
71}
72
73async function showGame($: EngineInterface, game: Game): Promise<void> {
74 await keep($, held => ({ ...held, gameId: game.id, gameLeague: game.league, gameDay: dayOf(game.startsAt) }))
75 await update($, liveGame, () => game)
76 await update($, sportsCheckedAt, () => 0)
77 const chosen = await read($, prefs)
78 const outcome =
79 chosen.bar.sports?.isShown === true
80 ? { prefs: chosen, note: null }
81 : arranged(chosen, { kind: 'item', id: 'sports' }, 'toggle')
82 await update($, prefs, () => outcome.prefs)
83 await $.store.set(PREFS_KEY, forStore(outcome.prefs, await read($, sharedPalette)))
84 await update($, sportsView, view => ({
85 ...view,
86 note: outcome.note ?? `${game.away.abbr} at ${game.home.abbr} is on the toolbar. Team logos arrive within half a minute.`,
87 }))
88}
89
90export function sportsRoom(on: On): void {
91 on('ui.render', { component: 'Pane', requestId: 'clubhouse-sports' }, async ($, e) => {
92 const elements = $.ui.resolve(e)
93 const { Box } = elements
94 const chosen = await read($, prefs)
95 const plan = await read($, sports)
96 const list: Game[] = await read($, games)
97 const live: Game | null = await read($, liveGame)
98 const view = await read($, sportsView)
99 const { frame, note, plain, title, card, Button } = makeParts(elements, chosen, e.surface)
100 const league = leagueOf(plan.league)
101
102 return (
103 <Box flexDirection="column" gap={1} {...frame}>
104 {title('Live sports')}
105 {note(
106 'Put one game on the toolbar: both teams, the score and the clock, home team on the left. Pick a sport, then a game. /clubhouse sports opens this.',
107 )}
108 {card('On the toolbar', [
109 plain(live === null || live.id !== plan.gameId ? 'No game chosen yet.' : gameLine(live)),
110 plan.gameId === null ? null : (
111 <Box>
112 <Button
113 key="sports-clear"
114 label="Take it off"
115 onPress={() => void keep($, held => ({ ...held, gameId: null, gameLeague: null, gameDay: null }))}
116 />
117 </Box>
118 ),
119 ])}
120 {card('Sport', [
121 <Box gap={1} flexWrap="wrap">
122 {LEAGUES.map(one => (
123 <Button
124 key={`league-${one.id}`}
125 label={one.label}
126 variant={one.id === league.id ? 'primary' : 'secondary'}
127 onPress={() => void pickLeague($, one.id)}
128 />
129 ))}
130 </Box>,
131 <Box>
132 <Button key="sports-refresh" label={`Get ${league.label} games`} onPress={() => void loadGames($, league.id)} />
133 </Box>,
134 ])}
135 {view.note !== null && plain(view.note)}
136 {list.length > 0 &&
137 card(
138 `${league.label}: on now and this week`,
139 list
140 .filter(game => game.league === league.id)
141 .slice(0, GAMES_SHOWN)
142 .map(game => (
143 <Box flexDirection="column">
144 {plain(gameLine(game))}
145 <Box>
146 <Button
147 key={`game-${game.id}`}
148 label={plan.gameId === game.id ? 'On the toolbar' : 'Show on toolbar'}
149 variant={plan.gameId === game.id ? 'primary' : 'secondary'}
150 onPress={() => void showGame($, game)}
151 />
152 </Box>
153 </Box>
154 )),
155 )}
156 {note(
157 'Scores come from ESPN\'s free feed and refresh every half minute while a game is live. The feed is unofficial, so it can lag or stop.',
158 )}
159 </Box>
160 )
161 })
162}
163