SLOPSHOPPER

rpg-hud

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

newpanerowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rpg-hud
│ ┃ Adventure ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╔═══════════════════════════════════════════ │ rpg-hud │ │ ┃ ║ ▄▄▄▄ CLAUDE THE ARTIFICER ⏺ Read(src/auth.ts) │ ⚔ Today Claude plays the artificer: Tinker │ │ ┃ ║ ▄▀▀▀▀▀▀▄ Tinker · 9 casts ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ ║ ▀▀▀▀▀▀▀▀▀▀ HP ██████████████░░░ ⏺ Update(src/auth.ts) ╭───────────────────────────╮ │ ┃ ║ ▀▀▀▀▀▀▀▀▀▀ MP █████████░░░░░░░░ ⎿ Added 2 lines, removed 1 line │ rpg-hud │ │ ┃ ║ ▄▄ ▄▄ ▀▀▀▀▀▀▀▀▀▀ XP ████████████████░ ⏺ Bash(bun test) │ ☠ Bug Hydra appears! HP 1 │ │ ┃ ║ ▀▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▄ ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ ⎿ 3 pass, 1 fail ╰───────────────────────────╯ │ ┃ ║ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀ ⚡×3 best 5 │ ┃ ║ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ⚙ 0 jobs ♣ 0 in par ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ║ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ⚙ oiling the gears │ ┃ ║ ▀▀▀▀ ▀▀▀▀ ☠ Bug Hydra ██████ 1 ✻ Worked for 42s · done 4:20 PM │ ┃ ╚═══════════════════════════════════════════ │ ┃ ╭─────────────────────────────────╮╭──────── › /hud │ ┃ │▣ client module ./menu-client.tsx││▣ client ⎿ rpg-hud: Adventure HUD opened. │ ┃ │1: SPELL ││2: PARTY │ ┃ │ ││ │ ┃ │ ││ │ ┃ ╰─────────────────────────────────╯╰──────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 🧭 WORLD MAP @ detached · branching paths │ ┃ │ ────────────────────────────────────────── │ ┃ │ @ detached ✓ all saved │ ┃ │ │ ┃ │ │ ┃ │ ✧ Explored 3 places ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Adventure
╔══════════════════════════════════════════════════════╗ ║ ▄▄▄▄ CLAUDE THE ARTIFICER Lv.1 ? ║ ║ ▄▀▀▀▀▀▀▄ Tinker · 9 casts ║ ║ ▀▀▀▀▀▀▀▀▀▀ HP ██████████████░░░░ 4/5 ║ ║ ▀▀▀▀▀▀▀▀▀▀ MP █████████░░░░░░░░░ 51% ║ ║ ▄▄ ▄▄ ▀▀▀▀▀▀▀▀▀▀ XP ████████████████░░ 9/10 ║ ║ ▀▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▄ ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ ║ ║ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀ ⚡×3 best 5 ⛁ 0.42 ║ ║ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ⚙ 0 jobs ♣ 0 in party ║ ║ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ⚙ oiling the gears ║ ║ ▀▀▀▀ ▀▀▀▀ ☠ Bug Hydra ██████ 1/1 ⚔ fix ║ ╚══════════════════════════════════════════════════════╝ ╭─────────────────────────────────╮╭──────────────────────── │▣ client module ./menu-client.tsx││▣ client module ./menu-c │1: SPELL ││2: PARTY │ ││ │ ││ ╰─────────────────────────────────╯╰──────────────────────── ╭──────────────────────────────────────────────────────╮ │ 🧭 WORLD MAP @ detached · branching paths │ │ ──────────────────────────────────────────────────── │ │ @ detached ✓ all saved │ │ │ │ │ │ ✧ Explored 3 places │ │ ◆ src/auth.ts ✎1 ◈1 │ │ ◆ src/audit.ts ✎1 │ │ ◆ src/cache.ts ✎1 │ │ │ ╰──────────────────────────────────────────────────────╯
README

Dotfiles — GNU Stow 管理

使用 GNU Stow 管理個人設定檔,方便在不同機器上快速部署一致的開發環境。

套件總覽

套件說明安裝後的路徑
zshZsh shell 設定~/.zshrc、~/zshrc.d/ → config/zsh/zshrc.d/
tmuxtmux 終端多工器設定~/.tmux.conf
ghosttyGhostty 終端模擬器設定~/.config/ghostty/config
cmuxCmux 終端機設定~/.config/cmux/
claudeClaude Code 系統提示 + output styles + hooks 範本 + RPG status line~/.claude/CLAUDE.md、~/.claude/output-styles/、~/.claude/rpg-statusline.sh
claude-modsClaude Code mods(hooks plugin:pane、band 等)~/.claude/mods/<mod>/ → 目錄 symlink
codexCodex CLI 系統提示~/.codex/AGENTS.md
hammerspoonHammerspoon macOS 自動化~/.hammerspoon/
ripgrepripgrep 搜尋工具設定~/.ripgreprc
git全域 git ignore(XDG 路徑,免設定 git config)~/.config/git/ignore

