SLOPSHOPPER

slime-band

A slime hops above the prompt while Claude works, with the session's key facts beside it

newbandguardpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slime-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ✎ changed ↻ turn ◷ last turn ↓ in ↑ out ▄▄▄▄ ▄▄▄ ▄ ▄ ▄ ▄▄▄ ▄▄ ▄▄▄ ▄▄▄ ▄ ▄ ▄ ▄▄▄ ▄ ▄▀▀▀▀▀▀▀▀▄ ▄▄▀ ▀▀ ▀▄▀ ▄▄▀ ▀▄ ▀▄▀ ▄▄▀ ▀▀ ▀▄▀ ▀▀ ▀▄▄ ▀▄▀ ▀▀▀▀▀▀▀▀▀▀ ▀▄▄ ▄▀▄ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✎ changed ↻ turn ◷ last turn ↓ in ↑ out ▄▄▄▄ ▄▄▄ ▄ ▄ ▄ ▄▄▄ ▄▄ ▄▄▄ ▄▄▄ ▄ ▄ ▄ ▄▄▄ ▄ ▄▀▀▀▀▀▀▀▀▄ ▄▄▀ ▀▀ ▀▄▀ ▄▄▀ ▀▄ ▀▄▀ ▄▄▀ ▀▀ ▀▄▀ ▀▀ ▀▄▄ ▀▄▀ ▀▀▀▀▀▀▀▀▀▀ ▀▄▄ ▄▀▄ ⟨Claude Code's own drawing⟩
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 5 files
hooks/register.tsx 444 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { BandInfo, ContextInfo } from '../types'
5import { CLOSE, FRAME, CTX_LABEL, OPEN, TITLE, gaugeLine, innerWidth, layoutGauge, legendLines, topFill } from './context-bar'
6import type { ContextGauge } from './context-bar'
7import { SLIME_FRAMES, SLIME_PALETTE, SLIME_ROWS, SLIME_WIDTH } from './slime-sprite'
8import type { SlimePose } from './slime-sprite'
9import { formatTokens, layoutHud } from './hud'
10import type { HudFact, HudPixel } from './hud'
11
12const info = atom({ plugin: 'slime-band', key: 'info' } as const, {
13  branch: '',
14  dirty: 0,
15  lastMs: 0,
16  inTokens: 0,
17  outTokens: 0,
18})
19
20const EMPTY_CONTEXT: ContextInfo = { slices: [], total: 0, max: 0, percent: 0 }
21const contextInfo = atom({ plugin: 'slime-band', key: 'context' } as const, EMPTY_CONTEXT)
22
23const FRAME_MS = 80
24// Ticks each hop frame (squash, stretch) stays up.
25const RUN_TICKS = 3
26// How long the slime sits after a turn before it naps.
27export const NAP_AFTER_MS = 20_000
28// Ticks each 'z Z' frame of the nap stays up.
29const SNORE_TICKS = 10
30
31const UPPER_HALF = 0x2580
32const LOWER_HALF = 0x2584
33const SPACE = 0x20
34const DEFAULT_COLOR = 0x01000000
35const SNORE_COLOR = 0xc8d2ff
36const ALERT_COLOR = 0xffd23c
37// Ticks the '!' stays lit, then dark, while the slime waits on the person.
38const ALERT_TICKS = 5
39
40// The track: the slime's six pixel rows, then a grass row and a soil row.
41export const TRACK_ROWS = SLIME_ROWS + 1
42const GRASS_Y = SLIME_ROWS * 2
43const GRASS = [0x4caf50, 0x66bb6a, 0x4caf50, 0x81c784]
44const SOIL = [0x8d5a2b, 0x8d5a2b, 0x6d4220, 0x8d5a2b, 0x7a4b24]
45
46// A fixed pattern by column, so the floor stays still while the slime moves.
47export const floorAt = (col: number, y: number): number | undefined => {
48  if (y === GRASS_Y) return GRASS[col % GRASS.length]
49  if (y === GRASS_Y + 1) return SOIL[(col * 3) % SOIL.length]
50  return undefined
51}
52
53// Waiting on the person (a permission prompt, a question) it sits up, never hopping or napping.
54export const slimePose = (isWorking: boolean, idleMs: number, tick: number, isWaiting = false): SlimePose => {
55  if (isWaiting) return 'sit'
56  if (isWorking) return Math.floor(tick / RUN_TICKS) % 2 === 0 ? 'run1' : 'run2'
57  return idleMs >= NAP_AFTER_MS ? 'sleep' : 'sit'
58}
59
60// One step along the track, turning round at either end.
61export const stepSlime = (x: number, dir: 1 | -1, trackWidth: number) => {
62  const last = Math.max(0, trackWidth - SLIME_WIDTH)
63  const next = x + dir
64  if (next < 0) return { x: Math.min(1, last), dir: 1 as const }
65  if (next > last) return { x: Math.max(0, last - 1), dir: -1 as const }
66  return { x: next, dir }
67}
68
69const pixelAt = (pose: SlimePose, row: number, col: number, dir: 1 | -1): number | undefined => {
70  const line = SLIME_FRAMES[pose][row] ?? ''
71  const ch = line[dir === 1 ? col : SLIME_WIDTH - 1 - col] ?? '.'
72  return SLIME_PALETTE[ch]
73}
74
75// The whole track as Raster cells: the slime at `x`, the HUD's big numbers,
76// the rest see-through.
77export const composeTrack = (
78  trackWidth: number,
79  slime: { x: number; dir: 1 | -1; pose: SlimePose; snore: number; alert?: boolean },
80  hud: readonly HudPixel[] = [],
81): string => {
82  const lit = new Map(hud.map(pixel => [pixel.y * trackWidth + pixel.x, pixel.rgb]))
83  const colorAt = (col: number, y: number): number | undefined => {
84    const floor = floorAt(col, y)
85    if (floor !== undefined) return floor
86    const spriteCol = col - slime.x
87    if (spriteCol >= 0 && spriteCol < SLIME_WIDTH) {
88      const sprite = pixelAt(slime.pose, y, spriteCol, slime.dir)
89      if (sprite !== undefined) return sprite
90    }
91    return lit.get(y * trackWidth + col)
92  }
93  const words = new Uint32Array(trackWidth * TRACK_ROWS * 3)
94  for (let row = 0; row < TRACK_ROWS; row += 1) {
95    for (let col = 0; col < trackWidth; col += 1) {
96      const at = (row * trackWidth + col) * 3
97      const top = colorAt(col, row * 2)
98      const bottom = colorAt(col, row * 2 + 1)
99      if (top === undefined && bottom === undefined) {
100        words.set([SPACE, DEFAULT_COLOR, DEFAULT_COLOR], at)
101      } else if (top === undefined) {
102        // A default foreground is the text colour, not see-through: paint the lower half.
103        words.set([LOWER_HALF, bottom ?? DEFAULT_COLOR, DEFAULT_COLOR], at)
104      } else {
105        words.set([UPPER_HALF, top, bottom ?? DEFAULT_COLOR], at)
106      }
107    }
108  }
109  // While napping, a 'z' then a 'Z' drift up beside its head.
110  if (slime.pose === 'sleep' && slime.snore > 0) {
111    const col = Math.min(trackWidth - 1, slime.x + (slime.dir === 1 ? SLIME_WIDTH - 2 : 1))
112    const glyph = slime.snore === 1 ? 0x7a : 0x5a
113    words.set([glyph, SNORE_COLOR, DEFAULT_COLOR], (0 * trackWidth + col) * 3)
114  }
115  // Waiting on the person, a '!' blinks just ahead of its face.
116  if (slime.alert === true) {
117    const col = Math.max(0, Math.min(trackWidth - 1, slime.dir === 1 ? slime.x + SLIME_WIDTH : slime.x - 1))
118    words.set([0x21, ALERT_COLOR, DEFAULT_COLOR], (0 * trackWidth + col) * 3)
119  }
120  return toBase64(new Uint8Array(words.buffer))
121}
122
123// btoa takes a binary string; chunks keep fromCharCode's argument list short.
124const toBase64 = (bytes: Uint8Array): string => {
125  let binary = ''
126  for (let at = 0; at < bytes.length; at += 0x8000) {
127    binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000))
128  }
129  return btoa(binary)
130}
131
132export { formatTokens }
133
134// Module state, so a reload starts the slime over.
135const slime = {
136  x: 0,
137  dir: 1 as 1 | -1,
138  tick: 0,
139  isWorking: false,
140  idleSince: 0,
141  // The tool a permission prompt asks about, or 'AskUserQuestion', while the session waits on the person.
142  waitingFor: undefined as string | undefined,
143  // The loop that tool runs in: a subagent's id, undefined for the main loop.
144  waitingAgent: undefined as string | undefined,
145  trackWidth: 0,
146  // The columns left of the HUD, where the slime hops.
147  areaWidth: 0,
148  hud: [] as HudPixel[],
149  requestId: '',
150  ticker: undefined as Timer | undefined,
151  shown: '',
152}
153
154async function paint($: EngineInterface) {
155  if (slime.trackWidth === 0) return
156  const now = await $.clock.now()
157  slime.tick += 1
158  const isWaiting = slime.waitingFor !== undefined
159  const pose = slimePose(slime.isWorking, now - slime.idleSince, slime.tick, isWaiting)
160  const alert = isWaiting && Math.floor(slime.tick / ALERT_TICKS) % 2 === 0
161  // The slime only travels while it is in the air.
162  if (pose === 'run2') {
163    const moved = stepSlime(slime.x, slime.dir, slime.areaWidth)
164    slime.x = moved.x
165    slime.dir = moved.dir
166  }
167  const snore = pose === 'sleep' ? Math.floor(slime.tick / SNORE_TICKS) % 3 : 0
168  const cells = composeTrack(slime.trackWidth, { x: slime.x, dir: slime.dir, pose, snore, alert }, slime.hud)
169  if (cells === slime.shown) return
170
171  // A refusal and a failed blit both mean the track is not on screen.
172  const done = await $.ui
173    .blit({ requestId: slime.requestId, key: 'track', cells, columns: slime.trackWidth, rows: TRACK_ROWS })
174    .catch(() => ({ deny: 'blit failed' }))
175  if (done.deny !== undefined) {
176    slime.ticker?.cancel()
177    slime.ticker = undefined
178    slime.shown = ''
179    return
180  }
181  slime.shown = cells
182}
183
184async function refreshGit($: EngineInterface) {
185  const cwd = await $.session.cwd()
186  const branch = await $.process.run(['git', 'branch', '--show-current'], { cwd, timeoutMs: 2000 })
187  const status = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: 2000 })
188  const isRepo = branch.exitCode === 0
189  await update($, info, value => ({
190    ...value,
191    branch: isRepo ? branch.stdout.trim() || '(detached)' : '',
192    dirty: isRepo ? status.stdout.split('\n').filter(line => line.trim() !== '').length : 0,
193  }))
194}
195
196// A local estimate, as /context's summary counts it: no token-count requests.
197async function refreshContext($: EngineInterface) {
198  const usage = await $.session.usage({ breakdown: 'summary' })
199  const breakdown = usage.context.breakdown
200  if (breakdown === undefined) return
201  await update($, contextInfo, () => ({
202    slices: breakdown.categories.map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
203    total: breakdown.totalTokens,
204    max: breakdown.rawMaxTokens,
205    percent: breakdown.percentage,
206    compactAt: breakdown.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined,
207  }))
208}
209
210// Only what the person's status line leaves out: it already shows the model,
211// the branch and a dirty mark, so the band adds the count of changed files.
212// The big numbers need RGB; the labels use the matching named colour.
213const FACT_COLORS = {
214  yellow: 0xffd23c,
215  cyan: 0x56c8dc,
216  magenta: 0xc88cff,
217  blue: 0x5aa9ff,
218  green: 0x5ad27a,
219} as const
220
221const fact = (
222  key: string,
223  icon: string,
224  value: string,
225  label: string,
226  color: keyof typeof FACT_COLORS,
227): HudFact => ({ key, icon, value, label, color, rgb: FACT_COLORS[color] })
228
229export const infoSegments = (value: BandInfo, turns: number): HudFact[] => {
230  const segments: HudFact[] = []
231  if (value.branch !== '' && value.dirty > 0) {
232    segments.push(fact('dirty', '✎', `${value.dirty}`, 'changed', 'yellow'))
233  }
234  segments.push(fact('turns', '↻', `${turns}`, turns === 1 ? 'turn' : 'turns', 'cyan'))
235  if (value.lastMs > 0) {
236    segments.push(
237      fact('last', '◷', `${Math.round(value.lastMs / 1000)}s`, 'last turn', 'magenta'),
238      fact('in', '↓', formatTokens(value.inTokens), 'in', 'blue'),
239      fact('out', '↑', formatTokens(value.outTokens), 'out', 'green'),
240    )
241  }
242  return segments
243}
244
245const SEPARATOR = '  │  '
246
247export const infoLine = (value: BandInfo, turns: number): string =>
248  infoSegments(value, turns)
249    .map(s => `${s.icon} ${s.value} ${s.label}`)
250    .join(SEPARATOR)
251
252type TextElement = ReturnType<EngineInterface['ui']['resolve']>['Text']
253
254const FRAME_COLOR = '#8a93c8'
255const TITLE_COLOR = '#c8d2ff'
256const CTX_COLOR = '#56c8dc'
257
258// An RPG status window under the floor: the title and mana status on the top
259// border, the CTX bar, then a legend of what fills it, all inside one frame.
260const renderGauge = (Text: TextElement, gauge: ContextGauge) => {
261  // Not `h`: that name is the JSX factory.
262  const { tl, tr, bl, br, h: dash, v } = FRAME
263  const inner = innerWidth(gauge)
264  const legend = legendLines(gauge)
265  return [
266    <Text key="ctx-top" wrap="truncate">
267      <Text color={FRAME_COLOR}>{`${tl}${dash} `}</Text>
268      <Text color={TITLE_COLOR} bold>{TITLE}</Text>
269      <Text color={FRAME_COLOR}>{` ${dash.repeat(topFill(gauge))} `}</Text>
270      <Text color={gauge.percentColor} bold inverse>{gauge.status}</Text>
271      <Text color={FRAME_COLOR}>{` ${dash}${tr}`}</Text>
272    </Text>,
273    <Text key="ctx-gauge" wrap="truncate">
274      <Text color={FRAME_COLOR}>{`${v} `}</Text>
275      <Text color={CTX_COLOR} bold>{CTX_LABEL}</Text>
276      <Text dimColor>{OPEN}</Text>
277      {gauge.runs.map((run, at) => (
278        <Text key={`run-${at}`} color={run.color} dimColor={run.dim}>
279          {run.glyph.repeat(run.length)}
280        </Text>
281      ))}
282      <Text dimColor>{CLOSE}</Text>
283      <Text color={gauge.percentColor} bold>{gauge.suffix}</Text>
284      <Text color={FRAME_COLOR}>{` ${v}`}</Text>
285    </Text>,
286    ...gauge.legendRows.map((row, at) => {
287      const used = [...(legend[at] ?? '')].length
288      return (
289        <Text key={`ctx-legend-${at}`} wrap="truncate">
290          <Text color={FRAME_COLOR}>{`${v} `}</Text>
291          {row.map((item, i) => (
292            <Text key={item.name}>
293              {i > 0 ? '  ' : ''}
294              <Text color={item.color}>■</Text>
295              <Text dimColor> {item.name} </Text>
296              <Text>{item.tokens}</Text>
297            </Text>
298          ))}
299          {' '.repeat(Math.max(0, inner - used))}
300          <Text color={FRAME_COLOR}>{` ${v}`}</Text>
301        </Text>
302      )
303    }),
304    <Text key="ctx-bottom" wrap="truncate" color={FRAME_COLOR}>
305      {`${bl}${dash.repeat(gauge.width - 2)}${br}`}
306    </Text>,
307  ]
308}
309
310export const register: Register = on => {
311  on('session.start', async ($, e, next) => {
312    slime.idleSince = await $.clock.now()
313    await refreshGit($).catch(() => undefined)
314
315    return next(e)
316  })
317
318  // The engine raises this whenever the window's fill moves: turns, compaction, /clear, a /model switch.
319  on('session.measure', async ($, e, next) => {
320    const result = await next(e)
321    if (e.changed.includes('context')) await refreshContext($).catch(() => undefined)
322    return result
323  })
324
325  // /clear and resume end this conversation without a session.start: drop its gauge until the next measure.
326  on('session.end', async ($, e, next) => {
327    await update($, contextInfo, () => EMPTY_CONTEXT)
328    return next(e)
329  })
330
331  // A permission prompt is about to ask the person; the slime waits with them until that tool is done. A hook
332  // beneath that decides leaves no prompt to wait on.
333  on('classic.PermissionRequest', async ($, e, next) => {
334    const result = await next(e)
335    if (result.decision === undefined) {
336      slime.waitingFor = e.tool_name
337      slime.waitingAgent = e.agent_id
338    }
339    return result
340  })
341
342  // A question to the person waits on them while it is open; a call of the tool a prompt asked about, in the
343  // same loop, ends the wait once it has run or been refused.
344  on('tool.call', async ($, e, next) => {
345    if (e.tool === 'AskUserQuestion') {
346      slime.waitingFor = e.tool
347      slime.waitingAgent = e.agentId
348    }
349    try {
350      return await next(e)
351    } finally {
352      if (slime.waitingFor === e.tool && slime.waitingAgent === e.agentId) slime.waitingFor = undefined
353    }
354  })
355
356  on('prompt.submit', async ($, e, next) => {
357    slime.isWorking = true
358    slime.waitingFor = undefined
359
360    return next(e)
361  })
362
363  on('turn.complete', async ($, e, next) => {
364    // A subagent's turn ends inside the main one; only the main turn rests the slime.
365    if (e.agentId === undefined) {
366      slime.isWorking = false
367      slime.waitingFor = undefined
368      slime.idleSince = await $.clock.now()
369      const usage = e.usage
370      await update($, info, value => ({
371        ...value,
372        lastMs: e.durationMs,
373        inTokens: usage ? usage.input_tokens + usage.cache_read_input_tokens : value.inTokens,
374        outTokens: usage ? usage.output_tokens : value.outTokens,
375      }))
376      await refreshGit($).catch(() => undefined)
377    }
378
379    return next(e)
380  })
381
382  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
383    if (e.props.hasSurvey) {
384      return next(e)
385    }
386    const engine = await next(e)
387    const value = await read($, info)
388    const context = await read($, contextInfo)
389
390    if (e.surface !== 'terminal') {
391      const gauge = layoutGauge(context, e.props.bodyColumns, false)
392      const { Box, Text } = $.ui.resolve(e)
393      return (
394        <Box flexDirection="column">
395          <Text wrap="truncate">◕‿◕ {infoLine(value, await $.session.turns())}</Text>
396          {gauge && <Text wrap="truncate">{gaugeLine(gauge)}</Text>}
397          {gauge && legendLines(gauge).map(line => <Text key={line}>{line}</Text>)}
398          {engine}
399        </Box>
400      )
401    }
402
403    const { Box, Text, Raster } = $.ui.resolve(e)
404    if (e.props.isWorking && !slime.isWorking) {
405      slime.isWorking = true
406    }
407    const trackWidth = e.props.bodyColumns
408    const gauge = layoutGauge(context, trackWidth)
409    const hud = layoutHud(infoSegments(value, await $.session.turns()), trackWidth)
410    slime.trackWidth = trackWidth
411    slime.areaWidth = hud.areaWidth
412    slime.hud = hud.pixels
413    slime.x = Math.min(slime.x, Math.max(0, hud.areaWidth - SLIME_WIDTH))
414    slime.requestId = e.requestId
415    slime.ticker ??= $.clock.every(FRAME_MS, () => void paint($))
416    const pose = slimePose(slime.isWorking, (await $.clock.now()) - slime.idleSince, slime.tick, slime.waitingFor !== undefined)
417    slime.shown = composeTrack(trackWidth, { x: slime.x, dir: slime.dir, pose, snore: 0 }, hud.pixels)
418
419    // Each label sits centred over its big number.
420    let cursor = 0
421    const labelPieces = hud.labels.map(label => {
422      const length = [...label.text].length
423      const start = label.x + Math.floor((label.width - length) / 2)
424      const pad = ' '.repeat(Math.max(0, start - cursor))
425      cursor = start + length
426      return (
427        <Text key={label.text}>
428          {pad}
429          <Text color={label.color}>{label.text}</Text>
430        </Text>
431      )
432    })
433
434    return (
435      <Box flexDirection="column">
436        <Text wrap="truncate">{labelPieces}</Text>
437        <Raster key="track" columns={trackWidth} rows={TRACK_ROWS} cells={slime.shown} />
438        {gauge && renderGauge(Text, gauge)}
439        {engine}
440      </Box>
441    )
442  })
443}
444
hooks/context-bar.ts 172 lines
1// The context window as an RPG status window under the slime's floor: a framed
2// CTX bar with one run of blocks per category in /context's own colours, free
3// space as dim shade, and the categories listed inside the frame.
4
5import type { ContextCategoryKind } from 'claude-code'
6
7import type { ContextInfo } from '../types'
8import { formatTokens } from './hud'
9
10// A mana crystal: single-width, so column math stays exact.
11const PREFIX = '◈ '
12export const TITLE = '◈ MANA'
13export const CTX_LABEL = 'CTX '
14export const OPEN = '▕'
15export const CLOSE = '▏'
16export const FRAME = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' } as const
17// '│ ' on the left, ' │' on the right.
18const SIDE = 2
19const MIN_GAUGE = 10
20
21const GLYPH: Record<ContextCategoryKind, string> = {
22  used: '█',
23  buffer: '▒',
24  free: '░',
25  deferred: '',
26}
27
28export type GaugeRun = { glyph: string; length: number; color: string; dim: boolean }
29export type LegendItem = { name: string; tokens: string; color: string }
30export type ManaLevel = 'ok' | 'low' | 'critical'
31export type ContextGauge = {
32  prefix: string
33  // The frame's full width, borders included.
34  width: number
35  status: string
36  runs: GaugeRun[]
37  suffix: string
38  percentColor: string
39  legend: LegendItem[]
40  // The legend broken into rows that fit the band, each item whole.
41  legendRows: LegendItem[][]
42}
43
44const LEGEND_GAP = 2
45
46const itemWidth = (item: LegendItem): number => [...`■ ${item.name} ${item.tokens}`].length
47
48// Greedy rows from the left edge; an item wider than a row gets one to itself.
49export const wrapLegend = (items: readonly LegendItem[], available: number): LegendItem[][] => {
50  const room = Math.max(1, available)
51  const rows: LegendItem[][] = []
52  let row: LegendItem[] = []
53  let used = 0
54  for (const item of items) {
55    const width = itemWidth(item)
56    const needed = row.length === 0 ? width : used + LEGEND_GAP + width
57    if (row.length > 0 && needed > room) {
58      rows.push(row)
59      row = [item]
60      used = width
61    } else {
62      row.push(item)
63      used = needed
64    }
65  }
66  if (row.length > 0) rows.push(row)
67  return rows
68}
69
70// Green while roomy, amber as it nears its limit, red just short of it. The
71// limit is where auto-compaction runs, or the full window when it is off.
72export const manaLevel = (info: Pick<ContextInfo, 'percent' | 'max' | 'compactAt'>): ManaLevel => {
73  const limit = info.compactAt !== undefined && info.max > 0 ? (info.compactAt / info.max) * 100 : 100
74  if (info.percent >= limit - 10) return 'critical'
75  if (info.percent >= limit - 25) return 'low'
76  return 'ok'
77}
78
79const LEVEL_COLOR: Record<ManaLevel, string> = { ok: '#5ad27a', low: '#ffd23c', critical: '#ff5f5f' }
80const LEVEL_STATUS: Record<ManaLevel, string> = { ok: 'STABLE', low: 'LOW MP', critical: 'DANGER' }
81
82export const percentColor = (info: Pick<ContextInfo, 'percent' | 'max' | 'compactAt'>): string =>
83  LEVEL_COLOR[manaLevel(info)]
84
85// Cells per slice by largest remainder, so the runs fill the gauge exactly and
86// every non-empty slice keeps at least one cell.
87export const allocateCells = (slices: readonly ContextInfo['slices'][number][], width: number): number[] => {
88  const total = slices.reduce((sum, s) => sum + s.tokens, 0)
89  // Negated so a NaN total bails too: no slice would take a cell and the loop below would never end.
90  if (!(total > 0) || width <= 0) return slices.map(() => 0)
91  const exact = slices.map(s => (s.tokens / total) * width)
92  const cells = exact.map((x, at) => ((slices[at]?.tokens ?? 0) > 0 ? Math.max(1, Math.floor(x)) : 0))
93  let left = width - cells.reduce((sum, n) => sum + n, 0)
94  const order = exact.map((x, at) => ({ at, rest: x - Math.floor(x) })).sort((a, b) => b.rest - a.rest)
95  for (let i = 0; left > 0 && order.length > 0; i = (i + 1) % order.length) {
96    const at = order[i]?.at ?? 0
97    if ((slices[at]?.tokens ?? 0) > 0) {
98      cells[at] = (cells[at] ?? 0) + 1
99      left -= 1
100    }
101  }
102  // Minimums can overshoot on a narrow gauge: take back from the largest.
103  while (left < 0) {
104    const big = cells.indexOf(Math.max(...cells))
105    cells[big] = (cells[big] ?? 0) - 1
106    left += 1
107  }
108  return cells
109}
110
111// `framed` lays out the terminal status window; without it, the plain one-line
112// gauge (prefix, bar, suffix) and its legend span the whole band.
113export const layoutGauge = (info: ContextInfo, columns: number, framed = true): ContextGauge | undefined => {
114  const slices = info.slices.filter(s => s.kind !== 'deferred')
115  if (slices.length === 0 || info.max <= 0) return undefined
116  const suffix = ` ${info.percent}% ${formatTokens(info.total)}/${formatTokens(info.max)}`
117  // bodyColumns already leaves out the engine's '[-]' column: the gauge takes it all.
118  const frameWidth = columns
119  const inner = framed ? frameWidth - SIDE * 2 : frameWidth
120  const lead = framed ? CTX_LABEL : PREFIX
121  const width = inner - [...lead].length - OPEN.length - CLOSE.length - [...suffix].length
122  if (width < MIN_GAUGE) return undefined
123  const cells = allocateCells(slices, width)
124  const runs: GaugeRun[] = []
125  slices.forEach((s, at) => {
126    const length = cells[at] ?? 0
127    if (length > 0) runs.push({ glyph: GLYPH[s.kind], length, color: s.color, dim: s.kind === 'free' })
128  })
129  const legend = slices
130    .filter(s => s.kind === 'used' && s.tokens > 0)
131    .map(s => ({ name: s.name, tokens: formatTokens(s.tokens), color: s.color }))
132  const level = manaLevel(info)
133  return {
134    prefix: PREFIX,
135    width: frameWidth,
136    status: LEVEL_STATUS[level],
137    runs,
138    suffix,
139    percentColor: LEVEL_COLOR[level],
140    legend,
141    legendRows: wrapLegend(legend, inner),
142  }
143}
144
145// The frame's inner width, between '│ ' and ' │'.
146export const innerWidth = (gauge: ContextGauge): number => gauge.width - SIDE * 2
147
148// Dashes between the title and the status tag: '╭─ ' title ' ' fill ' ' status ' ─╮'.
149export const topFill = (gauge: ContextGauge): number =>
150  Math.max(1, gauge.width - 8 - [...TITLE].length - [...gauge.status].length)
151
152// The plain-text gauge, for surfaces without colour runs and for tests.
153export const gaugeLine = (gauge: ContextGauge): string =>
154  `${gauge.prefix}${OPEN}${gauge.runs.map(r => r.glyph.repeat(r.length)).join('')}${CLOSE}${gauge.suffix}`
155
156export const legendLines = (gauge: ContextGauge): string[] =>
157  gauge.legendRows.map(row => row.map(item => `■ ${item.name} ${item.tokens}`).join(' '.repeat(LEGEND_GAP)))
158
159// The whole status window as plain text, every row exactly the frame's width.
160export const frameLines = (gauge: ContextGauge): string[] => {
161  const { tl, tr, bl, br, h, v } = FRAME
162  const inner = innerWidth(gauge)
163  const bar = gauge.runs.map(r => r.glyph.repeat(r.length)).join('')
164  const pad = (text: string) => text + ' '.repeat(Math.max(0, inner - [...text].length))
165  return [
166    `${tl}${h} ${TITLE} ${h.repeat(topFill(gauge))} ${gauge.status} ${h}${tr}`,
167    `${v} ${CTX_LABEL}${OPEN}${bar}${CLOSE}${gauge.suffix} ${v}`,
168    ...legendLines(gauge).map(line => `${v} ${pad(line)} ${v}`),
169    `${bl}${h.repeat(gauge.width - 2)}${br}`,
170  ]
171}
172
hooks/slime-sprite.ts 51 lines
1// One character per pixel; '.' is see-through. The slime faces right.
2export const SLIME_PALETTE: Record<string, number> = {
3  G: 0x5aa9ff,
4  g: 0x2a5fb4,
5  L: 0xd2e8ff,
6  K: 0x191e23,
7  P: 0xff8caa,
8}
9
10export const SLIME_WIDTH = 12
11// Six pixels tall: three terminal rows of half blocks.
12export const SLIME_ROWS = 3
13
14export const SLIME_FRAMES = {
15  // A slime: it hops (squash, then stretch) instead of running.
16  run1: [
17    '............',
18    '............',
19    '...GGGGGG...',
20    '.GLLGGGGGGG.',
21    'GGGGGKGGKGGG',
22    '.gggggggggg.',
23  ],
24  run2: [
25    '....GGGG....',
26    '...GLGGGG...',
27    '..GLGKGGKGG.',
28    '..GGGGGGGGG.',
29    '...GGGGGG...',
30    '....gggg....',
31  ],
32  sit: [
33    '............',
34    '....GGGG....',
35    '..GLLGGGGG..',
36    '.GLGGKGGKGG.',
37    '.GGGPGGGGPG.',
38    '..gggggggg..',
39  ],
40  sleep: [
41    '............',
42    '............',
43    '............',
44    '...GGGGGG...',
45    '.GLGggGGggG.',
46    'gggggggggggg',
47  ],
48} as const
49
50export type SlimePose = keyof typeof SLIME_FRAMES
51
hooks/hud.ts 85 lines
1// Big pixel numbers on the right of the slime's track: a 3x5 font in half
2// blocks, one colour per fact, its label in small text on the row below.
3
4// '#' is a lit pixel. Every glyph is five pixels tall.
5const FONT: Record<string, readonly string[]> = {
6  '0': ['###', '#.#', '#.#', '#.#', '###'],
7  '1': ['.#.', '##.', '.#.', '.#.', '###'],
8  '2': ['###', '..#', '###', '#..', '###'],
9  '3': ['###', '..#', '###', '..#', '###'],
10  '4': ['#.#', '#.#', '###', '..#', '..#'],
11  '5': ['###', '#..', '###', '..#', '###'],
12  '6': ['###', '#..', '###', '#.#', '###'],
13  '7': ['###', '..#', '.#.', '.#.', '.#.'],
14  '8': ['###', '#.#', '###', '#.#', '###'],
15  '9': ['###', '#.#', '###', '..#', '###'],
16  '.': ['.', '.', '.', '.', '#'],
17  k: ['#..', '#.#', '##.', '#.#', '#.#'],
18  s: ['.##', '#..', '.#.', '..#', '##.'],
19}
20
21// The slime keeps at least this many columns to hop in.
22export const SLIME_AREA_MIN = 24
23// Columns between two facts.
24const BLOCK_GAP = 3
25// The track is six pixels tall and glyphs five: starting one pixel down puts
26// the numbers on the same ground row the slime stands on.
27const GLYPH_TOP = 1
28
29export type HudFact = { key: string; icon: string; value: string; label: string; color: string; rgb: number }
30export type HudPixel = { x: number; y: number; rgb: number }
31export type HudLabel = { x: number; width: number; text: string; color: string }
32export type HudLayout = { areaWidth: number; pixels: HudPixel[]; labels: HudLabel[] }
33
34export const formatTokens = (n: number): string => {
35  if (n < 1000) return `${n}`
36  const k = n / 1000
37  return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`
38}
39
40const glyphWidth = (ch: string): number => FONT[ch]?.[0]?.length ?? 0
41
42// Pixel width of a value: glyphs one column apart.
43export const valueWidth = (value: string): number =>
44  [...value].reduce((sum, ch, at) => sum + glyphWidth(ch) + (at > 0 ? 1 : 0), 0)
45
46const labelText = (fact: HudFact): string => `${fact.icon} ${fact.label}`
47
48const blockWidth = (fact: HudFact): number =>
49  Math.max(valueWidth(fact.value), [...labelText(fact)].length)
50
51// Right-aligned facts, flush with the band's edge (bodyColumns already leaves out
52// the engine's '[-]'); the last ones drop first when the slime would be squeezed.
53export const layoutHud = (facts: readonly HudFact[], trackWidth: number): HudLayout => {
54  let shown = [...facts]
55  const widthOf = (list: readonly HudFact[]) =>
56    list.reduce((sum, fact) => sum + blockWidth(fact) + BLOCK_GAP, 0) - BLOCK_GAP
57  while (shown.length > 0 && trackWidth - widthOf(shown) - BLOCK_GAP < SLIME_AREA_MIN) {
58    shown = shown.slice(0, -1)
59  }
60  if (shown.length === 0) return { areaWidth: trackWidth, pixels: [], labels: [] }
61
62  const hudWidth = widthOf(shown)
63  const pixels: HudPixel[] = []
64  const labels: HudLabel[] = []
65  let x = trackWidth - hudWidth
66  for (const fact of shown) {
67    const width = blockWidth(fact)
68    // The number sits centred over its label.
69    let glyphX = x + Math.floor((width - valueWidth(fact.value)) / 2)
70    for (const ch of fact.value) {
71      const rows = FONT[ch] ?? []
72      for (let y = 0; y < rows.length; y += 1) {
73        const row = rows[y] ?? ''
74        for (let dx = 0; dx < row.length; dx += 1) {
75          if (row[dx] === '#') pixels.push({ x: glyphX + dx, y: y + GLYPH_TOP, rgb: fact.rgb })
76        }
77      }
78      glyphX += glyphWidth(ch) + 1
79    }
80    labels.push({ x, width, text: labelText(fact), color: fact.color })
81    x += width + BLOCK_GAP
82  }
83  return { areaWidth: trackWidth - hudWidth - BLOCK_GAP, pixels, labels }
84}
85
types/index.d.ts 33 lines
1export type BandInfo = {
2  branch: string
3  dirty: number
4  lastMs: number
5  inTokens: number
6  outTokens: number
7}
8
9// One /context row (the SDK's ContextCategory), its colour a theme key. The
10// contract must stand alone, so register.tsx's mapping and context-bar.ts's
11// GLYPH table are what tsc holds to the SDK's kinds.
12export type ContextSlice = {
13  name: string
14  tokens: number
15  color: string
16  kind: 'used' | 'free' | 'buffer' | 'deferred'
17}
18
19export type ContextInfo = {
20  slices: ContextSlice[]
21  total: number
22  max: number
23  percent: number
24  // Tokens at which auto-compaction runs; absent when it is off.
25  compactAt?: number
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'slime-band': { info: BandInfo; context: ContextInfo }
31  }
32}
33