A game HUD pane: hero stats, summoned subagents, a git world map, skills and background jobs

使用 GNU Stow 管理個人設定檔,方便在不同機器上快速部署一致的開發環境。
| 套件 | 說明 | 安裝後的路徑 |
|---|---|---|
zsh | Zsh shell 設定 | ~/.zshrc、~/zshrc.d/ → config/zsh/zshrc.d/ |
tmux | tmux 終端多工器設定 | ~/.tmux.conf |
ghostty | Ghostty 終端模擬器設定 | ~/.config/ghostty/config |
cmux | Cmux 終端機設定 | ~/.config/cmux/ |
claude | Claude Code 系統提示 + output styles + hooks 範本 + RPG status line | ~/.claude/CLAUDE.md、~/.claude/output-styles/、~/.claude/rpg-statusline.sh |
claude-mods | Claude Code mods(hooks plugin:pane、band 等) | ~/.claude/mods/<mod>/ → 目錄 symlink |
codex | Codex CLI 系統提示 | ~/.codex/AGENTS.md |
hammerspoon | Hammerspoon macOS 自動化 | ~/.hammerspoon/ |
ripgrep | ripgrep 搜尋工具設定 | ~/.ripgreprc |
git | 全域 git ignore(XDG 路徑,免設定 git config) | ~/.config/git/ignore |
config/shared/skills/為共享 skills 的單一來源,不是 stow 套件。stow-wrap.sh部署 AI CLI 套件時會自動將其 symlink 到每個工具的~/.<tool>/skills/。
# Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# GNU Stow
brew install stow
# CLI 增強工具
brew install eza bat htop fd fzf ripgrep zoxide jq direnv
# 開發工具(按需安裝)
brew install tmux pyenv
依照官方安裝腳本安裝:https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script
# Oh-My-Zsh
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
# 第三方 plugins
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git \
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
git clone https://github.com/zsh-users/zsh-autosuggestions.git \
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
git clone https://github.com/jeffreytse/zsh-vi-mode.git \
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-vi-mode
# Powerlevel10k 主題
git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \
${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k
其餘 plugins(
git、macos、sudo、extract、colored-man-pages、command-not-found)為 Oh-My-Zsh 內建。zsh-vi-mode已啟用系統剪貼簿整合(ZVM_SYSTEM_CLIPBOARD_ENABLED=true)。
git clone https://github.com/junegunn/fzf-git.sh.git ~/fzf-git.sh
brew install --cask font-jetbrains-mono-nerd-font
# 關閉長按字元選單,改為按住重複輸入(vim 操作必要)
defaults write -g ApplePressAndHoldEnabled -bool false
需登出再登入或重新啟動 app 才會生效。
git clone <此 repo 的 URL> ~/dotfiles
cd ~/dotfiles
# 備份現有設定檔
mkdir -p ~/.dotfiles-backup
for f in ~/.zshrc ~/.tmux.conf ~/.ripgreprc \
~/.config/ghostty/config ~/.config/cmux/cmux.json \
~/.claude/CLAUDE.md ~/.codex/AGENTS.md \
~/.hammerspoon/init.lua; do
# 用 cp -L 解引用 symlink,確保備份的是實體內容
[ -e "$f" ] && mkdir -p ~/.dotfiles-backup/"$(dirname "${f#$HOME/}")" \
&& cp -L "$f" ~/.dotfiles-backup/"${f#$HOME/}" \
&& rm "$f"
done
# 部署所有套件
chmod +x scripts/stow-wrap.sh
for pkg in zsh tmux ghostty cmux claude claude-mods codex hammerspoon ripgrep git; do
./scripts/stow-wrap.sh "$pkg"
done
p10k configure
~/.p10k.zsh由 p10k 精靈產生,不納入版控。專案中的36-p10k-theme.zsh會自動覆寫 Gruvbox 色彩主題。
Codex 的 hooks.json 已由 stow 部署,按以下步驟完成設定:
~/.codex/config.toml 啟用 feature flag: ``toml [features] codex_hooks = true ``./scripts/stow-wrap.sh claude 時,stow-wrap 會自動呼叫 scripts/sync-ai-cli-settings.sh:~/.<tool>/settings.json 不存在 → 直接複製 .example 範本jq 智慧合併:permissions.allow/deny 取聯集去重;hooks 以 command 字串為鍵冪等附加;env 同 key 以範本為準。plugins、mcpServers 等使用者自訂內容完全保留settings.json.bak.YYYYMMDD-HHMMSS 備份Claude Code 的
settings.json含機器專屬設定(plugins、MCP servers),無法整檔由 stow 管理,故改採「不存在則複製、已存在則合併」策略。jq為必要相依(brew install jq)。可用--dry-run預覽合併動作。
cp ~/dotfiles/config/zsh/zshrc.d/90-local.zsh.example ~/dotfiles/config/zsh/zshrc.d/90-local.zsh
編輯 90-local.zsh 加入機器專屬的 PATH、環境變數或 secrets 來源。~/zshrc.d/ 是指向 config/zsh/zshrc.d/ 的 symlink,因此直接在 dotfiles 內操作即可。此檔案已加入 .gitignore,不會被提交。
敏感資訊(API key、token)建議放在
~/.secrets,並在90-local.zsh中 source 它。
exec zsh
ls -la ~/.zshrc ~/.tmux.conf
alias
which fzf eza
./scripts/stow-wrap.sh zsh # 部署套件
./scripts/stow-wrap.sh --dry-run zsh # 預覽模式
./scripts/stow-wrap.sh --debug zsh # 除錯模式
./scripts/stow-wrap.sh --list-ignore # 列出忽略規則
./scripts/stow-wrap.sh -D zsh # 移除套件 symlink
bin、claude、codex套件會自動以--no-folding模式部署(腳本內的NO_FOLDING_PKGS),避免將目標目錄折疊為單一 symlink,確保非 dotfiles 管理的檔案不受影響。多套件混合執行時會自動拆分為獨立呼叫。部署完成後,
config/shared/skills/內的共享 skills 會自動 symlink 至各 AI CLI 工具的~/.<tool>/skills/(腳本內的AI_CLI_PKGS,目前為claude、codex);工具專屬 skill 優先,不會被覆蓋。bin雖同為 no-folding 套件但不參與 skills 注入。shared不是合法的套件名稱,傳入會直接報錯。
--no-folding 為什麼必要stow 預設會做 directory folding:若目標目錄在 $HOME 尚不存在,stow 不會逐檔建 symlink,而是直接把整個目錄做成一個指向 repo 的 symlink。之後任何寫入該目錄的檔案都會實際落在 dotfiles repo 內。
實際案例:曾有一個 bin 套件(放 cmux-notify)對應 ~/.local/bin/,但 ~/.local/ 同時是 uv、Claude Code installer 等工具的安裝位置。~/.local 被折疊成 ~/.local -> dotfiles/config/bin/.local 後,這些工具安裝的內容累積到 1.5 GB 全部落在 repo 內。該套件已隨 cmux-notify 一併移除,但教訓保留於此。
判斷準則:只要套件的目標路徑或其上層目錄可能被 dotfiles 以外的程式寫入,就要加進 NO_FOLDING_PKGS。
同理,
.gitignore不要使用*.local這類樣式 —— gitignore 的*可匹配零字元,會連名為.local的目錄整棵樹一起忽略,使上述污染在git status完全隱形。
~/.zshrc ← Stow symlink,最小化 loader
├── direnv 預載 ← 在 instant prompt 前完成首次 .envrc 載入,避免 p10k 警告
├── p10k instant prompt ← 最頂端載入,確保 prompt 即時顯示
└── source zshrc.d/*.zsh ← 依檔名順序載入以下模組
├── 00-paths.zsh ← PATH 設定($HOME/bin、$HOME/.local/bin)
├── 01-env.zsh ← 環境變數(Powerline、NVM、Pyenv、ripgrep)
├── 02-omz.zsh ← Oh-My-Zsh 框架、主題、plugins
├── 10-functions.zsh ← 載入 functions.d/*.sh 輔助函式
├── 20-aliases.zsh ← 條件式別名(htop、bat、eza、tmux)
├── 30-fzf.zsh ← FZF 模糊搜尋(色彩、fd、preview、rfv)
├── 31-zoxide.zsh ← Zoxide 智慧目錄跳轉
├── 32-direnv.zsh ← direnv 目錄式環境變數自動載入(須先 brew install direnv)
├── 35-p10k.zsh ← 載入 ~/.p10k.zsh(各機器獨立)
├── 36-p10k-theme.zsh ← Gruvbox 色彩主題覆寫
└── 90-local.zsh ← 機器專屬設定(不納入版控)
每個模組使用 guard 變數(不 export)防止同一 shell 內重複載入,不會影響 tmux 等子 shell 的初始化。
兩個工具各自維護獨立的系統提示檔,皆透過 stow 管理。目前內容相同,可依不同 LLM 特性分別微調。
config/shared/skills/ 是所有 AI CLI 工具共用 skills 的唯一來源。部署時 stow-wrap.sh 自動將每個 skill 目錄 symlink 到各工具的 ~/.<tool>/skills/<skill>,無需手動同步。
若特定工具需要專屬 skill,將其放入 config/<tool>/.<tool>/skills/<skill>/;該目錄下的 skill 由既有 promote 流程優先處理,不會被共享版本覆蓋。
新增 / 修改共享 skill 只需操作 config/shared/skills/,重新執行 ./scripts/stow-wrap.sh <AI 工具套件> 即可生效。
由 cmux 自行處理,dotfiles 不再維護通知腳本。
| 工具 | 安裝方式 | 產生的檔案 |
|---|---|---|
| Claude Code | cmux Claude wrapper 自動注入,無須設定 | 無(wrapper 動態注入) |
| 其他 agent | cmux hooks setup <agent> | 各 agent 自己的 hook 檔(cmux 管理) |
Claude Code 只要 cmux 設定中 automation.claudeCodeIntegration 為 true 即生效:cmux 用 wrapper 包住 claude 執行檔(PATH 最前面的 cmux-cli-shims/),啟動時動態注入自己的 hooks, 提供 running/idle/needsInput 狀態、Feed 審批、session restore 與 PushNotification 橋接。
其他 agent(codex、opencode、gemini 等)用 cmux hooks setup 安裝,cmux 會寫入該 agent 自己的設定檔(Codex 為 ~/.codex/hooks.json 與 config.toml)。這些檔案由 cmux 管理,不納入 dotfiles——否則 cmux 更新格式時 repo 內的手寫版本會悄悄失效。
歷史:先前由 dotfiles 維護一支
cmux-notify腳本供各工具呼叫,因 cmux 變更 socket 路徑 (/tmp/cmux.sock→~/.local/state/cmux/cmux-<uid>.sock)而靜默失效。既然 cmux 已內建整合, 該腳本與其所屬的bin套件已一併移除。細節見cmux docs agents。
rpg-statusline.sh)claude 套件含 ~/.claude/rpg-statusline.sh(stow symlink),風格對齊 claude-mods 的遊戲 UI。 settings.json.example 的 statusLine 指向它,merge-settings.jq 會以範本覆蓋本機 statusLine。
COLUMNS 以段為單位自動換行:model + effort 星等、⚡ fast mode、目錄 / 分支 / ✎ dirty / 增刪行數 / PR、資源條、session 時長MP 為 context window(與 rpg-hud 人物面板的 MP 同義、同變色門檻),⌛5h / ⌛7d 為用量限額並附重置日期時間,⛁ cap 為 spend limit(僅 gateway)refreshInterval: 2:調整視窗寬度不會觸發 status line 更新,靠每 2 秒重跑讓換行跟上新寬度slime-band,不重複顯示~/.claude/settings.json 的 statusLine,並從 settings.json.example 移除,否則下次 stow-wrap.sh claude 會加回cc-notify.sh)claude 套件另含 ~/.config/claude/cc-notify.sh(由 stow 部署為 symlink),在 Claude Code 「工作真正完成」時發一則 Telegram 訊息。設計為 trailing-edge idle debounce:主 agent 閒置 CC_NOTIFY_IDLE_WINDOW 秒後才發,任何後續活動(新 prompt、工具呼叫、subagent 結束)都會 取消待發通知,因此多 subagent/多 turn 的工作會 collapse 成單一通知。相關 hooks (UserPromptSubmit/PreToolUse/SubagentStop/Stop)已寫入 settings.json.example,由 sync 合併。
設定:複製 ~/.config/claude/telegram.env.example 為 telegram.env 並填入 TELEGRAM_BOT_TOKEN、 TELEGRAM_CHAT_ID(此檔含 secret,不納入版控);可選用 CC_NOTIFY_IDLE_WINDOW(預設 25s)與 CC_NOTIFY_MIN_SECONDS(預設 30s,主 turn 短於此不通知)。未設定 telegram.env 時腳本靜默略過。
claude 套件含 config/claude/.claude/output-styles/,由 stow 以 --no-folding 逐檔 symlink 到 ~/.claude/output-styles/,因此 Claude Code 自己在該目錄產生的 style 不受影響。目前收錄:
| Style | 用途 |
|---|---|
eli5 | 極簡回覆,只講做了什麼、成不成功、下一步做什麼 |
ste | 受控技術語言(ASD-STE100 Issue 9 紀律):單一詞義、短句、主動語態、零歧義 |
新增 style:在 config/claude/.claude/output-styles/ 放入 <name>.md(frontmatter 需含 name、description),再跑 ./scripts/stow-wrap.sh claude。切換用 /output-style。
claude-mods 套件收錄 config/claude-mods/.claude/mods/<mod>/,由 CLAUDE_CODE_PLUGIN_DIRS (settings.json.example 的 env,經 sync-ai-cli-settings.sh 合併)載入。目前收錄 rpg-hud、 slime-band、skill-bar。
此套件刻意不用 --no-folding:plugin loader 讀 hooks/hooks.json 時不跟隨 symlink,且 module realpath 落在 plugin 目錄外會被判為 path traversal,逐檔 symlink 會讓 mod 整個載入失敗。folding 後 ~/.claude/mods/<mod> 是指向 repo 的目錄 symlink,engine 產生的 .claude-plugin/types/ 會寫進 repo (已 gitignore)。
新增 mod:在 config/claude-mods/.claude/mods/ 建目錄、把路徑加進 settings.json.example 的 CLAUDE_CODE_PLUGIN_DIRS,再跑 ./scripts/stow-wrap.sh claude claude-mods。驗證用 claude plugin validate ~/.claude/mods/<mod> 與 claude plugin test ~/.claude/mods/<mod>。
各工具的設定檔(config.toml、settings.json、config.json)包含機器專屬內容,不納入版控,各機器獨立維護。
git pull 只更新 repo 內的檔案,無法撤銷既有部署。從 repo 刪除一個檔案後,其他機器上仍會留下:
sync-ai-cli-settings.sh 合併進 live ~/.claude/settings.json 的項目(該腳本只增不減)[hooks.state] trusted hash)因此凡是「下架」性質的變更,都在 scripts/ 下附一支 migrate-<YYYYMMDD>-<描述>.sh,記錄該次變更需要在其他機器上執行的清理步驟。慣例:
--dry-run,預覽時不得寫入任何檔案cp -p 保留權限),並在輸出中告知備份路徑現有腳本:
./scripts/migrate-20260812-remove-cmux-notify.sh --dry-run # 預覽
./scripts/migrate-20260812-remove-cmux-notify.sh # 執行
若尚未 pull,優先在 pull 之前執行
./scripts/stow-wrap.sh -D <套件>解除部署——套件目錄一旦被 pull 刪除,stow -D就無法再運作。
config/ 下建立新目錄,結構反映 $HOME 下的相對路徑scripts/stow-wrap.sh 的 NO_FOLDING_PKGS(原因見上方「--no-folding 為什麼必要」)./scripts/stow-wrap.sh <套件名> 部署ls -ld ~/<目標目錄>hooks/register.tsx 1496 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type {
5 BagItem,
6 Boss,
7 Combo,
8 Gear,
9 GitMap,
10 Inspect,
11 Job,
12 Loadout,
13 MapView,
14 Outpost,
15 Pet,
16 Progress,
17 SpellFilter,
18 Tab,
19 Tally,
20 ToolCall,
21 ToolCallStatus,
22 TouchedFile,
23 Vitals,
24} from '../types'
25import { FRAME_MS, HERO_CELLS, HURT_MS, PET_TICKS, anim, pickFrame, pickMood } from './anim'
26import { fightBoss, fightText, runsGit } from './boss'
27import type { BagProps, BagRow } from './bag-client'
28import { GIT_LOG, GIT_NUMSTAT, GIT_REMOTES, GIT_STATUS, GIT_WORKTREES, diffArgv, outpostArgv, parseBag, parseStatus, parseWorktrees, shortAge } from './git'
29import type { OutpostRow, OutpostsProps } from './outposts-client'
30import { bagSubtitle, inspectItems } from './inspect'
31import { renderEditCard } from './edit-card'
32import type { EditOutput } from './edit-card'
33import { CALLS_PER_LEVEL, STATUS_ROWS, heroStats, normalizeCall, renderStatus } from './hero'
34import { CLASS_IDS, classOf, isClassId, rankOf, rollClass } from './classes'
35import { mapItems, mapSubtitle } from './map'
36import { ICON_COLUMNS, ICON_ROWS, MENU, entryOf, iconText, isTab } from './menu'
37import { cardWidth, slotKey } from './menu-client'
38import type { MenuIconProps } from './menu-client'
39import { PET_COLUMNS, PET_ROWS, petCells, petSeed, speciesFor } from './pet-sprites'
40import { partySubtitle, petItems } from './pets'
41import { recapText } from './recap'
42import { castText, skillItems, skillKeys, skillRows, skillsSubtitle } from './skills'
43import { filterSpells, spellFilters, spellItems } from './spellbook'
44import { HELP_COMMANDS, helpItems, helpText } from './help'
45import { EMPTY_PROGRESS, progressOf } from './progress'
46import { addJob, endJob, jobItems, jobsSubtitle, parseJobEnds, reconcileJobs, trimJobs } from './jobs'
47import type { JobEnd } from './jobs'
48import { summarize } from './util'
49import { WINDOW_CHROME_COLUMNS, WINDOW_CHROME_ROWS, clampOffset, pageItems, renderWindow, totalRows } from './window'
50import type { Item } from './window'
51
52const calls = atom({ plugin: 'rpg-hud', key: 'calls' } as const, [] as ToolCall[])
53const castCount = atom({ plugin: 'rpg-hud', key: 'castCount' } as const, 0)
54// Rows scrolled down each menu window; the status panel and the menu never scroll.
55const offsets = atom({ plugin: 'rpg-hud', key: 'offsets' } as const, {} as Record<string, number>)
56const flashUntil = atom({ plugin: 'rpg-hud', key: 'flashUntil' } as const, 0)
57const tab = atom({ plugin: 'rpg-hud', key: 'tab' } as const, 'spells' as Tab)
58// Empty until the session rolls its class; a reload keeps the roll.
59const heroClass = atom({ plugin: 'rpg-hud', key: 'heroClass' } as const, '')
60const pets = atom({ plugin: 'rpg-hud', key: 'pets' } as const, [] as Pet[])
61const jobs = atom({ plugin: 'rpg-hud', key: 'jobs' } as const, [] as Job[])
62const stopArmed = atom({ plugin: 'rpg-hud', key: 'stopArmed' } as const, null as string | null)
63const vitals = atom({ plugin: 'rpg-hud', key: 'vitals' } as const, {} as Vitals)
64const combo = atom({ plugin: 'rpg-hud', key: 'combo' } as const, { current: 0, best: 0 } as Combo)
65const boss = atom({ plugin: 'rpg-hud', key: 'boss' } as const, null as Boss | null)
66const map = atom({ plugin: 'rpg-hud', key: 'map' } as const, {
67 isRepo: false,
68 branch: '',
69 ahead: 0,
70 behind: 0,
71 dirty: 0,
72 graph: [],
73} as GitMap)
74const touched = atom({ plugin: 'rpg-hud', key: 'touched' } as const, [] as TouchedFile[])
75const bag = atom({ plugin: 'rpg-hud', key: 'bag' } as const, [] as BagItem[])
76const mapView = atom({ plugin: 'rpg-hud', key: 'mapView' } as const, { view: 'map' } as MapView)
77const inspect = atom({ plugin: 'rpg-hud', key: 'inspect' } as const, null as Inspect | null)
78const outposts = atom({ plugin: 'rpg-hud', key: 'outposts' } as const, [] as Outpost[])
79const outpostBag = atom({ plugin: 'rpg-hud', key: 'outpostBag' } as const, null as { root: string; items: BagItem[] } | null)
80const loadout = atom({ plugin: 'rpg-hud', key: 'loadout' } as const, { skills: [], gear: [] } as Loadout)
81const skillCasts = atom({ plugin: 'rpg-hud', key: 'skillCasts' } as const, {} as Record<string, number>)
82const spellFilter = atom({ plugin: 'rpg-hud', key: 'spellFilter' } as const, 'all' as SpellFilter)
83const EMPTY_TALLY: Tally = { failures: 0, refusals: 0, bossesDefeated: 0, petsSummoned: 0 }
84const tally = atom({ plugin: 'rpg-hud', key: 'tally' } as const, EMPTY_TALLY)
85const startedAt = atom({ plugin: 'rpg-hud', key: 'startedAt' } as const, 0)
86const isHelpOpen = atom({ plugin: 'rpg-hud', key: 'isHelpOpen' } as const, false)
87const waitingFor = atom({ plugin: 'rpg-hud', key: 'waitingFor' } as const, null as string | null)
88
89// Starts the HUD's watch of the session, once; a reload keeps it.
90const markStart = async ($: EngineInterface) => {
91 if ((await read($, startedAt)) === 0) {
92 const now = await $.clock.now()
93 await update($, startedAt, at => (at === 0 ? now : at))
94 }
95}
96
97// Moving to another session (a /clear, an in-session /resume) empties every figure the HUD keeps for the one
98// it leaves: the calls, the level, the combo, the boss, the files touched, the casts that glow, the tally, and
99// the finished pets and jobs (one still questing or running will still come back). The new session's watch starts now and it rolls
100// its own class. The view (tab, filter, scroll of other windows) stays, and what is read from the world (map,
101// bag, loadout, vitals) is read again by the caller.
102async function resetSession($: EngineInterface) {
103 const now = await $.clock.now()
104 await Promise.all([
105 update($, calls, () => []),
106 update($, castCount, () => 0),
107 update($, offsets, () => ({})),
108 update($, flashUntil, () => 0),
109 update($, combo, () => ({ current: 0, best: 0 })),
110 update($, boss, () => null),
111 update($, touched, () => []),
112 update($, skillCasts, () => ({})),
113 update($, tally, () => EMPTY_TALLY),
114 update($, waitingFor, () => null),
115 update($, startedAt, () => now),
116 update($, pets, list => list.filter(pet => pet.status === 'run')),
117 update($, jobs, list => list.filter(job => job.status === 'run')),
118 update($, stopArmed, () => null),
119 ])
120 await chooseClass($, rollClass(Math.random()))
121}
122
123const progress = atom({ plugin: 'rpg-hud', key: 'progress' } as const, EMPTY_PROGRESS)
124
125const PLUGIN = 'rpg-hud'
126const PANE = 'rpg-hud'
127const TITLE = 'Adventure'
128const COMMAND = 'hud'
129const PROGRESS_KEY = 'progress'
130// Set to false by `/hud cards` to give Edit and Write results back to the engine's own drawing.
131const CARDS_KEY = 'editCards'
132const CARD_TOOLS = new Set(['Edit', 'Write'])
133
134// A menu card: its frame's top and bottom, the pixel icon and the label button between.
135const SLOT_ROWS = 1 + ICON_ROWS + 1 + 1
136// The narrowest card that holds the 8-column icon, and the longest label, inside its frame.
137const MIN_SLOT_COLUMNS = 10
138// The window keeps at least this many rows before the menu shrinks to one line.
139const MIN_WINDOW_ROWS = 4
140// The status panel glows this long after a level up.
141const FLASH_MS = 2500
142
143const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
144// The longest diff the inspect view keeps, and how many new files' lines the bag counts.
145const DIFF_LINES = 3000
146const COUNTED_NEW_FILES = 30
147// Outposts beyond this many are listed without their status, which costs a git run each.
148const SURVEYED_OUTPOSTS = 10
149
150const firstLine = (text: string): string => text.split('\n').find(line => line.trim().length > 0)?.trim() ?? ''
151
152// Claude Code's answer when the person says no at a permission prompt. No event names that call, so its text is
153// the only sign; auto mode's denials come as `classic.PermissionDenied` with the call's id instead.
154const REFUSAL = /^The user doesn't want to (?:proceed|take this action)/
155
156// A call refused before it ran: a hook's deny, or the person's no at a permission prompt. A choice, not a fizzle.
157export const isRefusal = (ran: { deny?: string; isError?: boolean; text?: string }): boolean =>
158 ran.deny !== undefined || (ran.isError === true && REFUSAL.test(ran.text ?? ''))
159
160// A tool's touch on a file, counted under the path relative to the session's folder.
161export const touchFile = (list: readonly TouchedFile[], path: string, isEdit: boolean): TouchedFile[] => {
162 const found = list.find(file => file.path === path) ?? { path, reads: 0, edits: 0 }
163 const next = { ...found, reads: found.reads + (isEdit ? 0 : 1), edits: found.edits + (isEdit ? 1 : 0) }
164 return [...list.filter(file => file.path !== path), next].slice(-200)
165}
166
167// The skill a typed `/name args` prompt invokes, when `name` is a known skill.
168export const skillOfPrompt = (text: string, known: readonly string[]): string | undefined => {
169 const name = /^\/([\w:.-]+)/.exec(text.trim())?.[1]
170 return name !== undefined && known.includes(name) ? name : undefined
171}
172
173// Changes waiting to be saved, and the saves, one after another: each reads what the last one wrote.
174let pendingChanges: ((p: Progress) => Progress)[] = []
175let progressQueue: Promise<void> = Promise.resolve()
176
177// Applies every waiting change to the lifetime progress in one store read and write. The store is shared by
178// every session, so the changes apply to what the store holds now, other sessions' work included, never to this
179// session's copy; this session's copy then follows the store.
180async function saveProgress($: EngineInterface) {
181 if (pendingChanges.length === 0) return
182 const changes = pendingChanges
183 pendingChanges = []
184 // A store that cannot be read is never written: building on nothing would erase every session's progress.
185 // This session's copy takes the changes alone until the store answers again.
186 const read_ = await $.store.get(PROGRESS_KEY).then(
187 value => ({ isRead: true, value: value as Partial<Progress> | undefined }),
188 () => ({ isRead: false, value: undefined }),
189 )
190 const base = read_.isRead ? read_.value : await read($, progress)
191 const next = changes.reduce((p, change) => change(p), progressOf(base))
192 if (read_.isRead) {
193 // A failed write keeps the changes for the next save rather than dropping the whole batch.
194 const isSaved = await $.store.set(PROGRESS_KEY, next).then(() => true, () => false)
195 if (!isSaved) {
196 pendingChanges = [...changes, ...pendingChanges]
197 return
198 }
199 }
200 await update($, progress, () => next)
201}
202
203// Queues `change` to the lifetime progress. Changes made while a save runs wait for the next one, which takes
204// them all at once, so a burst of tool calls costs one store round trip, not one each. Never rejects: a caller
205// on the tool path need not wait.
206function advance($: EngineInterface, change: (p: Progress) => Progress): Promise<void> {
207 pendingChanges.push(change)
208 progressQueue = progressQueue.then(() => saveProgress($)).catch(() => undefined)
209 return progressQueue
210}
211
212async function loadProgress($: EngineInterface) {
213 const stored = (await $.store.get(PROGRESS_KEY).catch(() => undefined)) as Partial<Progress> | undefined
214 await update($, progress, () => progressOf(stored))
215}
216
217// How full the context gets before mana runs low, warned once each until a compact or /clear empties it again.
218export const MANA_ALARMS = [80, 90] as const
219
220// The alarm `percent` sounds after `warned` (the last alarm sounded), and the alarm to remember next. Falling
221// below an alarm forgets it, so a context filled again after a compact warns again.
222export const manaAlarm = (percent: number, warned: number): { alarm?: number; warned: number } => {
223 const reached = MANA_ALARMS.filter(level => percent >= level).at(-1) ?? 0
224 return reached > warned ? { alarm: reached, warned: reached } : { warned: Math.min(warned, reached) }
225}
226
227// The last alarm this session sounded.
228let manaWarned = 0
229
230async function refreshVitals($: EngineInterface) {
231 const usage = await $.session.usage().catch(() => undefined)
232 if (usage === undefined) return
233 const contextPercent = usage.context.percent
234 await update($, vitals, () => ({ contextPercent, usd: usage.cost?.usd }))
235 if (contextPercent === undefined) return
236 const { alarm, warned } = manaAlarm(contextPercent, manaWarned)
237 manaWarned = warned
238 if (alarm !== undefined) $.ui.toast(`🔮 Mana low: context ${alarm}% full. /compact to restore it`)
239}
240
241// The skills and MCP servers the context lists, from the free local estimate of /context.
242async function refreshLoadout($: EngineInterface) {
243 const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
244 const breakdown = usage?.context.breakdown
245 if (breakdown === undefined) return
246 const skills = (breakdown.skills?.skillFrontmatter ?? []).map(skill => ({
247 name: skill.name,
248 source: skill.source,
249 ...(skill.pluginName === undefined ? {} : { plugin: skill.pluginName }),
250 }))
251 const servers = new Map<string, Gear>()
252 for (const tool of breakdown.mcpTools) {
253 const gear = servers.get(tool.serverName) ?? { server: tool.serverName, tools: 0, isLoaded: false }
254 servers.set(tool.serverName, { ...gear, tools: gear.tools + 1, isLoaded: gear.isLoaded || tool.isLoaded })
255 }
256 const gear = [...servers.values()].sort((a, b) => a.server.localeCompare(b.server))
257 await update($, loadout, () => ({ skills, gear }))
258}
259
260const toplevel = async ($: EngineInterface) =>
261 (await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => undefined))?.stdout.trim()
262
263// A path with its symbolic links resolved (macOS's /tmp is /private/tmp), or as given when it cannot be.
264const realOf = async ($: EngineInterface, path: string) =>
265 (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath ?? path
266
267const baseName = (path: string) => path.split('/').filter(part => part.length > 0).pop() ?? path
268
269// One folder's bag from its status: numstat run in `root` (an outpost) or the session's folder, untracked files
270// read from `base`. A file git does not track yet has no numstat: its lines all count as added.
271async function loadBag($: EngineInterface, status: string, root: string | undefined, base: string | undefined) {
272 const numstat = await $.process.run(root === undefined ? GIT_NUMSTAT : outpostArgv.numstat(root)).catch(() => undefined)
273 const items = parseBag(status, numstat?.exitCode === 0 ? numstat.stdout : '')
274 let counted = 0
275 return Promise.all(
276 items.map(async item => {
277 if (item.status !== '?' || base === undefined || counted >= COUNTED_NEW_FILES) return item
278 counted += 1
279 const text = await $.fs.read(`${base}/${item.path}`).catch(() => undefined)
280 return text === undefined ? item : { ...item, added: text.split('\n').length - (text.endsWith('\n') ? 1 : 0) }
281 }),
282 )
283}
284
285// `files`: what an edit can change, the status and the bag. `all`: the graph, the remotes and the outposts too.
286async function refreshMap($: EngineInterface, scope: 'files' | 'all' = 'all') {
287 const isAll = scope === 'all'
288 const [status, log, remoteList] = await Promise.all([
289 $.process.run(GIT_STATUS).catch(() => undefined),
290 isAll ? $.process.run(GIT_LOG).catch(() => undefined) : undefined,
291 isAll ? $.process.run(GIT_REMOTES).catch(() => undefined) : undefined,
292 ])
293 if (status === undefined || status.exitCode !== 0) {
294 await update($, map, current => ({ ...current, isRepo: false }))
295 await update($, bag, () => [])
296 await update($, outposts, () => [])
297 return
298 }
299 const root = await toplevel($)
300 if (isAll) {
301 const graph = log?.exitCode === 0 ? log.stdout.split('\n').filter(line => line.length > 0) : []
302 const remotes = remoteList?.exitCode === 0 ? remoteList.stdout.split('\n').filter(name => name.length > 0) : []
303 await update($, map, () => ({ isRepo: true, graph, remotes, ...parseStatus(status.stdout) }))
304 } else {
305 await update($, map, current => ({ ...current, isRepo: true, ...parseStatus(status.stdout) }))
306 }
307 const items = await loadBag($, status.stdout, undefined, root)
308 await update($, bag, () => items)
309 if (isAll) await listOutposts($, root, false)
310}
311
312// The worktrees as outposts. Listing them is one git run; `survey` also reads each one's branch distance, unsaved
313// work and last commit (two runs each), which only the outposts view shows, so it alone asks for it.
314// Without a survey an outpost keeps the figures its last one found.
315async function listOutposts($: EngineInterface, root: string | undefined, survey: boolean) {
316 const listed = await $.process.run(GIT_WORKTREES).catch(() => undefined)
317 if (listed === undefined || listed.exitCode !== 0) return void (await update($, outposts, () => []))
318 const entries = parseWorktrees(listed.stdout)
319 // One outpost is the repository alone: nothing to show.
320 if (entries.length < 2) return void (await update($, outposts, () => []))
321 const here = root === undefined ? undefined : await realOf($, root)
322 const previous = await read($, outposts)
323 const list = await Promise.all(
324 entries.map(async (entry, index): Promise<Outpost> => {
325 const old = previous.find(one => one.path === entry.path)
326 const realPath = await realOf($, entry.path)
327 const known = { ahead: old?.ahead ?? 0, behind: old?.behind ?? 0, dirty: old?.dirty ?? 0, age: old?.age ?? '' }
328 const outpost = { ...entry, realPath, isHere: realPath === here, ...known }
329 if (!survey || entry.isPrunable || index >= SURVEYED_OUTPOSTS) return outpost
330 const [status, age] = await Promise.all([
331 $.process.run(outpostArgv.status(entry.path)).catch(() => undefined),
332 $.process.run(outpostArgv.age(entry.path)).catch(() => undefined),
333 ])
334 const { ahead, behind, dirty } = status?.exitCode === 0 ? parseStatus(status.stdout) : known
335 return { ...outpost, ahead, behind, dirty, age: age?.exitCode === 0 ? shortAge(age.stdout.trim()) : known.age }
336 }),
337 )
338 await update($, outposts, () => list)
339}
340
341const surveyOutposts = async ($: EngineInterface) => listOutposts($, await toplevel($), true)
342
343// Opens an outpost's bag: the session's own folder shows the usual bag, another worktree its own.
344// Either way back leads to the outposts list it was opened from.
345async function openOutpost($: EngineInterface, path: string) {
346 const outpost = (await read($, outposts)).find(one => one.path === path)
347 if (outpost === undefined || outpost.isPrunable) return
348 await update($, offsets, all => ({ ...all, 'map:bag': 0 }))
349 if (outpost.isHere) return void (await update($, mapView, (): MapView => ({ view: 'bag', via: 'outposts' })))
350 await update($, outpostBag, () => ({ root: path, items: [] }))
351 await update($, mapView, (): MapView => ({ view: 'bag', root: path, via: 'outposts' }))
352 const status = await $.process.run(outpostArgv.status(path)).catch(() => undefined)
353 const items = status?.exitCode === 0 ? await loadBag($, status.stdout, path, path) : []
354 // A later outpost opened while this one loaded keeps its own bag.
355 if ((await read($, mapView)).root !== path) return
356 await update($, outpostBag, () => ({ root: path, items }))
357}
358
359// Opens one bag item's diff in the map tab's window, keeping where its bag came from (`root`, `via`).
360async function inspectChange($: EngineInterface, path: string, from: Pick<MapView, 'root' | 'via'>) {
361 const { root } = from
362 const items = root === undefined ? await read($, bag) : ((await read($, outpostBag))?.items ?? [])
363 const item = items.find(one => one.path === path) ?? { path, status: 'M' as const }
364 await update($, inspect, () => null)
365 const keep = { ...(from.root === undefined ? {} : { root: from.root }), ...(from.via === undefined ? {} : { via: from.via }) }
366 await update($, mapView, (): MapView => ({ view: 'diff', path, ...keep }))
367 await update($, offsets, all => ({ ...all, 'map:diff': 0 }))
368 const cwd = root ?? (await toplevel($))
369 const ran = await $.process.run(diffArgv(item), cwd === undefined ? {} : { cwd }).catch(() => undefined)
370 // `git diff --no-index` exits 1 when the files differ, which is the point.
371 const lines = ran === undefined || ran.exitCode > 1 ? [] : ran.stdout.split('\n')
372 // Another diff opened while this one ran keeps the window.
373 const shown = await read($, mapView)
374 if (shown.view !== 'diff' || shown.path !== path || shown.root !== root) return
375 await update($, inspect, () => ({ path, lines: lines.slice(0, DIFF_LINES), isCut: lines.length > DIFF_LINES }))
376}
377
378// Following: a sent message turns the window to the spell book, where its tool calls show, and then to the tab
379// of what the session does: a skill cast to the skills, a summoned pet to the party, a git command to the map.
380// A tab the person picks, or anything they do in the pane, holds the window until their next message;
381// `/hud follow` turns it off, kept across sessions.
382const FOLLOW_KEY = 'follow'
383const follow = { isOn: true, isHeld: false }
384
385async function followTo($: EngineInterface, to: Tab) {
386 if (!follow.isOn || follow.isHeld || (await read($, isHelpOpen))) return
387 // A tab picked while the help check was pending still holds the window.
388 await update($, tab, current => (follow.isHeld ? current : to))
389}
390
391// Adds a pet to the party and counts the summon; a pet already there under the same agent (one met through its
392// tool calls before its spawn answered) is replaced, keeping what it has done, and not counted twice.
393async function joinParty($: EngineInterface, pet: Pet) {
394 const before = (await read($, pets)).find(one => one.agentId !== undefined && one.agentId === pet.agentId)
395 const joined = before === undefined ? pet : { ...pet, actions: before.actions, lastTool: before.lastTool, lastSummary: before.lastSummary, startedAt: before.startedAt }
396 await update($, pets, list =>
397 [...list.filter(one => one.id !== pet.id && (pet.agentId === undefined || one.agentId !== pet.agentId)), joined].slice(-50),
398 )
399 if (before !== undefined) return
400 await update($, tally, t => ({ ...t, petsSummoned: t.petsSummoned + 1 }))
401 $.ui.toast(`🐾 ${speciesFor(petSeed(pet)).name} summoned: ${pet.description}`)
402 await followTo($, 'pets')
403}
404
405// A subagent met through its tool calls with no spawn of its own seen: a skill's fork or a teammate, which the
406// session's agent list names. A loop it does not name is the engine's own fork (compaction, memory) or a subagent
407// whose spawn has not answered yet: no pet of the party, so false.
408async function discoverPet($: EngineInterface, agentId: string): Promise<boolean> {
409 const info = (await $.agent.list().catch(() => [])).find(agent => agent.id === agentId)
410 if (info === undefined) return false
411 await joinParty($, {
412 id: `agent:${agentId}`,
413 agentId,
414 kind: info.type ?? 'fork',
415 description: info.description || info.name || 'a task in the background',
416 startedAt: await $.clock.now(),
417 status: 'run',
418 actions: 0,
419 })
420 return true
421}
422
423// An Agent call answered with its launch, not its answer: the agent runs on in the background (or remotely).
424const isLaunched = (ran: { result?: unknown }) => {
425 const status = (ran.result as { status?: unknown } | undefined)?.status
426 return status === 'async_launched' || status === 'remote_launched'
427}
428
429// Pets still questing that the agent list no longer names: engine forks an older build let in, never to finish.
430async function dropStrayPets($: EngineInterface) {
431 const listed = await $.agent.list().catch(() => undefined)
432 if (listed === undefined) return
433 const ids = new Set(listed.map(agent => agent.id))
434 await update($, pets, list => list.filter(pet => pet.status !== 'run' || pet.agentId === undefined || ids.has(pet.agentId)))
435}
436
437// Where back leads from a bag: the outposts list for a bag opened there, the map otherwise.
438const isFromOutposts = (view: Pick<MapView, 'root' | 'via'>) => view.root !== undefined || view.via === 'outposts'
439
440// The outpost (worktree) a pet works in: a worktree of its own wherever it sits (`<repo>/.claude/worktrees/…`
441// included), one of the outposts other than the session's own folder, or any folder outside the session's
442// repository. Its own folder or a folder inside it is no camp. Paths come with their links resolved.
443export const campFor = (pet: { cwd?: string; isIsolated: boolean }, outpostsNow: readonly Outpost[], root?: string) => {
444 if (pet.isIsolated) return pet.cwd === undefined ? 'worktree' : baseName(pet.cwd)
445 if (pet.cwd === undefined) return undefined
446 const { cwd } = pet
447 const outpost = outpostsNow.find(one => one.realPath === cwd)
448 if (outpost !== undefined) return outpost.isHere ? undefined : outpost.name
449 const isInside = root !== undefined && (cwd === root || cwd.startsWith(`${root}/`))
450 return isInside ? undefined : baseName(cwd)
451}
452
453async function campOfPet($: EngineInterface, cwd: string | undefined, isIsolated: boolean) {
454 const realCwd = cwd === undefined ? undefined : await realOf($, cwd)
455 const root = await toplevel($)
456 return campFor(
457 { ...(realCwd === undefined ? {} : { cwd: realCwd }), isIsolated },
458 await read($, outposts),
459 root === undefined ? undefined : await realOf($, root),
460 )
461}
462
463const refreshAll = ($: EngineInterface) =>
464 Promise.all([refreshVitals($), refreshLoadout($), refreshMap($)]).then(() => undefined)
465
466// Sets the session's class and announces it.
467async function chooseClass($: EngineInterface, id: string) {
468 const hero = classOf(id)
469 await update($, heroClass, () => hero.id)
470 $.ui.toast(`⚔ Today Claude plays the ${hero.title.toLowerCase()}: ${hero.ranks[0]}`)
471}
472
473async function castSkill($: EngineInterface, name: string) {
474 const now = await $.clock.now()
475 await update($, skillCasts, casts => ({ ...casts, [name]: now }))
476 void advance($, p => ({ ...p, skillUses: { ...p.skillUses, [name]: (p.skillUses[name] ?? 0) + 1 } }))
477}
478
479// The toast a background pet's return raises: what it brought back, or that it fell.
480export const returnToast = (pet: Pick<Pet, 'id' | 'agentId' | 'description' | 'loot'>, status: Pet['status']): string => {
481 const name = speciesFor(petSeed(pet)).name
482 if (status !== 'ok') return `🐾 ${name} fell: ${pet.description}`
483 return `🐾 ${name} returned: ${pet.loot || pet.description}`
484}
485
486async function finishPet($: EngineInterface, match: (pet: Pet) => boolean, status: Pet['status'], answer: string) {
487 const now = await $.clock.now()
488 const finished: Pet[] = []
489 await update($, pets, list => {
490 // An update may run its change more than once; only the last run's pets count.
491 finished.length = 0
492 return list.map(pet => {
493 if (!match(pet) || pet.status !== 'run') return pet
494 const done = { ...pet, status, endedAt: now, loot: firstLine(answer) || pet.loot }
495 finished.push(done)
496 return done
497 })
498 })
499 // A foreground pet's answer arrives as the call's result; only a background one needs announcing.
500 for (const pet of finished) if (pet.isBackground === true) $.ui.toast(returnToast(pet, status))
501}
502
503// A shell or monitor a call left running joins the jobs; one the main loop started turns the window there.
504async function startJob($: EngineInterface, job: Job) {
505 await update($, jobs, list => addJob(list, job))
506 if (job.agentId === undefined) await followTo($, 'jobs')
507}
508
509// Ends the jobs the engine reports finished, and toasts the ones that failed: the transcript's own notice is
510// easy to miss while a turn runs.
511async function endJobs($: EngineInterface, ends: readonly JobEnd[]) {
512 if (ends.length === 0) return
513 const now = await $.clock.now()
514 const failed: Job[] = []
515 await update($, jobs, list => {
516 // An update may run its change more than once; only the last run's jobs count.
517 failed.length = 0
518 const next = ends.reduce((all, end) => endJob(all, end, now), list)
519 for (const job of next) if (job.status === 'err' && list.find(one => one.id === job.id)?.status !== 'err') failed.push(job)
520 return trimJobs(next)
521 })
522 for (const job of failed) $.ui.toast(`⚙ Job failed: ${job.description || job.command}`)
523}
524
525// How long a stop pressed once waits for the second press.
526const STOP_ARMED_MS = 3000
527
528// A running job's stop asks twice: the first press arms it for a moment, the second stops the job.
529async function pressStop($: EngineInterface, job: Job) {
530 if ((await read($, stopArmed)) !== job.id) {
531 await update($, stopArmed, () => job.id)
532 void $.clock
533 .sleep(STOP_ARMED_MS)
534 .then(() => update($, stopArmed, armed => (armed === job.id ? null : armed)))
535 .catch(() => undefined)
536 return
537 }
538 await update($, stopArmed, () => null)
539 await stopJob($, job)
540}
541
542// TaskStop, the call the model would make, through the permission check.
543async function stopJob($: EngineInterface, job: Job) {
544 const ran = await $.tool.call({ tool: 'TaskStop', task_id: job.id }).catch(() => undefined)
545 if (ran === undefined || ran.isError === true || isRefusal(ran)) {
546 $.ui.toast(`⚙ Could not stop: ${job.description || job.command}`)
547 return
548 }
549 await endJobs($, [{ id: job.id, status: 'kill' }])
550}
551
552// The pane's animation: blits each tick, stops once a Raster refuses.
553const stop = () => {
554 anim.ticker?.cancel()
555 anim.ticker = undefined
556}
557
558// A refusal and a failed blit both mean nothing of ours is on screen to paint.
559const blit = async ($: EngineInterface, key: string, cells: string, columns: number, rows: number) => {
560 const done = await $.ui
561 .blit({ requestId: PANE, key, cells, columns, rows })
562 .catch(() => ({ deny: 'blit failed' }))
563 return done.deny === undefined
564}
565
566async function paintHero($: EngineInterface) {
567 const now = await $.clock.now()
568 const nextMood = pickMood(anim.running, anim.hurtUntil - now)
569 if (nextMood === anim.mood) {
570 anim.moodTick += 1
571 } else {
572 anim.mood = nextMood
573 anim.moodTick = 0
574 }
575 const cells = pickFrame(anim.mood, anim.moodTick, anim.heroClass)
576 if (cells === anim.shownCells) return true
577 if (!(await blit($, 'hero', cells, HERO_CELLS.columns, HERO_CELLS.rows))) return false
578 anim.shownCells = cells
579 return true
580}
581
582// A pet that refuses its blit has scrolled away; it leaves the animation.
583async function paintPets($: EngineInterface) {
584 const frame = Math.floor(anim.tick / PET_TICKS)
585 for (const [key, pet] of anim.pets) {
586 const cells = petCells(pet.seed, 'run', frame)
587 if (cells === pet.shown) continue
588 if (await blit($, key, cells, PET_COLUMNS, PET_ROWS)) pet.shown = cells
589 else anim.pets.delete(key)
590 }
591}
592
593async function paint($: EngineInterface) {
594 anim.tick += 1
595 const hasPets = anim.tab === 'pets' && anim.pets.size > 0
596 // The portrait refusing means the pane is closed: nothing of ours is on screen.
597 if (anim.hasHero && !(await paintHero($))) return stop()
598 if (hasPets) await paintPets($)
599 if (!anim.hasHero && !hasPets) stop()
600}
601
602// Each draw of an animated tab starts the clock again if a refusal stopped it.
603const startAnim = ($: EngineInterface) => {
604 anim.ticker ??= $.clock.every(FRAME_MS, () => void paint($))
605}
606
607// The session's camp, when it works in a worktree other than the repository's main folder.
608const campOf = (list: readonly Outpost[]): { camp?: string } => {
609 const here = list.find(one => one.isHere)
610 return here === undefined || here.isMain ? {} : { camp: here.name }
611}
612
613// --- The menu windows ---
614
615// Every window the menu can show: a tab, or a view inside the map tab.
616type PageKey = Tab | 'map:bag' | 'map:diff' | 'map:outposts' | 'help'
617
618// `$` goes beside it as an argument of its own: the engine never lets it ride in an object.
619type PageContext = {
620 ui: ElementTable
621 view: MapView
622 // The window's inner width.
623 inner: number
624 now: number
625 Raster: ElementTable<'terminal'>['Raster'] | undefined
626 callList: ToolCall[]
627 petList: Pet[]
628 progress: Progress
629 // Where the window stands: the first row it shows and how many it has room for.
630 offset: number
631 rows: number
632}
633
634type TerminalClient = ElementTable<'terminal'>['Client']
635
636// Rows the window draws as a list to pick from: a Client on the terminal, so a click anywhere on a row acts on
637// it; a column of buttons elsewhere. The Clients all take `{ rows, width }`.
638type PickList<Row> = {
639 key: string
640 rows: readonly Row[]
641 // The terminal's Client for the shown rows; its module must be spelled out where it is drawn.
642 client: (Client: TerminalClient, props: { rows: Row[]; width: number }, height: number) => RenderChildren
643 // A row's button on surfaces with no Client; undefined leaves it a blank line.
644 button: (row: Row) => { key: string; label: string; dimColor?: boolean; onPress: () => void } | undefined
645}
646
647// What a window shows: its own title when not the tab's, the subtitle, and either items it pages itself or a
648// list to pick from; `actions` sit on its title row.
649type Page = {
650 title?: string
651 subtitle: string
652 items?: Item[]
653 list?: PickList<never>
654 actions?: RenderChildren
655}
656
657const pickList = <Row,>(list: PickList<Row>): PickList<never> => list as unknown as PickList<never>
658
659// The scrolled slice of a pick list, drawn for the surface.
660function pickBody($: EngineInterface, e: { surface: string }, list: PickList<never>, offset: number, rows: number, inner: number) {
661 const start = clampOffset(offset, list.rows.length, rows)
662 const shown = list.rows.slice(start, start + rows)
663 const total = list.rows.length
664 const ui = $.ui.resolve(e as never) as ElementTable
665 if (e.surface === 'terminal') {
666 const { Client } = ui as ElementTable<'terminal'>
667 return { node: list.client(Client, { rows: shown, width: inner }, rows), total }
668 }
669 const { Box, Button } = ui
670 return {
671 node: (
672 <Box flexDirection="column">
673 {shown.map((row, index) => {
674 const button = list.button(row)
675 return button === undefined ? (
676 <Box key={`${list.key}-${index}`} height={1} />
677 ) : (
678 <Button key={button.key} label={button.label} plain {...(button.dimColor === undefined ? {} : { dimColor: button.dimColor })} onPress={button.onPress} />
679 )
680 })}
681 </Box>
682 ),
683 total,
684 }
685}
686
687const backToMap = ($: EngineInterface) => void update($, mapView, (): MapView => ({ view: 'map' }))
688
689const spellsPage = async ($: EngineInterface, { ui, inner, callList }: PageContext): Promise<Page> => {
690 const filter = await read($, spellFilter)
691 const casts = await read($, castCount)
692 return {
693 items: spellItems(ui, callList, inner, filter),
694 subtitle: filter === 'all' ? `${casts} casts` : `${filterSpells(callList, filter).length} of ${casts} casts`,
695 ...(callList.length === 0
696 ? {}
697 : {
698 actions: spellFilters(ui, callList, filter, picked => {
699 void update($, spellFilter, () => picked)
700 void update($, offsets, all => ({ ...all, spells: 0 }))
701 }),
702 }),
703 }
704}
705
706const petsPage = async (_$: EngineInterface, { ui, inner, now, Raster, petList }: PageContext): Promise<Page> => ({
707 items: petItems(ui, petList, now, inner, Raster),
708 subtitle: partySubtitle(petList),
709})
710
711const mapPage = async ($: EngineInterface, { ui, inner }: PageContext): Promise<Page> => {
712 const world = await read($, map)
713 const list = await read($, outposts)
714 return {
715 items: mapItems(ui, world, await read($, touched), inner, {
716 openBag: () => {
717 void update($, mapView, (): MapView => ({ view: 'bag' }))
718 void update($, offsets, all => ({ ...all, 'map:bag': 0 }))
719 },
720 openOutposts: () => {
721 void update($, mapView, (): MapView => ({ view: 'outposts' }))
722 void surveyOutposts($).catch(() => undefined)
723 },
724 outposts: list.length,
725 camps: Object.fromEntries(list.filter(one => !one.isHere && one.branch !== '').map(one => [one.branch, one.name])),
726 }),
727 subtitle: mapSubtitle(world),
728 }
729}
730
731const diffPage = async ($: EngineInterface, { ui, view, inner }: PageContext): Promise<Page> => {
732 const opened = await read($, inspect)
733 const source = view.root === undefined ? await read($, bag) : ((await read($, outpostBag))?.items ?? [])
734 return {
735 title: '🔍 INSPECT',
736 items: inspectItems(ui, opened, source.find(item => item.path === view.path), inner, () => {
737 void update($, mapView, (): MapView => ({ ...view, view: 'bag', path: undefined }))
738 }),
739 subtitle: view.path ?? '',
740 }
741}
742
743const outpostsPage = async ($: EngineInterface): Promise<Page> => {
744 const list = await read($, outposts)
745 const rows: OutpostRow[] = [{ kind: 'back' }, ...list.map(outpost => ({ kind: 'outpost' as const, outpost }))]
746 if (list.length === 0) rows.push({ kind: 'note', text: 'No outposts: this repository has one worktree.' })
747 return {
748 title: '🏕 OUTPOSTS',
749 subtitle: `${list.length} camps · ${list.filter(one => one.dirty > 0).length} with unsaved work`,
750 list: pickList<OutpostRow>({
751 key: 'outposts',
752 rows,
753 client: (Client, props, height) => <Client key="outposts" module="./outposts-client.tsx" props={props satisfies OutpostsProps} width={props.width} height={height} />,
754 button: row => {
755 if (row.kind === 'back') return { key: 'outposts-back', label: '◂ back to the map', onPress: () => backToMap($) }
756 if (row.kind !== 'outpost' || row.outpost.isPrunable) return undefined
757 const { path, name } = row.outpost
758 return { key: `outpost-${path}`, label: `⚑ ${name}`, onPress: () => void openOutpost($, path) }
759 },
760 }),
761 }
762}
763
764const bagPage = async ($: EngineInterface, { view }: PageContext): Promise<Page> => {
765 const opened = view.root === undefined ? undefined : await read($, outpostBag)
766 const items = view.root === undefined ? await read($, bag) : opened?.root === view.root ? opened.items : []
767 const rows: BagRow[] = [
768 { kind: 'back', ...(isFromOutposts(view) ? { label: 'the outposts' } : {}) },
769 ...(items.length === 0 ? [{ kind: 'note' as const, text: 'The bag is empty: every change is committed.' }] : []),
770 ...items.map(item => ({ kind: 'item' as const, item })),
771 ]
772 return {
773 title: '🎒 INVENTORY',
774 subtitle: view.root === undefined ? bagSubtitle(items) : `⚑${baseName(view.root)} · ${bagSubtitle(items)}`,
775 list: pickList<BagRow>({
776 key: 'bag',
777 rows,
778 client: (Client, props, height) => <Client key="bag" module="./bag-client.tsx" props={props satisfies BagProps} width={props.width} height={height} />,
779 button: row => {
780 if (row.kind === 'back') {
781 return { key: 'bag-back', label: '◂ back', onPress: () => void update($, mapView, (): MapView => ({ view: isFromOutposts(view) ? 'outposts' : 'map' })) }
782 }
783 if (row.kind !== 'item') return undefined
784 const { path } = row.item
785 return { key: `bag-${path}`, label: path, onPress: () => void inspectChange($, path, view) }
786 },
787 }),
788 }
789}
790
791// Fills the prompt with `text`, or toasts `fallback` when the prompt cannot take it.
792const fillPrompt = ($: EngineInterface, text: string, fallback: string) =>
793 void $.prompt
794 .fill({ text })
795 .then(filled => (filled.isFilled ? undefined : $.ui.toast(fallback)))
796 .catch(() => undefined)
797
798// Fills the prompt with a skill's command, or says how to cast it when the prompt cannot take it.
799const castFromPage = ($: EngineInterface, name: string) => fillPrompt($, castText(name), `Type /${name} to cast it`)
800
801// The skills page's letters as last drawn, letter to skill. A click on a menu icon gives that Client the keys,
802// and the skills' buttons then hear no letters; the icon hands them on and they are looked up here.
803let shownSkillKeys = new Map<string, string>()
804
805const skillsPage = async ($: EngineInterface, { ui, inner, now, progress: progressNow, offset, rows: room }: PageContext): Promise<Page> => {
806 const gear = await read($, loadout)
807 const rows = skillRows({ loadout: gear, progress: progressNow, casts: await read($, skillCasts), now })
808 const keys = skillKeys(rows, clampOffset(offset, rows.length, room), room)
809 shownSkillKeys = new Map(
810 [...keys].flatMap(([index, key]) => {
811 const row = rows[index]
812 return row?.kind === 'skill' ? [[key, row.name] as const] : []
813 }),
814 )
815 return {
816 subtitle: skillsSubtitle(gear, progressNow),
817 items: skillItems(ui, rows, inner, keys, name => castFromPage($, name)),
818 }
819}
820
821const jobsPage = async ($: EngineInterface, { ui, inner, now }: PageContext): Promise<Page> => {
822 const list = await read($, jobs)
823 const armed = await read($, stopArmed)
824 return { items: jobItems(ui, list, now, inner, job => void pressStop($, job), armed), subtitle: jobsSubtitle(list) }
825}
826
827const helpPage = async ($: EngineInterface, { ui }: PageContext): Promise<Page> => ({
828 title: '❓ HELP',
829 subtitle: `${HELP_COMMANDS.length} commands`,
830 items: helpItems(ui, () => void update($, isHelpOpen, () => false)),
831})
832
833// The window a menu entry, a view inside the map tab, or the help page shows.
834function pageFor($: EngineInterface, key: PageKey, ctx: PageContext): Promise<Page> {
835 switch (key) {
836 case 'spells':
837 return spellsPage($, ctx)
838 case 'pets':
839 return petsPage($, ctx)
840 case 'map':
841 return mapPage($, ctx)
842 case 'map:diff':
843 return diffPage($, ctx)
844 case 'map:outposts':
845 return outpostsPage($)
846 case 'map:bag':
847 return bagPage($, ctx)
848 case 'skills':
849 return skillsPage($, ctx)
850 case 'jobs':
851 return jobsPage($, ctx)
852 case 'help':
853 return helpPage($, ctx)
854 }
855}
856
857// The session's adventure log for /hud recap.
858// Every figure counts from the HUD's watch start, so the duration and the tally agree.
859async function recap($: EngineInterface) {
860 await refreshVitals($).catch(() => undefined)
861 const now = await $.clock.now()
862 const casts = await read($, castCount)
863 const callList = (await read($, calls)).map(normalizeCall)
864 const since = await read($, startedAt)
865 const vitalsNow = await read($, vitals)
866 return recapText({
867 hero: classOf(await read($, heroClass)),
868 level: heroStats(callList, casts).level,
869 casts,
870 calls: callList,
871 ...(since === 0 ? {} : { elapsedMs: now - since }),
872 tally: await read($, tally),
873 bestCombo: (await read($, combo)).best,
874 boss: await read($, boss),
875 touched: await read($, touched),
876 ...(vitalsNow.usd === undefined ? {} : { usd: vitalsNow.usd }),
877 ...(vitalsNow.contextPercent === undefined ? {} : { contextPercent: vitalsNow.contextPercent }),
878 })
879}
880
881// Moves a window `by` rows within its items: `view` is the window as last drawn, its key, rows in all and room.
882// A window that shows all it has is left alone, so a scroll key there writes nothing.
883const scrollWindow = async ($: EngineInterface, view: { key: string; total: number; rows: number }, by: number) => {
884 if (view.total <= view.rows) return
885 await update($, offsets, all => ({ ...all, [view.key]: clampOffset((all[view.key] ?? 0) + by, view.total, view.rows) }))
886}
887
888export const register: Register = on => {
889 // The last drawn log window; scrolling clamps against it.
890 // `key` names the window being scrolled: a tab, or `map:bag` / `map:diff` inside the map tab.
891 let layout = { rows: 1, total: 0, key: 'spells' }
892 // Agent calls asked to run in a worktree of their own, by tool_use_id, until their spawn.
893 const isolated = new Set<string>()
894 // The skill a typed slash command just cast, until the prompt it may also submit: counted once, not twice.
895 let commandCast: string | undefined
896 // Agent calls sent to run in the background, by tool_use_id, until their spawn.
897 const background = new Set<string>()
898 // Subagents being looked up in the agent list, so parallel tool calls of one add it once.
899 const discovering = new Set<string>()
900 // What the loops the agent list does not name have done: a spawn that answers later takes it on, an engine fork
901 // never shows.
902 const strays = new Map<string, Pick<Pet, 'actions' | 'startedAt' | 'lastTool' | 'lastSummary'>>()
903 // Calls auto mode denied, by tool_use_id, until their tool.call answers.
904 const autoDenied = new Set<string>()
905 // The loop whose tool `waitingFor` names: a subagent's id, undefined for the main loop.
906 let waitingAgent: string | undefined
907
908 on('session.start', async ($, e, next) => {
909 await $.command.register({
910 name: COMMAND,
911 description:
912 'Open the adventure HUD (args: spell, party, map, skill, jobs or 1-5; class [name] rerolls; cards toggles edit cards; follow toggles turning to the tab of what happens; recap sums up the session; help lists them all)',
913 })
914 await markStart($)
915 await loadProgress($)
916 // A tab kept from an older build (`feats`) no longer names a page: the spell book stands in.
917 await update($, tab, kept => (isTab(kept) ? kept : 'spells'))
918 await dropStrayPets($)
919 follow.isOn = (await $.store.get(FOLLOW_KEY).catch(() => undefined)) !== false
920 // Each session plays a class at random; a reload of the mod keeps the one rolled.
921 if ((await read($, heroClass)) === '') await chooseClass($, rollClass(Math.random()))
922 void refreshAll($).catch(() => undefined)
923 void $.ui.open({ id: PANE, title: TITLE })
924
925 return next(e)
926 })
927
928 // A /clear or an in-session /resume moves to another session without session.start.
929 on('classic.SessionStart', async ($, e, next) => {
930 if (e.source === 'clear' || e.source === 'resume') {
931 await resetSession($)
932 void refreshAll($).catch(() => undefined)
933 }
934 return next(e)
935 })
936
937 // A slash command the person typed is a message of theirs, even one that never reaches the conversation (a
938 // skill forked to the background): the window follows again, and a skill counts as cast.
939 on('command.run', async ($, e, next) => {
940 // A forked skill never submits its prompt, so its mark ends with the next command rather than outliving it.
941 commandCast = undefined
942 const isPerson = e.origin?.kind === 'composer' || e.origin?.kind === 'bridge'
943 if (!isPerson || e.command === COMMAND) return next(e)
944 follow.isHeld = false
945 const isSkill = (await read($, loadout)).skills.some(slot => slot.name === e.command)
946 if (isSkill) {
947 commandCast = e.command
948 await castSkill($, e.command)
949 await followTo($, 'skills')
950 }
951 return next(e)
952 })
953
954 on('command.run', { command: COMMAND }, async ($, e) => {
955 // A tab is named as its card shows it (`/hud party`, any case) or by its number there (`/hud 2`); its id
956 // (`pets`) still works.
957 const typed = e.args.trim()
958 const arg = MENU.find((entry, index) => slotKey(index) === typed || entry.label.toLowerCase() === typed.toLowerCase())?.id ?? typed
959 if (arg === 'class' || arg.startsWith('class ')) {
960 const asked = arg.slice('class'.length).trim()
961 if (asked !== '' && !isClassId(asked)) return { text: `No such class. Pick one of: ${CLASS_IDS.join(', ')}.` }
962 const current = await read($, heroClass)
963 const next = asked === '' ? rollClass(Math.random(), current) : asked
964 await chooseClass($, next)
965 return { text: `Claude is now a ${classOf(next).title.toLowerCase()}.` }
966 }
967 if (arg === 'recap') return { text: await recap($) }
968 if (arg === 'cards') {
969 const isOn = (await $.store.get(CARDS_KEY)) !== false
970 await $.store.set(CARDS_KEY, !isOn)
971 return { text: isOn ? 'Edit cards off: edits draw as before.' : 'Edit cards on.' }
972 }
973 if (arg === 'help') return { text: helpText() }
974 if (arg === 'follow') {
975 follow.isOn = !follow.isOn
976 await $.store.set(FOLLOW_KEY, follow.isOn)
977 return { text: follow.isOn ? 'The HUD follows the action to its tab.' : 'The HUD stays on the tab you pick.' }
978 }
979 if (isTab(arg)) {
980 follow.isHeld = true
981 await update($, tab, () => arg)
982 await update($, isHelpOpen, () => false)
983 }
984 await $.ui.open({ id: PANE, title: TITLE })
985
986 return { text: 'Adventure HUD opened.' }
987 })
988
989 // Auto mode's classifier denied a call: a refusal, known by the call's id rather than by its wording.
990 on('classic.PermissionDenied', async ($, e, next) => {
991 autoDenied.add(e.tool_use_id)
992 return next(e)
993 })
994
995 // A permission prompt is about to ask the person; the panel says so until that tool is done. A hook beneath
996 // that decides leaves no prompt to wait on.
997 on('classic.PermissionRequest', async ($, e, next) => {
998 const result = await next(e)
999 if (result.decision === undefined) {
1000 waitingAgent = e.agent_id
1001 const tool = e.tool_name
1002 await update($, waitingFor, () => tool)
1003 }
1004 return result
1005 })
1006
1007 on('prompt.submit', async ($, e, next) => {
1008 await update($, waitingFor, () => null)
1009 // A background task's notification says a job ended, and how. It is no message of the person's: a tab they
1010 // picked stays held, and the window stays where it is.
1011 if (e.origin?.kind === 'task-notification') {
1012 await endJobs($, parseJobEnds(e.text))
1013 return next(e)
1014 }
1015 // The HUD's own command is no message: `/hud map` and the like set the window themselves.
1016 if (skillOfPrompt(e.text, [COMMAND]) === COMMAND) return next(e)
1017 // A new message lets the window follow again, whatever tab was picked during the last.
1018 follow.isHeld = false
1019 const known = (await read($, loadout)).skills.map(slot => slot.name)
1020 const skill = skillOfPrompt(e.text, known)
1021 // A skill its slash command already counted is not counted again.
1022 if (skill !== undefined && skill !== commandCast) await castSkill($, skill)
1023 commandCast = undefined
1024 await followTo($, skill === undefined ? 'spells' : 'skills')
1025 return next(e)
1026 })
1027
1028 // The main loop stopping lists the background work still in flight: the jobs are checked against it.
1029 on('classic.Stop', async ($, e, next) => {
1030 const inFlight = e.background_tasks
1031 if (inFlight !== undefined) {
1032 const now = await $.clock.now()
1033 await update($, jobs, list => reconcileJobs(list, inFlight, now))
1034 }
1035 return next(e)
1036 })
1037
1038 on('turn.complete', async ($, e, next) => {
1039 if (e.agentId !== undefined) {
1040 const agentId = e.agentId
1041 await finishPet($, pet => pet.agentId === agentId, e.isAborted ? 'err' : 'ok', 'answer' in e ? e.answer : '')
1042 return next(e)
1043 }
1044 // An interrupted prompt leaves nothing waiting on the person.
1045 await update($, waitingFor, () => null)
1046 // The outposts list, while open, is surveyed again after each turn.
1047 const isSurveying = (await read($, mapView)).view === 'outposts'
1048 void refreshAll($)
1049 .then(() => (isSurveying ? surveyOutposts($) : undefined))
1050 .catch(() => undefined)
1051 return next(e)
1052 })
1053
1054 on('agent.spawn', async ($, e, next) => {
1055 const ran = await next(e)
1056 const isIsolated = isolated.delete(e.tool_use_id)
1057 const isBackground = background.delete(e.tool_use_id)
1058 if (ran.agentId === undefined) return ran
1059 const now = await $.clock.now()
1060 // A pet sent to its own worktree, or to a folder outside the repository, camps there.
1061 const camp = await campOfPet($, e.cwd, isIsolated)
1062 const stray = strays.get(ran.agentId)
1063 strays.delete(ran.agentId)
1064 const pet: Pet = {
1065 id: e.tool_use_id,
1066 agentId: ran.agentId,
1067 kind: e.subagentType,
1068 description: e.description,
1069 startedAt: now,
1070 status: 'run',
1071 actions: 0,
1072 ...stray,
1073 ...(camp === undefined ? {} : { camp }),
1074 ...(isBackground ? { isBackground } : {}),
1075 }
1076 await joinParty($, pet)
1077 return ran
1078 })
1079
1080 on('tool.call', async ($, e, next) => {
1081 // The HUD's own calls (a job's stop button) are none of the hero's: no cast, no combo, no level.
1082 if (next.origin.plugin === PLUGIN) return next(e)
1083 const isMain = e.agentId === undefined
1084 // A mod loaded mid-session starts its watch at the first call it sees.
1085 await markStart($)
1086 const startedAt = await $.clock.now()
1087 const summary = summarize(e)
1088 const call: ToolCall = { id: e.tool_use_id, tool: e.tool, summary, startedAt, status: 'run' }
1089
1090 // A foreground Agent call returns with its pet's answer; a background one returns at once.
1091 const isBackground = (e as { run_in_background?: unknown }).run_in_background === true
1092 if (e.tool === 'Agent' && (e as { isolation?: unknown }).isolation === 'worktree') isolated.add(e.tool_use_id)
1093 if (e.tool === 'Agent' && isBackground) background.add(e.tool_use_id)
1094 // A question to the person waits on them while it is open.
1095 if (e.tool === 'AskUserQuestion') {
1096 waitingAgent = e.agentId
1097 await update($, waitingFor, () => e.tool)
1098 }
1099 if (isMain) {
1100 await update($, calls, list => [...list, call].slice(-200))
1101 await update($, castCount, count => count + 1)
1102 anim.running += 1
1103 } else if (e.agentId !== undefined) {
1104 const agentId = e.agentId
1105 const stray = strays.get(agentId)
1106 if (stray !== undefined) {
1107 strays.set(agentId, { ...stray, actions: stray.actions + 1, lastTool: e.tool, lastSummary: summary })
1108 } else if (!(await read($, pets)).some(pet => pet.agentId === agentId) && !discovering.has(agentId)) {
1109 discovering.add(agentId)
1110 const isPet = await discoverPet($, agentId).finally(() => discovering.delete(agentId))
1111 if (!isPet) strays.set(agentId, { actions: 1, startedAt, lastTool: e.tool, lastSummary: summary })
1112 }
1113 await update($, pets, list =>
1114 list.map(pet =>
1115 pet.agentId === agentId ? { ...pet, actions: pet.actions + 1, lastTool: e.tool, lastSummary: summary } : pet,
1116 ),
1117 )
1118 }
1119
1120 let ran: Awaited<ReturnType<typeof next>>
1121 let isAutoDenied = false
1122 try {
1123 ran = await next(e)
1124 } finally {
1125 if (isMain) anim.running -= 1
1126 // Auto mode denies while the call is under way; answered or not, its mark goes with it.
1127 isAutoDenied = autoDenied.delete(e.tool_use_id)
1128 // An Agent call that never spawned (refused, interrupted) leaves no mark behind.
1129 if (e.tool === 'Agent') {
1130 isolated.delete(e.tool_use_id)
1131 background.delete(e.tool_use_id)
1132 }
1133 // Run or refused, the tool a prompt asked about no longer waits on the person; the same tool in another
1134 // loop is not the one asked about.
1135 if (waitingAgent === e.agentId && (await read($, waitingFor)) === e.tool) await update($, waitingFor, () => null)
1136 }
1137 // A refused call never ran: the hero takes no hurt, the combo and the turn's streak stand.
1138 const isRefused = isAutoDenied || isRefusal(ran)
1139 const isError = !isRefused && ran.isError === true
1140 const status: ToolCallStatus = isRefused ? 'deny' : isError ? 'err' : 'ok'
1141
1142 // The world reacts to every loop's calls: files explored, bosses fought.
1143 // NotebookEdit names its file `notebook_path`.
1144 const fields = e as { file_path?: unknown; notebook_path?: unknown }
1145 const filePath = fields.file_path ?? fields.notebook_path
1146 if (typeof filePath === 'string' && !isRefused && (e.tool === 'Read' || EDIT_TOOLS.has(e.tool))) {
1147 const cwd = await $.session.cwd()
1148 const path = filePath.startsWith(`${cwd}/`) ? filePath.slice(cwd.length + 1) : filePath
1149 await update($, touched, list => touchFile(list, path, EDIT_TOOLS.has(e.tool)))
1150 if (EDIT_TOOLS.has(e.tool)) void refreshMap($, 'files').catch(() => undefined)
1151 }
1152 if (e.tool === 'Bash' && !isRefused) {
1153 const command = e.command
1154 const fight = fightBoss(await read($, boss), command, isError, ran.text ?? '')
1155 await update($, boss, () => fight.boss)
1156 if (fight.event === 'appear' && fight.boss !== null) {
1157 $.ui.toast(`☠ ${fight.boss.name} appears! HP ${fight.boss.hp}`)
1158 }
1159 if (fight.event === 'defeat') {
1160 $.ui.toast('⚔ Boss defeated!')
1161 await update($, tally, t => ({ ...t, bossesDefeated: t.bossesDefeated + 1 }))
1162 }
1163 if (runsGit(command)) {
1164 void refreshMap($).catch(() => undefined)
1165 if (isMain) await followTo($, 'map')
1166 }
1167 }
1168 // A shell or monitor left running in the background (asked to, ctrl+b, a timeout) becomes a job.
1169 if ((e.tool === 'Bash' || e.tool === 'Monitor') && !isRefused && !isError) {
1170 const result = ran.result as { backgroundTaskId?: unknown; taskId?: unknown } | undefined
1171 const id = e.tool === 'Bash' ? result?.backgroundTaskId : result?.taskId
1172 if (typeof id === 'string') {
1173 await startJob($, {
1174 id,
1175 toolUseId: e.tool_use_id,
1176 kind: e.tool === 'Bash' ? 'shell' : 'monitor',
1177 command: e.tool === 'Bash' ? e.command : (e.command ?? e.ws?.url ?? ''),
1178 description: e.description ?? '',
1179 startedAt,
1180 status: 'run',
1181 ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
1182 })
1183 }
1184 }
1185 if (e.tool === 'TaskStop' && !isRefused && !isError) {
1186 const id = (ran.result as { task_id?: unknown } | undefined)?.task_id
1187 if (typeof id === 'string') await endJobs($, [{ id, status: 'kill' }])
1188 }
1189
1190 if (!isMain) return ran
1191
1192 const endedAt = await $.clock.now()
1193 const ms = Math.round(endedAt - startedAt)
1194 if (isError) anim.hurtUntil = endedAt + HURT_MS
1195 if (isError || isRefused) {
1196 await update($, tally, t => (isError ? { ...t, failures: t.failures + 1 } : { ...t, refusals: t.refusals + 1 }))
1197 }
1198 await update($, calls, list => list.map(one => (one.id === call.id ? { ...normalizeCall(one), ms, status } : one)))
1199 if (!isRefused) await update($, combo, current => {
1200 const streak = isError ? 0 : current.current + 1hooks/anim.ts 51 lines1import type { Timer } from 'claude-code'
2
3import type { Tab } from '../types'
4import { HERO_CLASS_FRAMES, HERO_SIZE } from './hero-cells'
5import type { HeroClassId } from './hero-cells'
6
7export const HERO_CELLS = { columns: HERO_SIZE.columns, rows: HERO_SIZE.rows }
8
9export type Mood = 'idle' | 'cast' | 'hurt'
10
11export const FRAME_MS = 100
12// Ticks each frame of a mood stays on screen.
13const FRAME_TICKS: Record<Mood, number> = { idle: 5, cast: 2, hurt: 3 }
14// Ticks each frame of a running pet stays on screen.
15export const PET_TICKS = 4
16// The smoke puff plays once through after a failed call.
17// Every class's puff has the wizard's length.
18export const HURT_MS = FRAME_MS * FRAME_TICKS.hurt * HERO_CLASS_FRAMES.wizard.hurt.length
19
20export const pickMood = (running: number, hurtLeftMs: number): Mood => {
21 if (hurtLeftMs > 0) return 'hurt'
22 return running > 0 ? 'cast' : 'idle'
23}
24
25// `tick` counts from the moment the mood began.
26export const pickFrame = (mood: Mood, tick: number, hero: HeroClassId = 'wizard'): string => {
27 const frames: readonly string[] = HERO_CLASS_FRAMES[hero][mood]
28 const frame = frames[Math.floor(tick / FRAME_TICKS[mood]) % frames.length]
29 if (frame === undefined) throw new Error(`no ${mood} frames in hero-cells.ts`)
30 return frame
31}
32
33// The pane's animation: runs while its Rasters take blits, stops once one refuses.
34// Module state, so a reload starts it over.
35export const anim = {
36 running: 0,
37 hurtUntil: 0,
38 ticker: undefined as Timer | undefined,
39 tab: 'spells' as Tab,
40 // The class the session rolled; the portrait animates its frames.
41 heroClass: 'wizard' as HeroClassId,
42 // Whether the status panel drew the portrait this time; a narrow pane leaves it out.
43 hasHero: false,
44 mood: 'idle' as Mood,
45 moodTick: 0,
46 tick: 0,
47 shownCells: pickFrame('idle', 0),
48 // Running pets on screen, by Raster key, with the seed that picks their sprite.
49 pets: new Map<string, { seed: string; shown: string }>(),
50}
51hooks/boss.ts 191 lines1import type { Boss } from '../types'
2
3const BOSS_NAMES: Record<string, string> = {
4 e2e: 'Phantom Browser',
5 types: 'Type Wraith',
6 lint: 'Lint Goblin',
7 test: 'Bug Hydra',
8}
9
10// Programs that are a check by themselves, to the kind of boss they guard.
11const RUNNERS: Record<string, string> = {
12 playwright: 'e2e', cypress: 'e2e',
13 tsc: 'types', 'vue-tsc': 'types', mypy: 'types', pyright: 'types',
14 eslint: 'lint', stylelint: 'lint', shellcheck: 'lint', ruff: 'lint', biome: 'lint', 'golangci-lint': 'lint',
15 vitest: 'test', jest: 'test', pytest: 'test', mocha: 'test', ava: 'test', rspec: 'test', phpunit: 'test', karma: 'test',
16}
17
18// Tools whose `test` / `lint` subcommand is the check: `go test`, `ng lint`.
19const SUBCOMMANDS: Record<string, Record<string, string>> = {
20 go: { test: 'test', vet: 'lint' },
21 cargo: { test: 'test', clippy: 'lint', check: 'types' },
22 dotnet: { test: 'test' },
23 ng: { test: 'test', lint: 'lint', e2e: 'e2e' },
24 deno: { test: 'test', lint: 'lint', check: 'types' },
25}
26
27const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
28// Words before the program itself: launchers and wrappers, their own flags skipped after them.
29const LAUNCHERS = new Set(['npx', 'bunx', 'pnpx', 'time', 'env', 'sudo', 'nice'])
30// Environment managers that start the program after `run`: `uv run pytest`, `poetry run pytest`.
31const RUN_WRAPPERS = new Set(['uv', 'poetry', 'pipenv', 'hatch', 'pdm', 'rye'])
32// Shell words a simple command may start with before its program: `if git diff --quiet`, `do git add "$f"`.
33const SHELL_WORDS = new Set(['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{', '(', 'exec', 'command'])
34// Flags that take the next word as their value: `pnpm --filter web test`, `npm --prefix app test`.
35const VALUED_FLAGS = new Set(['--filter', '-F', '-C', '--cwd', '--dir', '--prefix', '-w', '--workspace', '-p', '--package'])
36
37// Drops the flags at the front of `words`, with the value of each flag that takes one.
38const dropFlags = (words: string[]): string[] => {
39 let at = 0
40 while (at < words.length && (words[at] ?? '').startsWith('-')) {
41 const flag = words[at] ?? ''
42 at += VALUED_FLAGS.has(flag) && !flag.includes('=') ? 2 : 1
43 }
44 return words.slice(at)
45}
46
47// A package script's or make target's kind by its name: `test`, `test:unit`, `lint:fix`, `typecheck`, `e2e`.
48const scriptKind = (script: string): string | undefined => {
49 if (/^(e2e|test:e2e)\b/.test(script)) return 'e2e'
50 if (/^test\b/.test(script)) return 'test'
51 if (/^lint\b/.test(script)) return 'lint'
52 if (/^(typecheck|type-check|check-types|tsc)\b/.test(script)) return 'types'
53 return undefined
54}
55
56// The program one simple command starts and its arguments, past env assignments, launchers and wrappers.
57export const programOf = (segment: string): { program: string; rest: string[] } => {
58 // A subshell or group opener glued to the program, `(git status`, is dropped.
59 let words = segment.trim().replace(/^[({]+/, '').split(/\s+/).filter(word => word.length > 0 && !/^\w+=/.test(word))
60 for (;;) {
61 const [head = '', next = ''] = words
62 if (SHELL_WORDS.has(head)) words = words.slice(1)
63 else if (LAUNCHERS.has(head)) words = dropFlags(words.slice(1))
64 else if (RUN_WRAPPERS.has(head) && next === 'run') words = dropFlags(words.slice(2))
65 else if (PACKAGE_MANAGERS.has(head) && (next === 'exec' || next === 'dlx')) words = dropFlags(words.slice(2))
66 else break
67 }
68 return { program: (words[0] ?? '').replace(/^.*\//, ''), rest: words.slice(1) }
69}
70
71// A shell command's simple commands: its `&&`, `||`, `;`, `|` and newline-separated parts.
72const segmentsOf = (command: string): string[] => command.split(/&&|\|\||;|\||\n/)
73
74// Whether any part of a shell command starts `git` itself: `grep git` or `cat .gitignore` do not.
75export const runsGit = (command: string): boolean => segmentsOf(command).some(segment => programOf(segment).program === 'git')
76
77// The check one simple command runs, read from the program it starts, never from words in its arguments:
78// `grep test` or `ls tests/` run no check.
79export const kindOf = (segment: string): string | undefined => {
80 const { program, rest } = programOf(segment)
81 if (RUNNERS[program] !== undefined) return RUNNERS[program]
82 if (/^python3?$/.test(program) && rest[0] === '-m') return RUNNERS[rest[1] ?? '']
83 if (program === 'make') return scriptKind(dropFlags(rest)[0] ?? '')
84 if (SUBCOMMANDS[program] !== undefined) return SUBCOMMANDS[program][rest[0] ?? '']
85 if (PACKAGE_MANAGERS.has(program)) {
86 const args = dropFlags(rest)
87 const script = (args[0] === 'run' ? dropFlags(args.slice(1))[0] : args[0]) ?? ''
88 // `pnpm vitest run` starts the binary itself; `pnpm test` a script.
89 return RUNNERS[script] ?? scriptKind(script)
90 }
91 return undefined
92}
93
94type Guard = { kind: string; name: string }
95
96// Every check a shell command runs, in order, each kind once: `npm test && npm run lint` runs two.
97export const bossesFor = (command: string): Guard[] => {
98 const found: Guard[] = []
99 for (const segment of segmentsOf(command)) {
100 const kind = kindOf(segment)
101 if (kind !== undefined && !found.some(one => one.kind === kind)) found.push({ kind, name: BOSS_NAMES[kind] ?? 'Bug Hydra' })
102 }
103 return found
104}
105
106// The check a shell command runs, if any: the first one among its `&&`, `||`, `;` and `|` parts.
107export const bossFor = (command: string): Guard | undefined => bossesFor(command)[0]
108
109// What each kind of check prints when it fails, to tell which of several checks in one command did. Counts start
110// at 1, so a passing run's `0 failed` is no sign; lint's warnings alone are none either.
111const FAILURE_SIGNS: Record<string, RegExp> = {
112 types: /\berror TS\d+|\berror\[E\d+\]|^\S+:\d+: error:/m,
113 lint: /\([1-9]\d* errors?\b|^\s*\d+:\d+\s+error\s/m,
114 test: /\b[1-9]\d* (?:failed|failing)\b|^\s*FAIL\s|\bFAILED\b/m,
115 e2e: /\b[1-9]\d* failed\b|^\s*✘/m,
116}
117
118// Programs that only set the scene for a check: `cd app && npm test` fails at the test or not at all.
119// The shell's closing words (`fi`, `done`) are no work either.
120const SETUP = new Set(['', 'cd', 'pushd', 'popd', 'export', 'source', '.', 'set', 'unset', 'nvm', 'echo', 'true', 'fi', 'done', 'esac', '}', ')'])
121
122// Whether the command's parts all run in an `&&` chain, where exit 0 vouches for every one of them; after `;`,
123// `||` or a pipe it vouches for the last part alone.
124const isAndChain = (command: string): boolean => !/\|\||;|\||\n/.test(command.trim().replace(/&&/g, ''))
125
126// The check a failed run failed at, and where its output starts. The first check whose failure shows: in an
127// `a && b` chain a failed `a` stops `b` from running at all, so a later check matching too is only alike in its
128// wording. With no sign the first check, unless the command does other work (`npm test && git push`) that may
129// be what failed.
130export const failedGuard = (command: string, output: string): { guard: Guard; from: number } | undefined => {
131 const found = bossesFor(command)
132 for (const guard of found) {
133 const at = FAILURE_SIGNS[guard.kind]?.exec(output)?.index
134 if (at !== undefined) return { guard, from: output.lastIndexOf('\n', at) + 1 }
135 }
136 const hasOtherWork = segmentsOf(command).some(part => kindOf(part) === undefined && !SETUP.has(programOf(part).program))
137 const first = found[0]
138 return first === undefined || hasOtherWork ? undefined : { guard: first, from: 0 }
139}
140
141// The kinds of check a passing command vouches for.
142const passedKinds = (command: string): string[] => {
143 if (isAndChain(command)) return bossesFor(command).map(one => one.kind)
144 const kind = kindOf(segmentsOf(command).filter(part => part.trim() !== '').at(-1) ?? '')
145 return kind === undefined ? [] : [kind]
146}
147
148const WHAT_FAILED: Record<string, string> = {
149 e2e: 'end-to-end tests',
150 types: 'type errors',
151 lint: 'lint errors',
152 test: 'failing tests',
153}
154
155// The prompt a click on the boss fills in: fix what its command reported.
156export const fightText = (boss: Pick<Boss, 'kind' | 'command'>): string =>
157 `Fix the ${WHAT_FAILED[boss.kind] ?? 'failures'} from \`${boss.command}\` and run it again until it passes`
158
159const COUNT = /(\d+)\s+(?:failed|failing|failures?|errors?|problems?)\b/gi
160
161// The largest "N failed / N errors" a run printed, or 1 when it failed without saying how badly.
162export const countFailures = (output: string): number => {
163 let most = 0
164 for (const match of output.matchAll(COUNT)) {
165 most = Math.max(most, Number(match[1]))
166 }
167 return most > 0 ? most : 1
168}
169
170export type BossTurn = { boss: Boss | null; event?: 'appear' | 'hit' | 'defeat' }
171
172// One guarded run against the current boss: a failure spawns or heals it, its HP counted from the failed check's
173// own output on. A passing command slays the boss when it vouches for that boss's kind of check.
174export const fightBoss = (current: Boss | null, command: string, isError: boolean, output: string): BossTurn => {
175 if (!isError) {
176 if (current !== null && passedKinds(command).includes(current.kind)) return { boss: null, event: 'defeat' }
177 return { boss: current }
178 }
179 const failed = failedGuard(command, output)
180 if (failed === undefined) return { boss: current }
181 const found = failed.guard
182 const hp = countFailures(output.slice(failed.from))
183 if (current === null || current.kind !== found.kind) {
184 return { boss: { ...found, hp, maxHp: hp, command }, event: 'appear' }
185 }
186 return {
187 boss: { ...current, hp, maxHp: Math.max(current.maxHp, hp), command },
188 event: hp < current.hp ? 'hit' : undefined,
189 }
190}
191hooks/bag-client.tsx 87 lines1import type { ClientModule } from 'claude-code'
2
3import type { BagItem } from '../types'
4import { STATUS_GLYPHS, rarityOf, splitBar } from './diff'
5import { fit, fitTail, pickList } from './pick-list'
6import type { PickState } from './pick-list'
7
8// `label` names where back leads: the map, or the outposts for a bag opened from them.
9export type BagRow = { kind: 'back'; label?: string } | { kind: 'item'; item: BagItem } | { kind: 'note'; text: string }
10
11export type BagProps = {
12 // The rows the window shows, already scrolled.
13 rows: BagRow[]
14 width: number
15}
16
17const CURSOR = 2
18const GLYPH = 2
19const BAR = 8
20// ` +1234 −567` after the bar.
21const STATS = 12
22
23// The bag of unsaved changes: a click on an item inspects its diff, one on the first row goes back.
24const Bag: ClientModule<BagProps, PickState> = (props, surface) => {
25 const { Box, Text } = surface.elements
26 const { state, cursor } = pickList(surface, {
27 rows: props.rows,
28 width: props.width,
29 isPickable: row => row.kind !== 'note',
30 open: row => {
31 if (row.kind === 'back') surface.post({ back: true })
32 if (row.kind === 'item') surface.post({ inspect: row.item.path })
33 },
34 back: () => surface.post({ back: true }),
35 })
36 const pathWidth = Math.max(6, props.width - CURSOR - GLYPH - BAR - STATS)
37
38 return (
39 <Box flexDirection="column" width={props.width}>
40 {props.rows.map((row, index) => {
41 const isHover = state.hover === index
42 if (row.kind === 'back') {
43 return (
44 <Text key="back" color={isHover ? 'yellow' : 'cyan'} bold={isHover}>
45 {cursor(isHover)}◂ back to {row.label ?? 'the map'}
46 </Text>
47 )
48 }
49 if (row.kind === 'note') {
50 return (
51 <Text key={`note-${index}`} dimColor>
52 {row.text}
53 </Text>
54 )
55 }
56 const { item } = row
57 const rarity = rarityOf(item.added + item.removed)
58 const { plus, minus } = splitBar(item.added, item.removed, BAR)
59 const stats = item.isBinary
60 ? ' binary'
61 : item.status === '?' && item.added === 0
62 ? ' new'
63 : ` +${item.added} −${item.removed}`
64 return (
65 <Box key={`item-${item.path}`} flexDirection="row" height={1}>
66 <Text color="yellow">{cursor(isHover)}</Text>
67 <Text color={rarity.color} bold>
68 {(STATUS_GLYPHS[item.status] ?? '✎') + ' '}
69 </Text>
70 <Text color={isHover ? 'yellow' : rarity.color === 'white' ? undefined : rarity.color} bold={isHover}>
71 {fitTail(item.path, pathWidth)}
72 </Text>
73 <Text color="green">{'▮'.repeat(plus)}</Text>
74 <Text color="red">{'▮'.repeat(minus)}</Text>
75 <Text dimColor>{'·'.repeat(BAR - plus - minus)}</Text>
76 <Text color={isHover ? 'yellow' : undefined} dimColor={!isHover}>
77 {fit(isHover ? ' ⏎ inspect' : stats, STATS)}
78 </Text>
79 </Box>
80 )
81 })}
82 </Box>
83 )
84}
85
86export default Bag
87hooks/git.ts 155 lines1import type { BagItem, GitMap, Outpost } from '../types'
2
3// `## main...origin/main [ahead 1, behind 2]`, or `## HEAD (no branch)` when detached.
4export const parseStatus = (porcelain: string): Omit<GitMap, 'isRepo' | 'graph' | 'remotes'> => {
5 const lines = porcelain.split('\n').filter(line => line.length > 0)
6 const head = lines[0]?.startsWith('## ') ? (lines.shift() ?? '') : ''
7 const branch = head.slice(3).split('...')[0]?.replace(/^No commits yet on /, '') ?? ''
8 const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
9 const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
10 return { branch, ahead, behind, dirty: lines.length }
11}
12
13export type GraphRow = {
14 hash: string
15 // The parents' full hashes: none for a root, two or more for a merge.
16 parents: string[]
17 refs: string
18 age: string
19 subject: string
20 isHead: boolean
21 // The remote-tracking branches that point here (`origin/main`), without `origin/HEAD`.
22 remotes: string[]
23}
24
25// Fields of one GIT_LOG line, split by the unit separator.
26const SEP = '\x1f'
27
28// One `git log` line in GIT_LOG's format: full hash, parents, refs, age, subject.
29// `remoteNames` (`git remote`) tells a remote-tracking ref (`origin/main`) from a local branch with a slash (`feat/x`).
30export const parseLogLine = (line: string, remoteNames: readonly string[] = ['origin']): GraphRow => {
31 const [hash = '', parents = '', refs = '', age = '', ...subject] = line.split(SEP)
32 const names = refs.split(',').map(ref => ref.trim()).filter(ref => ref.length > 0)
33 return {
34 hash,
35 parents: parents.split(' ').filter(parent => parent.length > 0),
36 refs,
37 age: shortAge(age),
38 subject: subject.join(SEP),
39 isHead: names.some(ref => ref === 'HEAD' || ref.startsWith('HEAD -> ')),
40 remotes: names.filter(ref => remoteNames.some(remote => ref.startsWith(`${remote}/`)) && !ref.endsWith('/HEAD')),
41 }
42}
43
44// GIT_LOG's lines as commits; a line in any other shape (a graph kept from an older format) is left out.
45export const parseLog = (lines: readonly string[], remoteNames?: readonly string[]): GraphRow[] =>
46 lines.map(line => parseLogLine(line, remoteNames)).filter(row => /^[0-9a-f]{7,64}$/.test(row.hash))
47
48// `3 hours ago` → `3h`, `2 weeks ago` → `2w`; anything else as git wrote it.
49export const shortAge = (age: string): string => {
50 const match = /^(\d+) (second|minute|hour|day|week|month|year)s? ago$/.exec(age.trim())
51 if (match === null) return age.replace(/ ago$/, '')
52 const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[match[2] ?? 'day']
53 return `${match[1]}${unit}`
54}
55
56// A history with no fork or merge in sight: each commit's one parent is the next one shown.
57export const isLinear = (rows: readonly GraphRow[]): boolean =>
58 rows.length > 0 &&
59 rows.every((row, index) => {
60 const next = rows[index + 1]
61 return row.parents.length <= 1 && (next === undefined || row.parents[0] === next.hash)
62 })
63
64export const GRAPH_LIMIT = 40
65
66export const GIT_LOG = [
67 'git',
68 'log',
69 '--color=never',
70 '--all',
71 // Each branch's commits together, children before their parents: what the lane layout draws from.
72 '--topo-order',
73 `-n${GRAPH_LIMIT}`,
74 `--format=%H${SEP}%P${SEP}%D${SEP}%cr${SEP}%s`,
75]
76export const GIT_REMOTES = ['git', 'remote']
77// Paths as written (日記.md), not C-escaped ("\346\227\245…"), so they match between commands and on disk.
78const RAW_PATHS = ['-c', 'core.quotePath=false']
79
80export const GIT_STATUS = ['git', ...RAW_PATHS, 'status', '--porcelain=v1', '--branch', '--untracked-files=all']
81
82export const GIT_NUMSTAT = ['git', ...RAW_PATHS, 'diff', '--numstat', 'HEAD']
83
84// Paths from a porcelain line's tail: `a.ts`, `"with space.ts"` or `old.ts -> new.ts` (the new one).
85// A control character left in a name (git still quotes those) would make the whole drawing invalid: it shows as ?.
86const porcelainPath = (tail: string): string => {
87 const path = tail.includes(' -> ') ? (tail.split(' -> ').pop() ?? tail) : tail
88 return path.replace(/^"(.*)"$/, '$1').replace(/[\u0000-\u001f\u007f]/g, '?')
89}
90
91const STATUS_OF: Record<string, BagItem['status']> = { M: 'M', A: 'A', D: 'D', R: 'R', C: 'A', '?': '?', U: 'M', T: 'M' }
92
93// The bag from `git status --porcelain=v1 --branch` and `git diff --numstat HEAD`: one item per changed path.
94export const parseBag = (porcelain: string, numstat: string): BagItem[] => {
95 const counts = new Map<string, { added: number; removed: number; isBinary: boolean }>()
96 for (const line of numstat.split('\n')) {
97 const [added, removed, ...rest] = line.split('\t')
98 if (added === undefined || removed === undefined || rest.length === 0) continue
99 const path = rest.join('\t').replace(/^(.*)\{(.*) => (.*)\}(.*)$/, '$1$3$4').replace(/^.* => /, '')
100 const isBinary = added === '-'
101 counts.set(path, { added: isBinary ? 0 : Number(added), removed: isBinary ? 0 : Number(removed), isBinary })
102 }
103 return porcelain
104 .split('\n')
105 .filter(line => line.length > 3 && !line.startsWith('## '))
106 .map(line => {
107 const code = line.slice(0, 2)
108 const path = porcelainPath(line.slice(3))
109 const letter = code === '??' ? '?' : (code.trim()[0] ?? 'M')
110 const count = counts.get(path) ?? { added: 0, removed: 0, isBinary: false }
111 return { path, status: STATUS_OF[letter] ?? 'M', ...count }
112 })
113}
114
115// What `git diff` prints for one bag item: against HEAD, or against nothing for a file git does not track yet.
116export const diffArgv = (item: Pick<BagItem, 'path' | 'status'>): string[] =>
117 item.status === '?'
118 ? ['git', ...RAW_PATHS, 'diff', '--no-color', '--no-index', '--', '/dev/null', item.path]
119 : ['git', ...RAW_PATHS, 'diff', '--no-color', 'HEAD', '--', item.path]
120
121export const GIT_WORKTREES = ['git', 'worktree', 'list', '--porcelain']
122
123export type WorktreeEntry = Pick<Outpost, 'path' | 'name' | 'branch' | 'isMain' | 'isLocked' | 'isPrunable'>
124
125// `git worktree list --porcelain`: one block per worktree, the main one first. A bare repository's own entry
126// is left out, and then no worktree is the main one: every one of them is linked.
127export const parseWorktrees = (text: string): WorktreeEntry[] => {
128 const blocks = text
129 .split(/\n\s*\n/)
130 .map(block => block.split('\n').filter(line => line.length > 0))
131 .filter(lines => lines[0]?.startsWith('worktree '))
132 const isBare = blocks[0]?.includes('bare') === true
133 return blocks
134 .filter(lines => !lines.includes('bare'))
135 .map((lines, index) => {
136 const path = (lines[0] ?? '').slice('worktree '.length)
137 const branch = lines.find(line => line.startsWith('branch '))?.slice('branch '.length).replace(/^refs\/heads\//, '') ?? ''
138 return {
139 path,
140 name: path.split('/').filter(part => part.length > 0).pop() ?? path,
141 branch,
142 isMain: !isBare && index === 0,
143 isLocked: lines.some(line => line === 'locked' || line.startsWith('locked ')),
144 isPrunable: lines.some(line => line === 'prunable' || line.startsWith('prunable ')),
145 }
146 })
147}
148
149// A worktree's status, numstat and last commit, run from its own folder.
150export const outpostArgv = {
151 status: (path: string) => ['git', '-C', path, ...GIT_STATUS.slice(1)],
152 numstat: (path: string) => ['git', '-C', path, ...GIT_NUMSTAT.slice(1)],
153 age: (path: string) => ['git', '-C', path, 'log', '-1', '--format=%cr'],
154}
155hooks/outposts-client.tsx 93 lines1import type { ClientModule } from 'claude-code'
2
3import type { Outpost } from '../types'
4import { fit, pickList } from './pick-list'
5import type { PickState } from './pick-list'
6
7export type OutpostRow = { kind: 'back' } | { kind: 'outpost'; outpost: Outpost } | { kind: 'note'; text: string }
8
9export type OutpostsProps = {
10 // The rows the window shows, already scrolled.
11 rows: OutpostRow[]
12 width: number
13}
14
15// The cursor, the marker, the branch and the right-hand status columns.
16const CURSOR = 2
17const MARK = 3
18const BRANCH = 16
19const STATUS = 13
20const AGE = 5
21
22// Where the outpost stands: here, a lock, a ruin, or a plain camp.
23export const markerOf = (outpost: Outpost): { glyph: string; color: string } => {
24 if (outpost.isPrunable) return { glyph: '💀', color: 'gray' }
25 if (outpost.isHere) return { glyph: '@ ', color: 'yellow' }
26 if (outpost.isLocked) return { glyph: '🔒', color: 'cyan' }
27 return { glyph: '⚑ ', color: 'green' }
28}
29
30// The worktrees as outposts: a click on one opens its bag, one on the first row goes back to the map.
31// A ruin (prunable: its folder is gone) has nothing to open.
32const Outposts: ClientModule<OutpostsProps, PickState> = (props, surface) => {
33 const { Box, Text } = surface.elements
34 const { state, cursor } = pickList(surface, {
35 rows: props.rows,
36 width: props.width,
37 isPickable: row => row.kind === 'back' || (row.kind === 'outpost' && !row.outpost.isPrunable),
38 open: row => {
39 if (row.kind === 'back') surface.post({ back: true })
40 if (row.kind === 'outpost') surface.post({ outpost: row.outpost.path })
41 },
42 back: () => surface.post({ back: true }),
43 })
44 const nameWidth = Math.max(6, props.width - CURSOR - MARK - BRANCH - STATUS - AGE)
45
46 return (
47 <Box flexDirection="column" width={props.width}>
48 {props.rows.map((row, index) => {
49 const isHover = state.hover === index
50 if (row.kind === 'back') {
51 return (
52 <Text key="back" color={isHover ? 'yellow' : 'cyan'} bold={isHover}>
53 {cursor(isHover)}◂ back to the map
54 </Text>
55 )
56 }
57 if (row.kind === 'note') {
58 return (
59 <Text key={`note-${index}`} dimColor>
60 {row.text}
61 </Text>
62 )
63 }
64 const { outpost } = row
65 const marker = markerOf(outpost)
66 const distance = `${outpost.ahead > 0 ? `↑${outpost.ahead}` : ''}${outpost.behind > 0 ? `↓${outpost.behind}` : ''}`
67 let status = { text: outpost.dirty > 0 ? `⚠ ${outpost.dirty} unsaved` : '✓ saved', color: outpost.dirty > 0 ? 'red' : 'green' }
68 if (outpost.isPrunable) status = { text: 'prunable', color: 'gray' }
69 else if (isHover) status = { text: '⏎ open bag', color: 'yellow' }
70 const right = outpost.isHere && !isHover ? 'here' : outpost.isLocked && !isHover ? 'lock' : outpost.age
71 return (
72 <Box key={`o-${outpost.path}`} flexDirection="row" height={1}>
73 <Text color="yellow">{cursor(isHover)}</Text>
74 <Text color={marker.color}>{fit(marker.glyph, MARK)}</Text>
75 <Text bold={isHover || outpost.isHere} color={isHover ? 'yellow' : undefined} dimColor={outpost.isPrunable}>
76 {fit(outpost.name, nameWidth)}
77 </Text>
78 <Text color="green" dimColor={!isHover}>
79 {fit(`${outpost.branch || '(detached)'}${distance === '' ? '' : ` ${distance}`}`, BRANCH)}
80 </Text>
81 <Text color={status.color} dimColor={status.color === 'green'}>
82 {fit(status.text, STATUS)}
83 </Text>
84 <Text dimColor>{fit(right, AGE)}</Text>
85 </Box>
86 )
87 })}
88 </Box>
89 )
90}
91
92export default Outposts
93hooks/inspect.tsx 100 lines1import type { ElementTable } from 'claude-code'
2
3import type { BagItem, Inspect } from '../types'
4import { STATUS_GLYPHS, parseDiff, rarityOf } from './diff'
5import type { Item } from './window'
6
7// The line number column and the +/− marker after it.
8const GUTTER = 5
9const MARK = 2
10
11const MARKS = { add: { glyph: '+', color: 'green' }, del: { glyph: '−', color: 'red' }, ctx: { glyph: ' ', color: 'gray' } } as const
12
13export const bagSubtitle = (bag: readonly BagItem[]): string => {
14 const added = bag.reduce((sum, item) => sum + item.added, 0)
15 const removed = bag.reduce((sum, item) => sum + item.removed, 0)
16 return `${bag.length} items · +${added} −${removed}`
17}
18
19// One change's diff, a row a line: the back button, the item's card, then its hunks with line numbers.
20export function inspectItems(
21 ui: ElementTable,
22 inspect: Inspect | null,
23 item: BagItem | undefined,
24 width: number,
25 back: () => void,
26): Item[] {
27 const { Box, Text, Button } = ui
28 const row = (key: string, node: Item['node']): Item => ({ key, rows: 1, node })
29 const items: Item[] = [
30 row('back', <Button key="inspect-back" label="◂ back to the bag" plain onPress={back} />),
31 ]
32 if (inspect === null) return [...items, row('loading', <Text dimColor>Unrolling the scroll…</Text>)]
33
34 const rarity = rarityOf((item?.added ?? 0) + (item?.removed ?? 0))
35 items.push(
36 row(
37 'card',
38 <Text wrap="truncate">
39 <Text bold color={rarity.color}>
40 {STATUS_GLYPHS[item?.status ?? 'M'] ?? '✎'} {inspect.path}
41 </Text>
42 {item !== undefined && (
43 <Text>
44 <Text color="green"> +{item.added}</Text>
45 <Text color="red"> −{item.removed}</Text>
46 </Text>
47 )}
48 <Text color={rarity.color} dimColor>
49 {' '}[{rarity.name}]
50 </Text>
51 </Text>,
52 ),
53 )
54
55 const lines = parseDiff(inspect.lines)
56 if (lines.length === 0) items.push(row('empty', <Text dimColor>Nothing to read: the change is only in its mode or name.</Text>))
57 const textWidth = Math.max(4, width - GUTTER - MARK)
58 lines.forEach((line, index) => {
59 if (line.kind === 'meta') {
60 items.push(row(`l-${index}`, <Text dimColor>{line.text}</Text>))
61 return
62 }
63 if (line.kind === 'hunk') {
64 const label = `── line ${line.newStart} ${line.text}`.trimEnd() + ' '
65 items.push(
66 row(
67 `l-${index}`,
68 <Text color="cyan" dimColor wrap="truncate">
69 {label + '─'.repeat(Math.max(0, width - label.length))}
70 </Text>,
71 ),
72 )
73 return
74 }
75 const mark = MARKS[line.kind]
76 items.push(
77 row(
78 `l-${index}`,
79 <Box height={1}>
80 <Box width={GUTTER}>
81 <Text dimColor>{String(line.number).padStart(GUTTER - 1)}</Text>
82 </Box>
83 <Box width={MARK}>
84 <Text bold color={mark.color}>
85 {mark.glyph}
86 </Text>
87 </Box>
88 <Box width={textWidth}>
89 <Text wrap="truncate" {...(line.kind === 'ctx' ? { dimColor: true } : { color: mark.color })}>
90 {line.text.replace(/\t/g, ' ') || ' '}
91 </Text>
92 </Box>
93 </Box>,
94 ),
95 )
96 })
97 if (inspect.isCut) items.push(row('cut', <Text dimColor>⋯ the scroll goes on; open the file to read the rest</Text>))
98 return items
99}
100hooks/edit-card.tsx 102 lines1import type { ElementTable } from 'claude-code'
2
3import { rarityOf, splitBar } from './diff'
4
5export type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
6
7// What an Edit or Write result carries that the card reads.
8export type EditOutput = {
9 filePath: string
10 structuredPatch?: Hunk[]
11 type?: 'create' | 'update'
12 content?: string
13 userModified?: boolean
14 staged?: boolean
15}
16
17// The card shows this many diff lines; the transcript's own expansion (ctrl+o) still has the whole result.
18export const CARD_LINES = 40
19const BAR = 20
20
21export const countPatch = (hunks: readonly Hunk[]) => {
22 let added = 0
23 let removed = 0
24 for (const hunk of hunks) {
25 for (const line of hunk.lines) {
26 if (line.startsWith('+')) added += 1
27 else if (line.startsWith('-')) removed += 1
28 }
29 }
30 return { added, removed }
31}
32
33// The hunks as unified-diff text of at most `max` lines; a hunk cut short gets a header that counts what it kept,
34// so the text still parses as hunks.
35export const patchSource = (hunks: readonly Hunk[], max: number) => {
36 const out: string[] = []
37 let shown = 0
38 let total = 0
39 for (const hunk of hunks) {
40 total += hunk.lines.length
41 const room = max - shown
42 if (room <= 0) continue
43 const kept = hunk.lines.slice(0, room)
44 const oldLines = kept.filter(line => !line.startsWith('+')).length
45 const newLines = kept.filter(line => !line.startsWith('-')).length
46 out.push(`@@ -${hunk.oldStart},${oldLines} +${hunk.newStart},${newLines} @@`, ...kept)
47 shown += kept.length
48 }
49 return { source: out.join('\n'), hidden: total - shown }
50}
51
52const shortPath = (path: string, cwd: string): string => (path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path)
53
54// An Edit or Write result as a spell card: what was cast on which file, its +/− and rarity, then the diff itself.
55export function renderEditCard(ui: ElementTable, tool: string, output: EditOutput, cwd: string) {
56 const { Box, Text, Code } = ui
57 const hunks = output.structuredPatch ?? []
58 const isCreate = tool === 'Write' && output.type === 'create'
59 // The lines of the new file; a final newline ends the last line rather than starting another.
60 const content = output.content ?? ''
61 const created = !isCreate || content === '' ? [] : (content.endsWith('\n') ? content.slice(0, -1) : content).split('\n')
62 const { added, removed } = isCreate ? { added: created.length, removed: 0 } : countPatch(hunks)
63 const rarity = rarityOf(added + removed)
64 const { plus, minus } = splitBar(added, removed, BAR)
65 const verb = isCreate ? '✚ Conjured' : tool === 'Write' ? '✒ Rewrote' : '✎ Enchanted'
66 const patch = isCreate ? { source: created.slice(0, CARD_LINES).join('\n'), hidden: Math.max(0, created.length - CARD_LINES) } : patchSource(hunks, CARD_LINES)
67
68 return (
69 <Box flexDirection="column" borderStyle="round" borderColor={rarity.color} paddingX={1}>
70 <Box height={1}>
71 <Box flexGrow={1}>
72 <Text wrap="truncate">
73 <Text bold color={rarity.color}>
74 {verb}
75 </Text>
76 <Text bold> {shortPath(output.filePath, cwd)}</Text>
77 </Text>
78 </Box>
79 <Text>
80 <Text color="green">+{added}</Text>
81 <Text color="red"> −{removed}</Text>
82 <Text color={rarity.color}> [{rarity.name}]</Text>
83 </Text>
84 </Box>
85 <Text>
86 <Text color="green">{'▮'.repeat(plus)}</Text>
87 <Text color="red">{'▮'.repeat(minus)}</Text>
88 <Text dimColor>{'·'.repeat(BAR - plus - minus)}</Text>
89 {output.staged === true && <Text color="yellow"> ✋ held for review: the file is unchanged</Text>}
90 {output.userModified === true && <Text color="cyan"> ✎ you reshaped the spell</Text>}
91 </Text>
92 {patch.source.length > 0 &&
93 (isCreate ? (
94 <Code source={patch.source} path={output.filePath} startLine={1} wrap="truncate-end" />
95 ) : (
96 <Code source={patch.source} format="diff" path={output.filePath} wrap="truncate-end" />
97 ))}
98 {patch.hidden > 0 && <Text dimColor>⋯ {patch.hidden} more lines (ctrl+o shows the whole result)</Text>}
99 </Box>
100 )
101}
102hooks/hero.tsx 225 lines1import type { ElementTable } from 'claude-code'
2
3import type { Boss, Combo, ToolCall, Vitals } from '../types'
4import { HERO_CELLS, anim } from './anim'
5import { barParts, oneLine } from './util'
6import { CLASSES, rankOf } from './classes'
7import type { HeroClass } from './classes'
8
9// The stats column is as tall as the portrait: name, rank, HP, MP, XP, rule, combo, party, status, boss.
10export const STATS_ROWS = 10
11// The status panel's double border above and below the stats.
12export const STATUS_ROWS = STATS_ROWS + 2
13// The border and the padding column on each side.
14const PANEL_CHROME_COLUMNS = 4
15const PORTRAIT_GAP = 2
16// The narrowest stats column worth drawing beside the portrait.
17const MIN_STATS_COLUMNS = 24
18// 'HP ' before a bar and ' 99/99' after it.
19const BAR_LABEL = 3
20const BAR_NOTE = 7
21const MAX_BAR = 18
22const MIN_BAR = 6
23
24// The wizard's rank at a level; other classes go through rankOf.
25export const rankFor = (level: number): string => rankOf(CLASSES.wizard, level)
26
27export const HEARTS = 5
28const HP_WINDOW = 10
29export const CALLS_PER_LEVEL = 10
30const MP_MAX = 100
31
32// $.state outlives a reload, so entries the first pane version stored ({ id, tool, isDone }) still arrive here.
33export const normalizeCall = (stored: unknown): ToolCall => {
34 const call = stored as Partial<ToolCall> & { isDone?: boolean; id: string; tool: string }
35 if (call.status !== undefined) return call as ToolCall
36 return {
37 id: call.id,
38 tool: call.tool,
39 summary: call.summary ?? '',
40 startedAt: call.startedAt ?? 0,
41 status: call.isDone === false ? 'run' : 'ok',
42 }
43}
44
45// Level follows the call count (`total`, uncapped; the list keeps only the latest calls);
46// each failure among the recent calls costs a heart.
47export const heroStats = (list: readonly ToolCall[], total: number = list.length) => {
48 const failures = list.slice(-HP_WINDOW).filter(call => call.status === 'err').length
49 return {
50 level: 1 + Math.floor(total / CALLS_PER_LEVEL),
51 xp: total % CALLS_PER_LEVEL,
52 hp: HEARTS - Math.min(HEARTS, failures),
53 }
54}
55
56// MP is the context window left; unknown until the first reading.
57export const manaLeft = (vitals: Vitals): number | undefined =>
58 vitals.contextPercent === undefined ? undefined : Math.max(0, MP_MAX - Math.round(vitals.contextPercent))
59
60// Green while healthy, yellow when hurt, red when nearly out.
61export const gaugeColor = (value: number, max: number, healthy: string): string => {
62 const share = max <= 0 ? 0 : value / max
63 if (share > 0.6) return healthy
64 return share > 0.3 ? 'yellow' : 'red'
65}
66
67// Whether the portrait fits beside a usable stats column, and how wide that column and its bars are.
68export const statusLayout = (width: number, hasRaster: boolean) => {
69 const inner = Math.max(0, width - PANEL_CHROME_COLUMNS)
70 const hasPortrait = hasRaster && inner >= HERO_CELLS.columns + PORTRAIT_GAP + MIN_STATS_COLUMNS
71 const statsWidth = hasPortrait ? inner - HERO_CELLS.columns - PORTRAIT_GAP : inner
72 const barWidth = Math.max(MIN_BAR, Math.min(MAX_BAR, statsWidth - BAR_LABEL - BAR_NOTE))
73 return { hasPortrait, statsWidth, barWidth }
74}
75
76export type StatusData = {
77 hero: HeroClass
78 // Every call this session; `list` holds only the latest.
79 casts: number
80 list: readonly ToolCall[]
81 vitals: Vitals
82 combo: Combo
83 boss: Boss | null
84 // Background shells and monitors still running.
85 jobsOut: number
86 petsOut: number
87 // The outpost (worktree) the session works in, when not the repository's main folder.
88 camp?: string
89 isFlashing: boolean
90 width: number
91 // The tool the session waits on the person for: a permission prompt, or a question.
92 waitingFor?: string
93 // Opens or closes the help page; the name row then carries a ? for it.
94 onHelp?: () => void
95 // Fills the prompt with the boss's fix; the boss row then carries a button for it.
96 onFight?: () => void
97}
98
99const FIGHT_LABEL = '⚔ fix'
100
101
102
103const Bar = (ui: ElementTable, label: string, color: string, value: number, max: number, note: string, width: number) => {
104 const { Text } = ui
105 const { filled, empty } = barParts(value, max, width)
106 return (
107 <Text wrap="truncate">
108 <Text bold color={color}>{label} </Text>
109 <Text color={color}>{filled}</Text>
110 <Text dimColor>{empty}</Text>
111 <Text color={color}> {note}</Text>
112 </Text>
113 )
114}
115
116// The hero's status window: always on top, whatever the menu shows below.
117export function renderStatus(ui: ElementTable, data: StatusData, Raster?: ElementTable<'terminal'>['Raster']) {
118 const { Box, Text, Button } = ui
119 const { list, vitals, combo, boss, width } = data
120 const { level, xp, hp } = heroStats(list, data.casts)
121 const mp = manaLeft(vitals)
122 const current = list.findLast(call => call.status === 'run')
123 const failed = list.filter(call => call.status === 'err').length
124 const { hasPortrait, statsWidth, barWidth } = statusLayout(width, Raster !== undefined)
125 const { hero } = data
126 const frame = data.isFlashing ? 'yellow' : hero.color
127 const fullName = `CLAUDE THE ${hero.title}`
128 // Room for `Lv.NN` beside the name, and for the ? after it.
129 const name = statsWidth >= fullName.length + 6 + (data.onHelp === undefined ? 0 : 2) ? fullName : 'CLAUDE'
130 const { usd } = vitals
131 anim.hasHero = hasPortrait
132
133 const fightRoom = data.onFight === undefined ? 0 : FIGHT_LABEL.length + 1
134 const bossBar = barParts(boss?.hp ?? 0, boss?.maxHp ?? 0, Math.max(4, Math.min(10, statsWidth - (boss?.name.length ?? 0) - 9 - fightRoom)))
135 const bossRow =
136 boss === null ? null : (
137 <Box height={1}>
138 <Box flexGrow={1}>
139 <Text wrap="truncate">
140 <Text bold color="red">☠ {boss.name} </Text>
141 <Text color="red">{bossBar.filled}</Text>
142 <Text dimColor>{bossBar.empty}</Text>
143 <Text color="red"> {boss.hp}/{boss.maxHp}</Text>
144 </Text>
145 </Box>
146 {data.onFight !== undefined && <Button key="fight" label={FIGHT_LABEL} plain onPress={data.onFight} />}
147 </Box>
148 )
149
150 return (
151 <Box flexDirection="row" width={width} height={STATUS_ROWS} borderStyle="double" borderColor={frame} paddingX={1}>
152 {hasPortrait && Raster && (
153 <Box width={HERO_CELLS.columns + PORTRAIT_GAP}>
154 <Raster key="hero" {...HERO_CELLS} cells={anim.shownCells} />
155 </Box>
156 )}
157 <Box flexDirection="column" width={statsWidth} height={STATS_ROWS}>
158 <Box height={1}>
159 <Box flexGrow={1}>
160 <Text bold color={frame}>
161 {name}
162 </Text>
163 </Box>
164 <Text bold color="yellow">
165 Lv.{level}
166 </Text>
167 {data.onHelp !== undefined && <Text> </Text>}
168 {data.onHelp !== undefined && <Button key="help" label="?" plain onPress={data.onHelp} />}
169 </Box>
170 <Text wrap="truncate">
171 <Text italic color="cyan">{rankOf(hero, level)}</Text>
172 <Text dimColor> · {data.casts} casts</Text>
173 </Text>
174 {Bar(ui, 'HP', gaugeColor(hp, HEARTS, 'green'), hp, HEARTS, `${hp}/${HEARTS}`, barWidth)}
175 {mp === undefined
176 ? Bar(ui, 'MP', 'blue', 0, MP_MAX, '?', barWidth)
177 : Bar(ui, 'MP', gaugeColor(mp, MP_MAX, 'blue'), mp, MP_MAX, `${mp}%`, barWidth)}
178 {Bar(ui, 'XP', 'magenta', xp, CALLS_PER_LEVEL, `${xp}/${CALLS_PER_LEVEL}`, barWidth)}
179 <Text color={frame} dimColor>
180 {'┈'.repeat(statsWidth)}
181 </Text>
182 <Box height={1}>
183 <Box flexGrow={1}>
184 <Text wrap="truncate">
185 <Text bold color={combo.current >= 10 ? 'yellow' : 'white'}>⚡×{combo.current}</Text>
186 <Text dimColor> best {combo.best}</Text>
187 </Text>
188 </Box>
189 {usd !== undefined && <Text color="yellow">⛁ {usd.toFixed(2)}</Text>}
190 </Box>
191 <Text wrap="truncate">
192 <Text color="yellow">⚙ {data.jobsOut}</Text>
193 <Text dimColor> {data.jobsOut === 1 ? 'job' : 'jobs'}</Text>
194 <Text color="green"> ♣ {data.petsOut}</Text>
195 <Text dimColor> in party</Text>
196 </Text>
197 {data.waitingFor !== undefined ? (
198 <Text bold color="yellow" wrap="truncate">
199 ❗ {data.waitingFor === 'AskUserQuestion' ? 'awaiting your answer' : `awaiting your word on ${data.waitingFor}`}
200 </Text>
201 ) : current ? (
202 <Text color="yellow" wrap="truncate">
203 {hero.acting} <Text bold>{current.tool}</Text>{' '}
204 <Text dimColor>{oneLine(current.summary, statsWidth - hero.acting.length - 2 - current.tool.length)}</Text>
205 </Text>
206 ) : (
207 <Text dimColor wrap="truncate">
208 {data.camp === undefined ? hero.resting : `🏕 camping at ${data.camp}`}
209 </Text>
210 )}
211 {bossRow ??
212 (failed > 0 ? (
213 <Text color="red" wrap="truncate">
214 ✗ {failed} {hero.failures}
215 </Text>
216 ) : (
217 <Text color="green" dimColor>
218 ✓ no {hero.failures}
219 </Text>
220 ))}
221 </Box>
222 </Box>
223 )
224}
225hooks/classes.ts 99 lines1import { HERO_CLASS_FRAMES } from './hero-cells'
2import type { HeroClassId } from './hero-cells'
3
4export type { HeroClassId }
5
6export type HeroClass = {
7 id: HeroClassId
8 // `CLAUDE THE <title>` heads the status panel.
9 title: string
10 // The panel's frame and the name's colour.
11 color: string
12 // Titles from level 1, 3, 5, 7 and 10.
13 ranks: readonly [string, string, string, string, string]
14 // The status line while a tool runs, and while none does.
15 acting: string
16 resting: string
17 // What the failures count as: `3 spells fizzled`.
18 failures: string
19}
20
21export const CLASSES: Readonly<Record<HeroClassId, HeroClass>> = {
22 wizard: {
23 id: 'wizard',
24 title: 'WIZARD',
25 color: 'magenta',
26 ranks: ['Apprentice', 'Adept', 'Sorcerer', 'Archmage', 'Grand Wizard'],
27 acting: '✦ casting',
28 resting: '☾ meditating in the tower',
29 failures: 'spells fizzled',
30 },
31 knight: {
32 id: 'knight',
33 title: 'KNIGHT',
34 color: 'red',
35 ranks: ['Squire', 'Knight', 'Knight-Captain', 'Paladin', 'Lord Commander'],
36 acting: '⚔ striking',
37 resting: '⛨ standing guard',
38 failures: 'blows parried',
39 },
40 ranger: {
41 id: 'ranger',
42 title: 'RANGER',
43 color: 'green',
44 ranks: ['Scout', 'Tracker', 'Pathfinder', 'Warden', 'Ranger Lord'],
45 acting: '➶ shooting',
46 resting: '♣ scouting the woods',
47 failures: 'arrows missed',
48 },
49 rogue: {
50 id: 'rogue',
51 title: 'ROGUE',
52 color: '#a78bfa',
53 ranks: ['Cutpurse', 'Shadow', 'Nightblade', 'Phantom', 'Guildmaster'],
54 acting: '✧ sneaking',
55 resting: '☾ lurking in the shadows',
56 failures: 'traps sprung',
57 },
58 cleric: {
59 id: 'cleric',
60 title: 'CLERIC',
61 color: 'yellow',
62 ranks: ['Acolyte', 'Priest', 'Bishop', 'Saint', 'Oracle'],
63 acting: '✚ praying',
64 resting: '✧ tending the shrine',
65 failures: 'prayers unanswered',
66 },
67 artificer: {
68 id: 'artificer',
69 title: 'ARTIFICER',
70 color: '#e0874a',
71 ranks: ['Tinker', 'Mechanic', 'Engineer', 'Inventor', 'Grand Artificer'],
72 acting: '⚙ tinkering',
73 resting: '⚙ oiling the gears',
74 failures: 'gadgets misfired',
75 },
76}
77
78export const CLASS_IDS = Object.keys(HERO_CLASS_FRAMES) as HeroClassId[]
79
80export const isClassId = (value: string): value is HeroClassId => CLASS_IDS.includes(value as HeroClassId)
81
82export const classOf = (id: string | undefined): HeroClass => CLASSES[id !== undefined && isClassId(id) ? id : 'wizard']
83
84// A class at random; `roll` in [0, 1) as Math.random gives, `not` to roll something else.
85export const rollClass = (roll: number, not?: string): HeroClassId => {
86 const pool = CLASS_IDS.filter(id => id !== not)
87 return pool[Math.min(pool.length - 1, Math.floor(roll * pool.length))] ?? 'wizard'
88}
89
90const RANK_LEVELS = [1, 3, 5, 7, 10] as const
91
92export const rankOf = (hero: HeroClass, level: number): string => {
93 let rank = hero.ranks[0]
94 RANK_LEVELS.forEach((from, at) => {
95 if (level >= from) rank = hero.ranks[at] ?? rank
96 })
97 return rank
98}
99hooks/map.tsx 279 lines1import type { ElementTable } from 'claude-code'
2
3import type { GitMap, TouchedFile } from '../types'
4import { GRAPH_LIMIT, isLinear, parseLog } from './git'
5import type { GraphRow } from './git'
6import { layoutLanes } from './lanes'
7import type { LaneRow } from './lanes'
8import { oneLine } from './util'
9import type { Item } from './window'
10
11const LANE_COLORS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red']
12const HASH_COLUMNS = 8
13// The age column on the right: `11mo` and a space.
14const AGE_COLUMNS = 5
15// The trail's glyph, the marker and a space before the hash.
16const TRAIL_COLUMNS = 4
17// '🎒' at the end of a commit still to be pushed.
18const LOOT_COLUMNS = 3
19
20// Edited files first, then the most read.
21export const exploredOrder = (files: readonly TouchedFile[]): TouchedFile[] =>
22 files.slice().sort((a, b) => b.edits - a.edits || b.reads - a.reads || a.path.localeCompare(b.path))
23
24export const mapSubtitle = (map: GitMap): string => {
25 if (!map.isRepo) return 'uncharted'
26 const rows = parseLog(map.graph, map.remotes)
27 return `@ ${map.branch || 'detached'} · ${isLinear(rows) ? 'one road' : 'branching paths'}`
28}
29
30export type Stop =
31 | { kind: 'camp'; dirty: number }
32 | { kind: 'town'; remotes: string[]; isHere: boolean }
33 | { kind: 'step'; row: GraphRow; place: 'here' | 'carried' | 'road' }
34 | { kind: 'end'; isCut: boolean }
35
36// A linear history as a road walked from the newest commit back: the camp of unsaved work, you at HEAD,
37// the commits carried but not yet delivered, the remote's town where they would go, then the road behind.
38export const trailStops = (rows: readonly GraphRow[], dirty: number): Stop[] => {
39 const stops: Stop[] = []
40 if (dirty > 0) stops.push({ kind: 'camp', dirty })
41 const townAt = rows.findIndex(row => row.remotes.length > 0)
42 rows.forEach((row, index) => {
43 if (index === townAt) stops.push({ kind: 'town', remotes: row.remotes, isHere: row.isHead })
44 const place = row.isHead ? 'here' : townAt === -1 || index < townAt ? 'carried' : 'road'
45 stops.push({ kind: 'step', row, place })
46 })
47 stops.push({ kind: 'end', isCut: rows.length >= GRAPH_LIMIT })
48 return stops
49}
50
51// A row's lanes, each glyph in its lane's colour; the HEAD commit is the player.
52const Lanes = (ui: ElementTable, lanes: LaneRow, width: number, isHead: boolean) => {
53 const { Text } = ui
54 const glyphs = lanes.cells.map((cell, index) => {
55 const color = LANE_COLORS[cell.color % LANE_COLORS.length] ?? 'cyan'
56 if (cell.isCommit === true) {
57 return isHead ? (
58 <Text key={`c-${index}`} bold color="yellow">@</Text>
59 ) : (
60 <Text key={`c-${index}`} color={color}>●</Text>
61 )
62 }
63 return (
64 <Text key={`c-${index}`} color={color}>
65 {cell.glyph}
66 </Text>
67 )
68 })
69 // Narrower rows padded out so every hash starts in the same column.
70 const pad = width - lanes.cells.length
71 return pad > 0 ? [...glyphs, <Text key="pad">{' '.repeat(pad)}</Text>] : glyphs
72}
73
74const row = (key: string, node: Item['node']): Item => ({ key, rows: 1, node })
75
76// Branches checked out in another outpost (worktree), to the outpost's name.
77export type Camps = Readonly<Record<string, string>>
78
79// The outposts camped on a commit's branches: `refs` as git decorates it (`HEAD -> main, feat/x`).
80export const campsOn = (refs: string, camps: Camps): string[] =>
81 refs
82 .split(',')
83 .map(ref => ref.trim().replace(/^HEAD -> /, ''))
84 .flatMap(ref => (camps[ref] === undefined ? [] : [camps[ref]]))
85
86const Flags = (ui: ElementTable, names: readonly string[]) => {
87 const { Text } = ui
88 return names.length === 0 ? null : <Text color="cyan"> ⚑{names.join(' ⚑')}</Text>
89}
90
91// The camp of unsaved work, with the button that opens the bag of changes.
92const campRow = (ui: ElementTable, dirty: number, lead: string, openBag: () => void): Item => {
93 const { Box, Text, Button } = ui
94 return row(
95 'camp',
96 <Box height={1}>
97 <Box flexGrow={1}>
98 <Text wrap="truncate">
99 <Text color="yellow">{lead}</Text>
100 <Text color="red">⛺</Text>
101 <Text bold color="red"> camp </Text>
102 <Text dimColor>· {dirty} unsaved</Text>
103 </Text>
104 </Box>
105 <Button key="open-bag" label="🎒 open the bag ▸" plain onPress={openBag} />
106 </Box>,
107 )
108}
109
110function trailItems(ui: ElementTable, rows: readonly GraphRow[], dirty: number, width: number, actions: MapActions): Item[] {
111 const { openBag, camps } = actions
112 const { Box, Text } = ui
113 const subjectWidth = (extra: number) => Math.max(4, width - TRAIL_COLUMNS - HASH_COLUMNS - AGE_COLUMNS - extra)
114 return trailStops(rows, dirty).map((stop, index) => {
115 switch (stop.kind) {
116 case 'camp':
117 return campRow(ui, stop.dirty, '╭ ', openBag)
118 case 'town':
119 return row(
120 'town',
121 <Text wrap="truncate">
122 <Text color="cyan">╞═</Text>
123 <Text>🏰</Text>
124 <Text bold color="cyan"> {stop.remotes.join(', ')} </Text>
125 <Text color="cyan" dimColor>
126 {'═'.repeat(Math.max(0, width - 8 - stop.remotes.join(', ').length - (stop.isHere ? 11 : 6)))}
127 </Text>
128 <Text color={stop.isHere ? 'green' : 'cyan'}>{stop.isHere ? ' you\'re here' : ' town'}</Text>
129 </Text>,
130 )
131 case 'end':
132 return row(
133 'end',
134 <Text color="gray" dimColor>
135 {stop.isCut ? `╵ ⋯ the road goes on beyond ${GRAPH_LIMIT} steps` : '╵ ⚐ where it all began'}
136 </Text>,
137 )
138 case 'step': {
139 const { row: commit, place } = stop
140 const isFirst = index === 0
141 // One solid line down the column: colour, not the glyph, tells the carried steps from the road behind.
142 const trail = isFirst ? '╭' : '│'
143 const marker =
144 place === 'here' ? (
145 <Text bold color="yellow">@</Text>
146 ) : place === 'carried' ? (
147 <Text color="yellow">◆</Text>
148 ) : (
149 <Text color="gray">◇</Text>
150 )
151 const loot = place === 'carried'
152 const flags = campsOn(commit.refs, camps)
153 return row(
154 `s-${commit.hash}`,
155 <Box height={1}>
156 <Box flexGrow={1}>
157 <Text wrap="truncate">
158 <Text color={place === 'road' ? 'gray' : 'yellow'}>{trail} </Text>
159 {marker}
160 <Text> </Text>
161 <Text color="yellow" dimColor>{commit.hash.slice(0, 7)} </Text>
162 <Text bold={place === 'here'} dimColor={place === 'road'}>
163 {oneLine(commit.subject, subjectWidth((loot ? LOOT_COLUMNS : 0) + flags.join(' ').length + flags.length * 2))}
164 </Text>
165 {Flags(ui, flags)}
166 </Text>
167 </Box>
168 {loot && <Text> 🎒</Text>}
169 <Box width={AGE_COLUMNS} justifyContent="flex-end">
170 <Text dimColor>{commit.age}</Text>
171 </Box>
172 </Box>,
173 )
174 }
175 }
176 })
177}
178
179function branchItems(ui: ElementTable, rows: readonly GraphRow[], width: number, camps: Camps): Item[] {
180 const { Box, Text } = ui
181 const lanes = layoutLanes(rows)
182 // Every subject starts in the same column, past the widest row of lanes.
183 const graphWidth = Math.max(...lanes.map(lane => lane.cells.length))
184 return rows.map((line, index) => {
185 const refs = line.refs === '' ? '' : `(${line.refs})`
186 const flags = campsOn(line.refs, camps)
187 // The refs, their space, and each ⚑flag with its space.
188 const extras = (refs === '' ? 0 : refs.length + 1) + flags.reduce((sum, name) => sum + name.length + 2, 0)
189 const subjectWidth = Math.max(4, width - graphWidth - HASH_COLUMNS - extras - AGE_COLUMNS)
190 return row(
191 `g-${line.hash}`,
192 <Box height={1}>
193 <Box flexGrow={1}>
194 <Text wrap="truncate">
195 {Lanes(ui, lanes[index] as LaneRow, graphWidth, line.isHead)}
196 <Text color="yellow" dimColor>{line.hash.slice(0, 7)} </Text>
197 {refs !== '' && <Text color="green">{refs}</Text>}
198 {Flags(ui, flags)}
199 {refs !== '' && <Text> </Text>}
200 <Text bold={line.isHead}>{oneLine(line.subject, subjectWidth)}</Text>
201 </Text>
202 </Box>
203 <Box width={AGE_COLUMNS} justifyContent="flex-end">
204 <Text dimColor>{line.age}</Text>
205 </Box>
206 </Box>,
207 )
208 })
209}
210
211// Where the party stands, the history as a road (one line) or a lane graph (branches), then the places explored.
212export type MapActions = {
213 openBag: () => void
214 openOutposts: () => void
215 // How many outposts (worktrees) the repository has, the session's own included.
216 outposts: number
217 camps: Camps
218}
219
220export function mapItems(ui: ElementTable, map: GitMap, touched: readonly TouchedFile[], width: number, actions: MapActions): Item[] {
221 const { Box, Text, Button } = ui
222 const { openBag } = actions
223 if (!map.isRepo) return [row('none', <Text dimColor>Uncharted land: this folder is not a git repository.</Text>)]
224
225 const rows = parseLog(map.graph, map.remotes)
226 const items: Item[] = [
227 row(
228 'where',
229 <Box height={1}>
230 <Box flexGrow={1}>
231 <Text wrap="truncate">
232 <Text bold color="yellow">@ {map.branch || 'detached'}</Text>
233 {map.ahead > 0 && <Text color="green"> ↑{map.ahead} to deliver</Text>}
234 {map.behind > 0 && <Text color="red"> ↓{map.behind} behind</Text>}
235 {map.dirty > 0 ? (
236 <Text color="red"> ⚠ {map.dirty} unsaved</Text>
237 ) : (
238 <Text color="green" dimColor>
239 {' ✓ all saved'}
240 </Text>
241 )}
242 </Text>
243 </Box>
244 {actions.outposts > 1 && (
245 <Button key="open-outposts" label={`🏕 ${actions.outposts} outposts ▸`} plain onPress={actions.openOutposts} />
246 )}
247 </Box>,
248 ),
249 row('gap-top', <Text> </Text>),
250 ...(isLinear(rows)
251 ? trailItems(ui, rows, map.dirty, width, actions)
252 : [...(map.dirty > 0 ? [campRow(ui, map.dirty, '', openBag)] : []), ...branchItems(ui, rows, width, actions.camps)]),
253 row('gap', <Text> </Text>),
254 row(
255 'explored',
256 <Text bold color="cyan">
257 ✧ Explored <Text dimColor>{touched.length} places</Text>
258 </Text>,
259 ),
260 ]
261 if (touched.length === 0) items.push(row('nothing', <Text dimColor>Nothing explored yet.</Text>))
262 for (const file of exploredOrder(touched)) {
263 items.push(
264 row(
265 `f-${file.path}`,
266 <Text wrap="truncate">
267 {file.edits > 0 ? <Text color="yellow">◆ </Text> : <Text color="cyan">◇ </Text>}
268 <Text>{oneLine(file.path, width - 14)}</Text>
269 <Text dimColor>
270 {file.edits > 0 ? ` ✎${file.edits}` : ''}
271 {file.reads > 0 ? ` ◈${file.reads}` : ''}
272 </Text>
273 </Text>,
274 ),
275 )
276 }
277 return items
278}
279hooks/menu.ts 95 lines1import type { Tab } from '../types'
2
3export const ICON_COLUMNS = 8
4// Six pixels tall: three terminal rows of half blocks.
5export const ICON_ROWS = 3
6
7export type MenuEntry = {
8 id: Tab
9 // At most five letters, so the label fits a ten-column slot with room to spare.
10 label: string
11 title: string
12 color: string
13 pixels: readonly string[]
14}
15
16const PALETTE: Record<string, number> = {
17 V: 0x8c5ad2, v: 0x5a3296, Y: 0xffd23c, y: 0xb48c1e, W: 0xfff0dc,
18 G: 0x5ad27a, g: 0x2d8c4b, K: 0x191e23,
19 T: 0xf0dcaa, t: 0xa0643c, R: 0xff5a5a,
20 B: 0x5aa9ff, b: 0x2a5fb4,
21}
22
23export const MENU: readonly MenuEntry[] = [
24 {
25 id: 'spells',
26 label: 'Spell',
27 title: '📖 SPELL BOOK',
28 color: 'magenta',
29 pixels: ['.VVVVVV.', 'VWYYYYVv', 'VWYVVYVv', 'VWYYYYVv', 'VWVVVVVv', '.vvvvvv.'],
30 },
31 {
32 id: 'pets',
33 label: 'Party',
34 title: '🐾 PARTY',
35 color: 'green',
36 pixels: ['........', '..GGGG..', '.GWGGGG.', 'GGKGGKGG', 'GGGGGGGG', '.gggggg.'],
37 },
38 {
39 id: 'map',
40 label: 'Map',
41 title: '🧭 WORLD MAP',
42 color: 'cyan',
43 pixels: ['TTTTTTTT', 'TtTTTRTR', 'TTtTTTRT', 'TTTtTRTR', 'TTTTtTTT', 'tttttttt'],
44 },
45 {
46 id: 'skills',
47 label: 'Skill',
48 title: '📜 SKILLS',
49 color: 'blue',
50 pixels: ['...YY...', '...YY...', 'YYYBBYYY', '.YYYYYY.', '.YY..YY.', 'Y......Y'],
51 },
52 {
53 id: 'jobs',
54 label: 'Jobs',
55 title: '⚙ JOBS',
56 color: 'yellow',
57 pixels: ['yyyyyyyy', 'yKKKKKKy', 'yKGKKKKy', 'yKKGKKKy', 'yKGKWWKy', 'yyyyyyyy'],
58 },
59]
60
61export const entryOf = (id: string): MenuEntry => MENU.find(entry => entry.id === id) ?? MENU[0]!
62
63export const isTab = (value: string): value is Tab => MENU.some(entry => entry.id === value)
64
65// Unselected slots show their icon at this share of its brightness.
66const DIM = 0.4
67
68const dim = (rgb: number): number => {
69 const channel = (shift: number) => Math.round(((rgb >> shift) & 0xff) * DIM)
70 return (channel(16) << 16) | (channel(8) << 8) | channel(0)
71}
72
73const DIM_PALETTE = Object.fromEntries(Object.entries(PALETTE).map(([key, rgb]) => [key, dim(rgb)]))
74
75// One terminal cell of an icon drawn as text: the glyph, its colour and the colour behind it.
76export type TextCell = [glyph: string, color: string | null, background: string | null]
77
78const hex = (rgb: number | undefined): string | null =>
79 rgb === undefined ? null : `#${rgb.toString(16).padStart(6, '0')}`
80
81// The icon as rows of half-block text cells, for a Client, which draws no Raster.
82export const iconText = (entry: MenuEntry, isSelected: boolean): TextCell[][] => {
83 const palette = isSelected ? PALETTE : DIM_PALETTE
84 const colorAt = (x: number, y: number) => palette[entry.pixels[y]?.[x] ?? '.']
85 return Array.from({ length: ICON_ROWS }, (_, row) =>
86 Array.from({ length: ICON_COLUMNS }, (_, col): TextCell => {
87 const top = colorAt(col, row * 2)
88 const bottom = colorAt(col, row * 2 + 1)
89 if (top === undefined && bottom === undefined) return [' ', null, null]
90 if (top === undefined) return ['▄', hex(bottom), null]
91 return ['▀', hex(top), hex(bottom)]
92 }),
93 )
94}
95