config/shared/skills/ 為共享 skills 的單一來源,不是 stow 套件。stow-wrap.sh 部署 AI CLI 套件時會自動將其 symlink 到每個工具的 ~/.<tool>/skills/。


全新電腦安裝步驟

1. 安裝必要工具

# 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

2. 安裝 nvm(Node Version Manager)

依照官方安裝腳本安裝:https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script

3. 安裝 Oh-My-Zsh 與 plugins

# 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)。

4. 安裝 fzf-git 整合(選用)

git clone https://github.com/junegunn/fzf-git.sh.git ~/fzf-git.sh

5. 安裝 Nerd Font

brew install --cask font-jetbrains-mono-nerd-font

6. macOS 系統設定

# 關閉長按字元選單,改為按住重複輸入(vim 操作必要)
defaults write -g ApplePressAndHoldEnabled -bool false

需登出再登入或重新啟動 app 才會生效。

7. Clone 並部署

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

8. 設定 Powerlevel10k

p10k configure

~/.p10k.zsh 由 p10k 精靈產生,不納入版控。專案中的 36-p10k-theme.zsh 會自動覆寫 Gruvbox 色彩主題。

9. 設定 AI CLI 工具的 cmux 通知

Codex 的 hooks.json 已由 stow 部署,按以下步驟完成設定:

  • Codex CLI:在 ~/.codex/config.toml 啟用 feature flag: ``toml [features] codex_hooks = true ``
  • Claude Code:執行 ./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 預覽合併動作。

10. 建立機器專屬設定(選用)

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

11. 驗證安裝

exec zsh
ls -la ~/.zshrc ~/.tmux.conf
alias
which fzf eza

stow-wrap.sh 使用說明

./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 完全隱形。

Zsh 設定架構

~/.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 的初始化。

AI CLI 工具管理

系統提示

兩個工具各自維護獨立的系統提示檔,皆透過 stow 管理。目前內容相同,可依不同 LLM 特性分別微調。

Skills(共享技能)

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 通知 Hooks

由 cmux 自行處理,dotfiles 不再維護通知腳本。

工具安裝方式產生的檔案
Claude Codecmux Claude wrapper 自動注入,無須設定無(wrapper 動態注入)
其他 agentcmux 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。

Claude Code RPG status line(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 秒重跑讓換行跟上新寬度
  • 全部資料取自 stdin JSON,不讀 credentials、不連網;turn、token、變更檔數交給 slime-band,不重複顯示
  • 停用:刪除 ~/.claude/settings.json 的 statusLine,並從 settings.json.example 移除,否則下次 stow-wrap.sh claude 會加回
Claude Code Telegram 完成通知(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 Code Output Styles

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

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 刪除一個檔案後,其他機器上仍會留下:

  • stow 建立的 symlink(pull 後變成 broken symlink)
  • sync-ai-cli-settings.sh 合併進 live ~/.claude/settings.json 的項目(該腳本只增不減)
  • 各工具自己快取的狀態(例如 Codex 的 [hooks.state] trusted hash)

因此凡是「下架」性質的變更,都在 scripts/ 下附一支 migrate-<YYYYMMDD>-<描述>.sh,記錄該次變更需要在其他機器上執行的清理步驟。慣例:

  • 支援 --dry-run,預覽時不得寫入任何檔案
  • 冪等:在已清理或全新的機器上執行應為 no-op
  • 修改前先備份(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 就無法再運作。

新增套件

  1. 在 config/ 下建立新目錄,結構反映 $HOME 下的相對路徑
  2. 將設定檔放入對應位置
  3. 若目標路徑或其上層目錄可能被 dotfiles 以外的程式寫入,將套件名加入 scripts/stow-wrap.sh 的 NO_FOLDING_PKGS(原因見上方「--no-folding 為什麼必要」)
  4. 執行 ./scripts/stow-wrap.sh <套件名> 部署
  5. 部署後確認目標目錄本身仍是真實目錄而非 symlink:ls -ld ~/<目標目錄>
  6. 更新此 README 的套件總覽表格
Source 28 files
hooks/register.tsx 1496 lines
1import { 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 + 1
hooks/anim.ts 51 lines
1import 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}
51
hooks/boss.ts 191 lines
1import 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}
191
hooks/bag-client.tsx 87 lines
1import 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
87
hooks/git.ts 155 lines
1import 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}
155
hooks/outposts-client.tsx 93 lines
1import 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
93
hooks/inspect.tsx 100 lines
1import 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}
100
hooks/edit-card.tsx 102 lines
1import 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}
102
hooks/hero.tsx 225 lines
1import 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}
225
hooks/classes.ts 99 lines
1import { 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}
99
hooks/map.tsx 279 lines
1import 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}
279
hooks/menu.ts 95 lines
1import 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