SLOPSHOPPER

clubhouse

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

newpanebandspinnerrowsguard
★ 1v1.3.0MITupdated 2026-10-04dcglanton-stack/claude-clubhouse/clubhouse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clubhouse
│ ┃ Clubhouse ✕ › fix the failing auth test and add an audit log call │ ┃ Claude Clubhouse [ On ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Home ] [ More ] [ Colors ] [ Fonts ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Safeguard warning: off ] ⎿ Added 2 lines, removed 1 line │ ┃ When on, each prompt is checked before it is ⏺ Bash(bun test) │ ┃ sent. If it looks likely to set off a safety ⎿ 3 pass, 1 fail │ ┃ filter (which can stop it or hand it to a │ ┃ more restricted model), you are told why and ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ can edit it or send it anyway. A small model │ ┃ makes the guess, so it adds about a second ✻ Worked for 42s · done 4:20 PM │ ┃ and a few tokens per prompt. │ ┃ › /clubhouse │ ┃ ╭──────────────────────────────────────────╮ ⎿ clubhouse: Clubhouse opened. │ ┃ │ Rooms │ │ ┃ │ │ │ ┃ │ [ Agent HQ ] │ │ ┃ │ See agents at work, stop them, and build │ │ ┃ │ your own. │ │ ┃ │ │ │ ┃ │ [ Summary ] │ │ ┃ │ The last reply, cut down to the points │ │ ┃ │ that matter. │ │ ┃ │ │ │ ┃ │ [ Second opinion ] │ [ Summarize ] [ Tidy ] [ ⌂ Clubhouse ][ 5h ] █░ … █░ █░ █░ █░ █ █ █ █ █ █ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ Summarize ] [ Tidy ] [ ⌂ Clubhouse ][ 5h ] █░ … █░ █░ █░ █░ █ █ █ █ █ █
Pane · Clubhouse
Claude Clubhouse [ On ] [ Home ] [ More ] [ Colors ] [ Fonts ] [ Safeguard warning: off ] 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. ╭──────────────────────────────────────────────────────────╮ │ Rooms │ │ │ │ [ Agent HQ ] │ │ See agents at work, stop them, and build your own. │ │ │ │ [ Summary ] │ │ The last reply, cut down to the points that matter. │ │ │ │ [ Second opinion ] │ │ Ask about this session without adding to it. │ │ │ │ [ Tool rules ] │ │ Make Claude ask first, or block it, before it uses a │ │ tool. │ │ │ │ [ Recipes ] │ │ Turn a terminal command into a tool Claude can call. │ │ │ │ [ Night watch ] │ │ Check on things while you are away, and wake Claude when │ │ needed. │ │ │ │ [ Session notes ] │ │ Leave a note for a later session to read. │ │ │ │ [ To-do ] │ │ A scratchpad list for this session: add tasks and cross │ │ them off. │ │ │ │ [ Ticker ] │ │ A stock or coin price on the toolbar, and your │ │ favorites. │ │ │ │ [ Live sports ] │ │ Games on now and this week; put one score on the │ │ toolbar. │ │ │ │ [ Weather ] │ │ The next 7 days where you are, and the weather on the │ │ toolbar. │ │ │ │ [ Commands ] │ │ Everything you can type after a slash, most used first. │ │ │ │ [ Toolbar ] │ │ Add, remove and move what sits above the prompt. │ │ │ │ [ Usage ] │ │ Both limits, context and the cache timer, full size. │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ Quick commands │ │ [ /clubhouse ] [ /ship ] [ /clear ] [ /compact ] [ /model │ │ Your most used and most recent. A click puts one in the │ prompt box. ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Usage │ │ │ │ 5-hour window: 69% left │ │ ███████████████████░░░░░░░░░ │ │ │ │ Weekly window: no reading yet │ │ │ │ Context 49% full · cache 1h 0m │ │ │ │ last turn 42s · 97.4k in / 1.5k out · 0.0% of 5h │ ╰──────────────────────────────────────────────────────────╯
Pane · clubhouse-bar
Toolbar 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. ╭──────────────────────────────────────────────────────────╮ │ Rows │ │ │ │ Row 1: 11 of 14 used │ │ │ │ Every item has a size, and a row holds 14, 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 4 rows; row 1 is on top. │ │ │ │ [ Add a row ] │ │ │ │ A row only shows once something is placed on it. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Ready-made layouts │ │ │ │ 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. │ │ │ │ [ Focus ] │ │ Only the numbers, on one row: the usage meter on the │ │ left, the cache timer and context gauge on the right. │ │ │ │ [ Quiet ] │ │ Just the Clubhouse button in the middle. Everything else │ │ is one press away in the rooms. │ │ │ │ [ Drafting ] │ │ For writing prompts: Tidy, Draw it and Summarize on the │ │ left, the Clubhouse button in the middle, the usage │ │ meter on the right. │ │ │ │ [ Cost watch ] │ │ What a long session is costing you. Row 1: Summarize, │ │ the Clubhouse button and the usage meter. Row 2: the │ │ turn receipt on the left, the cache timer and context │ │ gauge on the right. │ │ │ │ [ Shipping ] │ │ For finishing work in a repository: Summarize and Prune │ │ on the left, the Clubhouse button in the middle, the │ │ context gauge and usage meter on the right. │ │ │ │ [ Game day ] │ │ The usual toolbar with a second row under it: the live │ │ score on the left, the weather in the middle, a price on │ │ the right. Pick the game, place and symbol in their │ │ rooms. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Clubhouse button │ │ │ │ The house that opens the Clubhouse. Size 2. │ │ │ │ [ On the toolbar ] [ Center ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Usage meter │ │ │ │ Clawd on a bar that drains as you use your limit. Size │ │ 6. │ │ │ │ [ On the toolbar ] [ Right ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Summarize button │ │ │ │ Shortens the last reply into a few bullet points. Size │ │ 2. │ │ │ │ [ On the toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Tidy button │ │ │ │ Fixes spelling and trims the draft in the prompt box │ │ before you send it. Size 1. │ │ │ │ [ On the toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Cache timer │ │ │ │ Counts one hour down from the last reply; after that the │ │ next turn costs more. Size 2. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Context gauge │ │ │ │ How full this conversation is, in tokens. Size 3. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Turn receipt │ │ │ │ Time, tokens and usage of the last turn. Size 8. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Draw it button │ │ │ │ Opens a sketch pad so you can draw what you want instead │ │ of describing it; the sketch goes into your prompt. Size │ │ 2. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Weather │ │ │ │ The temperature where you are, a picture of the sky, and │ │ the chance of rain. Size 3. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Live score │ │ │ │ One game: both teams, the score and the clock. Pick the │ │ game in the Live sports room. Size 5. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Ticker │ │ │ │ One stock or coin: symbol, price and the day's change. │ │ Pick it in the Ticker room. Size 4. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Is this AGI? button │ │ │ │ Just for fun: sends Claude the question "Is this AGI?". │ │ Size 2. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Gaslighting button │ │ │ │ Adds "Chat GPT did this easily. Figure it out." to what │ │ you are typing. Size 2. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Prune button │ │ │ │ Asks Claude to check this project's branches and pull │ │ requests for conflicts and say which are safe to delete. │ │ It asks you before deleting anything. Size 1. │ │ │ │ [ Add to toolbar ] [ Left ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Make your own button │ │ │ │ A button that types a prompt or command into the prompt │ │ box for you. Press Enter in each box, then Add. │ │ │ │ Label: Run tests │ │ │ │ What it types: run the tests and fix what fails │ │ │ │ [ Add to toolbar ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Saved layouts │ │ │ │ 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. │ │ │ │ Name: Game day ⏎ Save this layout │ ╰──────────────────────────────────────────────────────────╯ [ Reset the toolbar ] New toolbar features built later show up here as items you can add.
Pane · clubhouse-summary
Summary [ Summarize last reply ] [ Auto: off ] ╭──────────────────────────────────────────────────────────╮ │ The short version │ │ │ │ Press Summarize last reply for the short version. │ ╰──────────────────────────────────────────────────────────╯ A small, cheap model writes the summary; the full reply stays in the conversation. Auto summarizes every reply longer than 1,500 characters. /clubhouse summary opens this.
Pane · clubhouse-opinion
Second opinion Ask about this session without adding anything to it. Claude never sees the question or the answer in the conversation. /clubhouse opinion opens this. Question: Is this session on the right track, and what would Leave it empty to ask whether the session is on the right track. ╭──────────────────────────────────────────────────────────╮ │ Ask Claude here │ │ │ │ The same model, with the whole conversation in view. │ │ Best for "what is going on?" and "what did you just │ │ change?". Cheap, because the conversation is already │ │ cached. │ │ │ │ [ Ask Claude here ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Ask an outside model │ │ │ │ A different model with fresh eyes. It sees only the │ │ recent conversation and is told not to defer to Claude. │ │ Best for "is this the right approach?". │ │ │ │ [ Ask Sonnet ] [ Outside model: Sonnet ] │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ The answer │ │ │ │ Nothing asked yet. │ ╰──────────────────────────────────────────────────────────╯
Pane · clubhouse-tools
Tool rules Decide what Claude may do without checking with you. Each tool has one button that cycles Allowed, Ask first and Blocked. /clubhouse tools opens this. ╭──────────────────────────────────────────────────────────╮ │ How the rules work │ │ │ │ Allowed: Claude Code handles the tool the way it │ │ normally does, including its own permission prompts. │ │ │ │ Ask first: a question pops up showing the tool and what │ │ Claude is about to send. Nothing runs unless you pick │ │ Allow once. │ │ │ │ Blocked: the call is refused and Claude is told not to │ │ retry. │ │ │ │ Rules apply in every session and to agents too, and stay │ │ active even when the Clubhouse is switched off. │ ╰──────────────────────────────────────────────────────────╯ Find: part of a tool name or what it does ⏎ Find
Pane · clubhouse-recipes
Recipes A recipe is a shortcut for Claude. Save a command you would type in a terminal once, and from then on Claude runs exactly that command when you ask, in any session. /clubhouse recipes opens this. Example: name db_push, command supabase db push. After that, "push the database" is one step for Claude and you never open the terminal. ╭──────────────────────────────────────────────────────────╮ │ New recipe │ │ │ │ Press Enter in each box to set it, then Save. │ │ │ │ Name: run_tests │ │ │ │ What it does: Runs the project's tests │ │ │ │ Command: npm test -- {file} │ │ │ │ For anything that changes each time, put a blank in │ │ braces, like {file}. Claude fills the blank, and what it │ │ fills in is handed to the command as one argument, never │ │ run as code. │ │ │ │ [ Save recipe ] │ ╰──────────────────────────────────────────────────────────╯ No recipes yet. A first one to try: name run_tests, command npm test. A recipe runs in this session's folder and is stopped after five minutes. To make Claude ask you before it uses one, set it to Ask first in Tool rules.
Pane · clubhouse-watch
Night watch 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. ╭─────────────────────────────────────────────────────────── │ New watch │ │ Press Enter in each box to set it, then Start. │ │ Name (optional): Server check │ │ Tell Claude: Check the build is still going; restart it if │ │ Check command (optional): pgrep -f my-script │ │ 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. │ │ [ Every 30 min ] [ 8 checks ] │ │ That watches for about 4h 0m, then stops. │ │ [ When the command fails ] [ Wake Claude ] │ │ These two only matter with a check command: what counts │ as something wrong, and what happens then. │ │ [ Start watch ] [ Save for later ] │ │ Save for later keeps this watch, with these settings, to │ start with one press in any session. ╰─────────────────────────────────────────────────────────── 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 10% of your 5-hour limit is left, and one with a check command stops itself after 3 wake-ups in a row. In a permission mode that asks, Claude waits for you at the first thing that needs approval.
Pane · clubhouse-notes
Session notes Leave a note for a later session, so you do not have to explain where you left off. Example: "Pick up at the Stripe webhook; the two red tests are expected." /clubhouse notes opens this. ╭──────────────────────────────────────────────────────────╮ │ New note │ │ │ │ Press Enter in the box to set it, then Save note. │ │ │ │ Note: Where to pick up next time │ │ │ │ [ For: sessions in app ] [ Shown once ] │ │ │ │ [ Save note ] │ ╰──────────────────────────────────────────────────────────╯ Who reads a note: only a session that starts after you save it. Sessions already running never pick one up on their own, so two open sessions cannot both grab it. A once-note goes to the first new session that fits and is then gone; "Give to this session" hands one to the session you are in.
Pane · clubhouse-todo
To-do A scratchpad for this session: add tasks and cross them off as you go. The list is yours alone (Claude does not read it) and it is gone when the session ends. /clubhouse to-do opens this, and /clubhouse to-do call the bank adds a task. ╭──────────────────────────────────────────────────────────╮ │ Add a task │ │ │ │ Task: What needs doing ⏎ Add │ │ │ │ [ Add ] │ ╰──────────────────────────────────────────────────────────╯
Pane · clubhouse-ticker
Ticker One stock or coin on the toolbar: its symbol, price and change for the day. Search below, add one to the toolbar, and keep favorites here. /clubhouse ticker opens this. ╭──────────────────────────────────────────────────────────╮ │ On the toolbar │ │ │ │ Nothing chosen yet. │ │ │ │ [ Change shown in green or red ] [ Refresh prices ] │ │ │ │ Plain text keeps the change in your text color, so it │ │ never clashes with your colors. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Find a symbol │ │ │ │ Search: AAPL, bitcoin, S&P 500 ⏎ Find │ ╰──────────────────────────────────────────────────────────╯ Prices come from Yahoo's free feed, refresh about once a minute while the ticker is on the toolbar, and can run a few minutes behind. It shows prices only; it does not trade.
Pane · clubhouse-sports
Live sports 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. ╭──────────────────────────────────────────────────────────╮ │ On the toolbar │ │ │ │ No game chosen yet. │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ Sport │ │ [ NFL ] [ College football ] [ NBA ] [ MLB ] [ NHL ] [ MLS │ │ [ Get NFL games ] ╰─────────────────────────────────────────────────────────── 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.
Pane · clubhouse-fonts
Fonts 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. ╭─────────────────────────────────────────────────────────── │ Heading font │ │ Now: Anthropic Serif │ │ [ Anthropic Serif ] [ New York ] [ Georgia ] [ Palatino ] │ │ These are fonts already on your Mac. Anthropic Serif is │ the default. ╰─────────────────────────────────────────────────────────── The quick brown fox ╭──────────────────────────────────────────────────────────╮ │ Describe a font │ │ │ │ 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. │ │ │ │ Font: friendly and rounded ⏎ Find it │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ From a design file │ │ │ │ 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. │ │ │ │ File: ~/Downloads/DESIGN.md ⏎ Use it │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Your font presets │ │ │ │ Name: Game day ⏎ Save current font │ ╰──────────────────────────────────────────────────────────╯
Pane · clubhouse-weather
Weather 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. ╭─────────────────────────────────────────────────────────── │ Where │ │ No place set yet. │ │ [ Use my location ] [ Fahrenheit ] [ Refresh ] [ Add to to │ │ Or type a town: Austin ⏎ Find │ │ "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. ╰─────────────────────────────────────────────────────────── Forecasts come from Open-Meteo, a free weather service, and refresh every 15 minutes.
Pane · clubhouse-usage
Usage ╭──────────────────────────────────────────────────────────╮ │ 5-hour limit │ │ │ │ 5-hour window: 69% left │ │ ███████████████████░░░░░░░░░ │ │ │ │ Resets on a rolling five hours. Clawd gets unhappier as │ │ it drains. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Spend cap │ │ │ │ [ Spend cap: off ] │ │ │ │ Helper agents use your limit quickly. With a cap set, │ │ Claude has to ask you before starting one once your │ │ 5-hour limit drops under that level. You can allow one, │ │ allow them all until the limit resets, or say no. Agents │ │ you send yourself from Agent HQ are never stopped. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Weekly limit │ │ │ │ Weekly window: no reading yet │ │ │ │ Resets once a week. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ This conversation │ │ │ │ Context window: 49% full │ │ │ │ A fuller context makes every turn cost more. /compact │ │ shrinks it. │ │ │ │ cache 1h 0m │ │ │ │ Counts one hour down from the last reply. After it runs │ │ out, the next turn re-reads the whole conversation at │ │ full price. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Last turn │ │ │ │ last turn 42s · 97.4k in / 1.5k out · 0.0% of 5h │ │ │ │ Time taken, tokens read and written, and the share of │ │ the 5-hour limit it used. │ ╰──────────────────────────────────────────────────────────╯ /clubhouse usage opens this room.
Pane · clubhouse-commands
Commands 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. ╭─────────────────────────────────────────────────────────── │ Most used and most recent │ │ [ /clubhouse ] [ /ship ] [ /clear ] [ /compact ] [ /model ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ About hiding │ │ │ │ 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. │ │ │ │ Nothing is deleted from your computer, and it stays │ │ hidden in every session until you show it again under │ │ Hidden skills at the bottom. │ │ │ │ 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. │ │ │ │ To remove a whole plugin and everything it adds, ask │ │ Claude to turn that plugin off. │ ╰──────────────────────────────────────────────────────────╯ Find: part of a name or what it does ⏎ Find [ ▸ Your own (2) ] ╭──────────────────────────────────────────────────────────╮ │ Hidden skills (0) │ │ │ │ Hidden in every session: gone from the slash menu, and │ │ Claude is not told about them and cannot use them. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Presets │ │ │ │ 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. │ │ │ │ Name: coding ⏎ Set │ │ │ │ [ Save as preset ] │ ╰──────────────────────────────────────────────────────────╯
Pane · clubhouse-agents
Agent HQ [ + New agent ] ╭──────────────────────────────────────────────────────────╮ │ In the field (0) │ │ │ │ No agents out right now. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Your agents (0) │ │ │ │ None saved yet. Press + New agent to make one you can │ │ reuse in any session. │ ╰──────────────────────────────────────────────────────────╯ An agent can be stood down but not paused: Claude Code has no pause. /clubhouse agents opens this room.
Pane · clubhouse-colors
Colors 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. [ Reset all to default ] [ Sidebar: the app's own look ] [ a The Sidebar button extends your color over the list of sessions on the left, so the whole Claude window matches. Press the app button to give this project its own colors. Until then it uses the shared ones. 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. [ Build the helper ] [ ▸ Why is this needed? ] What to color [ Background ] The color of the whole session. Text turns dark or light on its own to stay readable. Code: app default ⏎ Confirm [ Reset ] [ Accent ] Headings and highlights in the Clubhouse. Code: #d97757 ⏎ Confirm [ Reset ] [ Clawd ] The mascot in the usage meter. Code: #e8743b ⏎ Confirm [ Reset ] Adjust background [ ◀ hue ] [ hue ▶ ] [ lighter ] [ darker ] [ bolder ] [ soft Presets Each preset sets all three colors at once. [ Claude ] [ Plush ] [ Forest ] [ Ocean ] [ Grape ] [ Slate Your presets Found colors you like? Give them a name and save them. A preset keeps all three colors as they are right now. Name: Game day ⏎ Save current colors One color for background Anthropic's own palette: warm ivory and oat neutrals with one clay accent. A press changes only the color chosen under What to color. [ Clay ] [ Clay Deep ] [ Slate Dark ] [ Slate Medium ] [ Clo Describe a look A small model picks all three colors from your words. Costs a few tokens. Look: cozy autumn cabin ⏎ Make it How the session gets its color 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 (dark right now), and it stops when you reset, switch the Clubhouse off or quit Claude. [ ▸ Advanced ]
README

Claude Clubhouse

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

Contents

What you get

  • A toolbar above the prompt that you lay out yourself: a usage meter where Clawd rides a bar that drains as you use your limit, the context gauge and cache timer, Summarize and Tidy buttons, a stock or coin price, a live score with team logos, the weather, a sketch pad, and buttons you make yourself.
  • Your colors, for real. Pick a Background and the whole window becomes that color: the conversation, text box, toolbar and sidebar take shades of it, and things with their own color (window buttons, icons, logos) keep it. It works with the app in dark or light mode, and each project can have its own colors. This part needs macOS.
  • Rooms, one screen each: your subagents and saved agents, every slash command, rules for what Claude may do with each tool, terminal commands saved as tools Claude can call, a timer that checks on things while you are away, notes left for a later session, a to-do list for the session you are in, usage, fonts and more.
  • It is yours to change. Say "in the clubhouse, add a button that..." in any session and Claude edits your copy. Nothing you change leaves your computer.

Where it works

WhereWorks
Claude desktop app, Code tab, Local sessionYes
Claude Code in the terminalYes, without the whole-window color
Cloud sessions (claude.ai/code, the phone app, Cloud in the desktop app)No
claude.ai chat and CoworkNo

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.

Install

Pick one way. With two, the Clubhouse loads twice, and settings do not carry over between them.

From the Claude directory

  1. Open the directory (Customize in the Claude app, or claude.ai/directory), find Claude Clubhouse and add it.
  2. Start a new Claude Code session on your computer, signed in to the same account.
  3. Type /clubhouse.

The directory listing is under review; until it is live, use the next way.

From the plugin marketplace on GitHub

  1. In any Claude Code session, add the marketplace:
/plugin marketplace add dcglanton-stack/claude-clubhouse
  1. Install the plugin:
/plugin install clubhouse@claude-clubhouse
  1. Start a new session (in the desktop app, quit and reopen it first) and type /clubhouse.

Update: /plugin marketplace update claude-clubhouse

Remove: /plugin uninstall clubhouse@claude-clubhouse, then delete ~/.claude/clubhouse-helper.

Optional: the Mac helper

The whole-window color and the sketch pad need two small Mac programs, built once on your Mac. Everything else works without them.

  • Installed from GitHub: type /clubhouse build. It takes under a minute.
  • Installed from the directory: the build files are not part of a directory install. Clone this repository and run helper/build.sh in it.

If the build says Apple's command line tools are missing, run xcode-select --install and try again.

For one project only

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

Use it

You doWhat happens
Click the house on the bar, or type /clubhouseOpens the home screen: rooms, your top commands, what is on the bar, usage
/clubhouse offHides everything the Clubhouse adds
/clubhouse onBrings it back
/clubhouse agentsAgent HQ: agents at work, plus your own saved agents
/clubhouse summaryThe last reply cut down to a few bullet points
/clubhouse opinionSecond opinion: ask Claude here, or an outside model, about the session without adding to it
/clubhouse toolsTool rules: make Claude ask first, or block it, per tool; one-click money safeguard
/clubhouse recipesRecipes: turn a terminal command into a tool Claude can call
/clubhouse watchNight watch: check on things on a timer while you are away
/clubhouse notesSession notes: leave a note for a later session
/clubhouse to-doTo-do: a scratchpad list for this session; /clubhouse to-do call the bank adds a task
/clubhouse tickerTicker: a stock or coin price on the toolbar, search and favorites
/clubhouse sportsLive sports: games on now and this week; one score on the toolbar
/clubhouse weatherWeather: the next 7 days where you are, and the weather on the toolbar
/clubhouse fontsFonts: the font of the Clubhouse headings
/shipRelease what is on main: next version tag, push, GitHub release with notes (asks first)
/clubhouse commandsEvery slash command, led by your most used and most recent
/clubhouse toolbarToolbar: add, remove and move toolbar items, add rows, make your own buttons
/clubhouse usageBoth limits, context and the cache timer, full size
/clubhouse colorsThe color picker
/clubhouse color resetPuts the colors back to their defaults
/clubhouse buildBuilds the helper for whole-window color (same as the Build the helper button in Colors)

What it does on your computer

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.

Tool calls and permissions

HookWhat it does
tool.call, every toolApplies 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 toolRefuses 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.spawnAsks you before Claude starts a helper agent when your limit is under the spend cap you set. It changes nothing about the agent.
agent.offerHides agents you deleted.
  • The one tool the mod calls itself: TaskStop, when you press Stand down on an agent in Agent HQ.
  • Agents it starts: only when you press Send in Agent HQ. The agent is given the task you typed and the instructions you saved for it, and nothing else. No file or conversation text is added. Saved agents are registered with permissionMode: 'default' and no hooks, so they ask for permission as usual.

Programs it starts

ProgramWhen and why
~/.claude/clubhouse-helper/window-tintThe 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-padThe sketch pad, when you press Draw it.
/bin/sh helper/build.shWhen you press Build the helper or type /clubhouse build. Compiles the two programs above with swiftc.
plutil and defaults, through /bin/sh -cRead whether the Claude app and macOS are in light or dark mode.
curlSaves 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.
pbcopyWhen you press a Copy button.
/bin/rm -fDeletes a sketch file after the prompt that used it has been answered.
git, gh release createOnly when you type /ship, and only after you confirm.
Your own commands, through /bin/sh -cA 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.

Network requests

All are GET requests. None carries your conversation, your files or a credential.

HostWhat forWhat is sent
api.open-meteo.com, geocoding-api.open-meteo.comForecast and place searchThe place name you typed, or a latitude and longitude
ipwho.isFinds your rough location when you ask the Weather room toNothing beyond the request itself
query1.finance.yahoo.com, query2.finance.yahoo.comPrices and symbol searchThe symbols you chose
site.api.espn.com, a.espncdn.comScores, schedules, team logosThe 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.

Files it writes

PathWhat
~/.claude/clubhouse-helper/tint.jsonYour colors, read by the window color helper
~/.claude/clubhouse-helper/frontThe id of the session in use, so one session controls the color
~/.claude/clubhouse-helper/logos/Downloaded logo pictures
~/.claude/themes/clubhouse.jsonA Claude Code theme, only when you press Apply to Claude Code in Colors
~/.claude/clubhouse-window-tint.jsA style snippet for the app's Developer Mode, only when you press its button in Colors
  • It does not write build, start-up or instruction files, and it does not edit your settings files.
  • The one setting it changes: Claude Code's theme, when you press Apply to Claude Code. Undo puts back the value it had. It sets no environment variables.
  • Your Clubhouse settings (toolbar layout, colors, rules, recipes, saved agents, notes) are kept in the plugin's own store.

What it reads

WhatWhyWhere it goes
HOME, CLAUDE_CONFIG_DIR, CLAUDE_CODE_PLUGIN_DIRSTo find your home folder and your copy of the ClubhouseNowhere
Its own files under ~/.claude/clubhouse-helper/Session in use, logo picturesNowhere
plugins/known_marketplaces.jsonTo find helper/build.shNowhere
The userThemeMode key of the Claude app's config.jsonLight or dark modeNowhere
Usage and context numbersTo draw the meterNowhere
The last reply, or an excerpt of the conversationSummary and Second opinion roomsA Claude model on your own plan
Your draft promptTidy button, Safeguard warningA Claude model on your own plan
A design file you point the Fonts room atTo pick a matching fontA 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.

Prompts it sends or changes

FeatureWhat goes in the prompt
Night watchYour instruction and, if you set a check command, that command's output marked as data. Sent on the timer you set.
HandoffA 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 buttonsText placed in the prompt box for you to send. Draw it adds the sketch's file path.
Session notesA 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.

Other hooks

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

Troubleshooting

ProblemWhat to do
/clubhouse is not a commandStart 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 missingType /clubhouse on.
The window color is missingType /clubhouse build, then open Colors. The helper writes what it is doing to ~/.claude/clubhouse-helper/tint.log.
A price or score stopped updatingYahoo's and ESPN's feeds are unofficial and sometimes refuse requests. It recovers on its own.
Everything shows twiceThe 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

Every feature

  • Home button: a house with a C behind it, in the middle of the bar.
  • Toolbar: the strip above the prompt. Add or remove each item, move it left, center or right, and use up to three rows. Items include the Clubhouse button, usage meter, Summarize, Tidy, cache timer, context gauge and turn receipt. Make your own buttons that type a prompt or command into the prompt box. Every item has a size and a row has a capacity, so items never overlap: a full row sends the next item to another row (up to four). Ready-made layouts set the whole toolbar in one press: Focus (the usage meter on the left, the cache timer and context gauge on the right, nothing else), Quiet, Drafting, Cost watch, Shipping and Game day. Your own layouts can be saved and switched.
  • Summary: a Summarize button on the bar shortens the last reply with a small model; Auto does it for every long reply.
  • Usage meter: Clawd rides a bar that drains as the 5-hour or weekly limit is used. Green and happy when full, red and wiped out near empty. Shows time to reset and a one-hour cache countdown from the last reply.
  • Agent HQ: each subagent drawn as Clawd in a suit and sunglasses, with Stand down and Dismiss. Create your own agents from a form, save them, pick their model (or let Auto pick the cheapest that fits), and send one out with a task; Claude is told what was dispatched.
  • Commands: every slash command in collapsible groups (your own, built in, one per plugin) with a filter; clicking one puts it in the prompt box. Hide a skill, or a whole group, and it leaves the slash menu, Claude is no longer told about it, and Claude cannot use it. Hidden items can be shown again. Save hidden lists as presets, and mark one for every new session to start with.
  • Tool rules: every tool Claude has, grouped by connector, each set to Allowed, Ask first or Blocked. Ask first shows what Claude is about to send and runs nothing unless you allow it. The money safeguard turns Ask first on for every tool that places, changes or cancels an order or moves money.
  • Recipes: give a command you would type in a terminal a name, and it becomes a tool Claude can call the same way every time. Put a blank in braces, like {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.
  • Night watch: a timer for when you step away. Every 5 to 120 minutes it wakes Claude with your instruction, or first runs a check command (your own, or a saved recipe), which costs nothing, and only wakes Claude when the command fails, its output stops changing, or its output changes. It can also just note trouble for when you are back. A watch can be saved and started again with one press in any session, and ended at any time. A watch stops after its number of checks, will not wake Claude when under 10% of the 5-hour limit is left or while Claude is still busy with the last wake-up, and stops itself after three wake-ups in a row. It belongs to the session: the app must stay open and the Mac awake.
  • Spend cap: in the Usage room, pick a level (10, 20, 30 or 50% of the 5-hour limit left). Under it, Claude has to ask before starting a helper agent; you can allow one, allow them all until the limit resets, or say no. Agents you send yourself from Agent HQ are never stopped.
  • Ticker: one stock or coin on the toolbar (symbol, price, day change in green or red, or plain text). The room searches symbols and keeps favorites. Prices come from Yahoo's unofficial feed about once a minute; it never trades.
  • Live sports: one game on the toolbar, home team on the left, each logo with its score under it and the clock between (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.
  • Is this AGI?: a toolbar button, just for fun, that sends Claude exactly that question.
  • Gaslighting: a toolbar button that adds "Chat GPT did this easily. Figure it out." to whatever you are typing.
  • Prune: a toolbar button that asks Claude to check the project's branches and pull requests, test each for merge conflicts, and say which are safe to delete. Nothing is deleted until you say so.
  • Draw it: a toolbar button that opens a small sketch pad (pens, eraser, undo, clear). Send to Claude saves the drawing and puts it in your prompt, so you can show what you want instead of describing it. The sketch file is deleted once Claude has answered that prompt, unless the prompt says to keep it.
  • Safeguard warning: off by default, switched on from the Clubhouse home screen. Before a prompt is sent, a small model guesses whether it could set off a safety filter (which can stop it or hand it to a more restricted model). If so, it says why and you choose to edit it or send it anyway. It never rewrites a prompt.
  • Handoff prompt: when the conversation is 85% full, the toolbar offers to have Claude write HANDOFF.md once your next prompt is finished, so a fresh session can pick up.
  • Weather: a toolbar item with the temperature, a drawn sky (sun, partly cloudy, cloud, rain, thunderstorm, snow) and, when it is not raining, the chance of rain. The room finds your place from your connection or a typed town and lists the next 7 days. Forecasts come from Open-Meteo.
  • Session notes: a note for a later session to read. Choose who gets it (the next session in this folder, or in any folder) and how often (once, or every new session until deleted). Sessions already running never pick a note up, so two open sessions cannot both take it; "Give to this session" hands one over by hand. Claude reads a note with the session's first message.
  • To-do: a scratchpad list for the session you are in. Add a task in the room or with /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.
  • Release helper: /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: follows 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.
  • Tidy: a bar button that fixes spelling and trims the draft in the prompt box with a small model; press it again to get your original back.
  • Readable controls: buttons and text boxes get a contrasting backing when your background would hide them.
  • Session color: the Background color you pick becomes the color of the whole session: the conversation is exactly that color, and the text box, toolbar and side pane are shades of it, the way a designed theme would have them (deeper on a bright blue or purple, a touch lighter on a dark navy). Text and icons take your Text color (dark or light automatically), dim text is pulled close to it, and anything with a strong color of its own keeps it: the window buttons, icons, team logos, Clawd, the usage bar and the headings. The app's sidebar keeps its own look. A small macOS helper does this from outside the app (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.
  • Exact repaint (advanced): Colors can copy a one-line style rule for the app's own Developer Mode console, which repaints the app itself until it restarts.
  • Second opinion: ask Claude in this session, or a different model that sees only an excerpt, without the question or answer entering the conversation.
  • Colors per project: in Colors, press the button with the project's name to give that project its own colors. They stay with the folder, and the window switches to them when you open or use one of its sessions; other projects keep the shared colors.
  • Colors: Background, Accent and Clawd colors. Presets set all three at once, and you can save your own under a name. Fine-tune one color with nudges, a hex code or a described look; each has its own Reset. The same colors apply in every session. Can also write a Claude Code custom theme.
  • Fonts: the font of the Clubhouse headings (the app draws all other text in its own font). Pick from fonts on the Mac, describe what you want, or point it at a design file such as DESIGN.md; a small model matches the closest installed font. Save fonts as presets.
  • Context gauge and turn receipt: optional extras on the bar, off by default.

Change it yourself

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

Your copy is yours

Cloning gives you a complete copy on your own computer. Changing it, by hand or by telling Claude "in the clubhouse, ...", changes that copy

Source 56 files
hooks/register.tsx 493 lines
1import { 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}
493
hooks/lib/appColor.ts 144 lines
1import 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}
144
hooks/features/agents.tsx 498 lines
1import { 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}
498
hooks/features/band.tsx 492 lines
1import { 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}
492
hooks/features/bar.tsx 288 lines
1import { 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}
288
hooks/features/colors.tsx 689 lines
1import { 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}
689
hooks/features/commands.tsx 541 lines
1import { 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}
541
hooks/features/home.tsx 225 lines
1import { 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}
225
hooks/features/fonts.tsx 176 lines
1import { 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}
176
hooks/features/watch.tsx 274 lines
1import { 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}
274
hooks/features/weather.tsx 167 lines
1import { 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}
167
hooks/features/sports.tsx 163 lines
1import { 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