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

使用 GNU Stow 管理個人設定檔,方便在不同機器上快速部署一致的開發環境。
| 套件 | 說明 | 安裝後的路徑 |
|---|---|---|
zsh | Zsh shell 設定 | ~/.zshrc、~/zshrc.d/ → config/zsh/zshrc.d/ |
tmux | tmux 終端多工器設定 | ~/.tmux.conf |
ghostty | Ghostty 終端模擬器設定 | ~/.config/ghostty/config |
cmux | Cmux 終端機設定 | ~/.config/cmux/ |
claude | Claude Code 系統提示 + output styles + hooks 範本 + RPG status line | ~/.claude/CLAUDE.md、~/.claude/output-styles/、~/.claude/rpg-statusline.sh |
claude-mods | Claude Code mods(hooks plugin:pane、band 等) | ~/.claude/mods/<mod>/ → 目錄 symlink |
codex | Codex CLI 系統提示 | ~/.codex/AGENTS.md |
hammerspoon | Hammerspoon macOS 自動化 | ~/.hammerspoon/ |
ripgrep | ripgrep 搜尋工具設定 | ~/.ripgreprc |
git | 全域 git ignore(XDG 路徑,免設定 git config) | ~/.config/git/ignore |
config/shared/skills/為共享 skills 的單一來源,不是 stow 套件。stow-wrap.sh部署 AI CLI 套件時會自動將其 symlink 到每個工具的~/.<tool>/skills/。
# Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# GNU Stow
brew install stow
# CLI 增強工具
brew install eza bat htop fd fzf ripgrep zoxide jq direnv
# 開發工具(按需安裝)
brew install tmux pyenv
依照官方安裝腳本安裝:https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script
# Oh-My-Zsh
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
# 第三方 plugins
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git \
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
git clone https://github.com/zsh-users/zsh-autosuggestions.git \
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
git clone https://github.com/jeffreytse/zsh-vi-mode.git \
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-vi-mode
# Powerlevel10k 主題
git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \
${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k
其餘 plugins(
git、macos、sudo、extract、colored-man-pages、command-not-found)為 Oh-My-Zsh 內建。zsh-vi-mode已啟用系統剪貼簿整合(ZVM_SYSTEM_CLIPBOARD_ENABLED=true)。
git clone https://github.com/junegunn/fzf-git.sh.git ~/fzf-git.sh
brew install --cask font-jetbrains-mono-nerd-font
# 關閉長按字元選單,改為按住重複輸入(vim 操作必要)
defaults write -g ApplePressAndHoldEnabled -bool false
需登出再登入或重新啟動 app 才會生效。
git clone <此 repo 的 URL> ~/dotfiles
cd ~/dotfiles
# 備份現有設定檔
mkdir -p ~/.dotfiles-backup
for f in ~/.zshrc ~/.tmux.conf ~/.ripgreprc \
~/.config/ghostty/config ~/.config/cmux/cmux.json \
~/.claude/CLAUDE.md ~/.codex/AGENTS.md \
~/.hammerspoon/init.lua; do
# 用 cp -L 解引用 symlink,確保備份的是實體內容
[ -e "$f" ] && mkdir -p ~/.dotfiles-backup/"$(dirname "${f#$HOME/}")" \
&& cp -L "$f" ~/.dotfiles-backup/"${f#$HOME/}" \
&& rm "$f"
done
# 部署所有套件
chmod +x scripts/stow-wrap.sh
for pkg in zsh tmux ghostty cmux claude claude-mods codex hammerspoon ripgrep git; do
./scripts/stow-wrap.sh "$pkg"
done
p10k configure
~/.p10k.zsh由 p10k 精靈產生,不納入版控。專案中的36-p10k-theme.zsh會自動覆寫 Gruvbox 色彩主題。
Codex 的 hooks.json 已由 stow 部署,按以下步驟完成設定:
~/.codex/config.toml 啟用 feature flag: ``toml [features] codex_hooks = true ``./scripts/stow-wrap.sh claude 時,stow-wrap 會自動呼叫 scripts/sync-ai-cli-settings.sh:~/.<tool>/settings.json 不存在 → 直接複製 .example 範本jq 智慧合併:permissions.allow/deny 取聯集去重;hooks 以 command 字串為鍵冪等附加;env 同 key 以範本為準。plugins、mcpServers 等使用者自訂內容完全保留settings.json.bak.YYYYMMDD-HHMMSS 備份Claude Code 的
settings.json含機器專屬設定(plugins、MCP servers),無法整檔由 stow 管理,故改採「不存在則複製、已存在則合併」策略。jq為必要相依(brew install jq)。可用--dry-run預覽合併動作。
cp ~/dotfiles/config/zsh/zshrc.d/90-local.zsh.example ~/dotfiles/config/zsh/zshrc.d/90-local.zsh
編輯 90-local.zsh 加入機器專屬的 PATH、環境變數或 secrets 來源。~/zshrc.d/ 是指向 config/zsh/zshrc.d/ 的 symlink,因此直接在 dotfiles 內操作即可。此檔案已加入 .gitignore,不會被提交。
敏感資訊(API key、token)建議放在
~/.secrets,並在90-local.zsh中 source 它。
exec zsh
ls -la ~/.zshrc ~/.tmux.conf
alias
which fzf eza
./scripts/stow-wrap.sh zsh # 部署套件
./scripts/stow-wrap.sh --dry-run zsh # 預覽模式
./scripts/stow-wrap.sh --debug zsh # 除錯模式
./scripts/stow-wrap.sh --list-ignore # 列出忽略規則
./scripts/stow-wrap.sh -D zsh # 移除套件 symlink
bin、claude、codex套件會自動以--no-folding模式部署(腳本內的NO_FOLDING_PKGS),避免將目標目錄折疊為單一 symlink,確保非 dotfiles 管理的檔案不受影響。多套件混合執行時會自動拆分為獨立呼叫。部署完成後,
config/shared/skills/內的共享 skills 會自動 symlink 至各 AI CLI 工具的~/.<tool>/skills/(腳本內的AI_CLI_PKGS,目前為claude、codex);工具專屬 skill 優先,不會被覆蓋。bin雖同為 no-folding 套件但不參與 skills 注入。shared不是合法的套件名稱,傳入會直接報錯。
--no-folding 為什麼必要stow 預設會做 directory folding:若目標目錄在 $HOME 尚不存在,stow 不會逐檔建 symlink,而是直接把整個目錄做成一個指向 repo 的 symlink。之後任何寫入該目錄的檔案都會實際落在 dotfiles repo 內。
實際案例:曾有一個 bin 套件(放 cmux-notify)對應 ~/.local/bin/,但 ~/.local/ 同時是 uv、Claude Code installer 等工具的安裝位置。~/.local 被折疊成 ~/.local -> dotfiles/config/bin/.local 後,這些工具安裝的內容累積到 1.5 GB 全部落在 repo 內。該套件已隨 cmux-notify 一併移除,但教訓保留於此。
判斷準則:只要套件的目標路徑或其上層目錄可能被 dotfiles 以外的程式寫入,就要加進 NO_FOLDING_PKGS。
同理,
.gitignore不要使用*.local這類樣式 —— gitignore 的*可匹配零字元,會連名為.local的目錄整棵樹一起忽略,使上述污染在git status完全隱形。
~/.zshrc ← Stow symlink,最小化 loader
├── direnv 預載 ← 在 instant prompt 前完成首次 .envrc 載入,避免 p10k 警告
├── p10k instant prompt ← 最頂端載入,確保 prompt 即時顯示
└── source zshrc.d/*.zsh ← 依檔名順序載入以下模組
├── 00-paths.zsh ← PATH 設定($HOME/bin、$HOME/.local/bin)
├── 01-env.zsh ← 環境變數(Powerline、NVM、Pyenv、ripgrep)
├── 02-omz.zsh ← Oh-My-Zsh 框架、主題、plugins
├── 10-functions.zsh ← 載入 functions.d/*.sh 輔助函式
├── 20-aliases.zsh ← 條件式別名(htop、bat、eza、tmux)
├── 30-fzf.zsh ← FZF 模糊搜尋(色彩、fd、preview、rfv)
├── 31-zoxide.zsh ← Zoxide 智慧目錄跳轉
├── 32-direnv.zsh ← direnv 目錄式環境變數自動載入(須先 brew install direnv)
├── 35-p10k.zsh ← 載入 ~/.p10k.zsh(各機器獨立)
├── 36-p10k-theme.zsh ← Gruvbox 色彩主題覆寫
└── 90-local.zsh ← 機器專屬設定(不納入版控)
每個模組使用 guard 變數(不 export)防止同一 shell 內重複載入,不會影響 tmux 等子 shell 的初始化。
兩個工具各自維護獨立的系統提示檔,皆透過 stow 管理。目前內容相同,可依不同 LLM 特性分別微調。
config/shared/skills/ 是所有 AI CLI 工具共用 skills 的唯一來源。部署時 stow-wrap.sh 自動將每個 skill 目錄 symlink 到各工具的 ~/.<tool>/skills/<skill>,無需手動同步。
若特定工具需要專屬 skill,將其放入 config/<tool>/.<tool>/skills/<skill>/;該目錄下的 skill 由既有 promote 流程優先處理,不會被共享版本覆蓋。
新增 / 修改共享 skill 只需操作 config/shared/skills/,重新執行 ./scripts/stow-wrap.sh <AI 工具套件> 即可生效。
由 cmux 自行處理,dotfiles 不再維護通知腳本。
| 工具 | 安裝方式 | 產生的檔案 |
|---|---|---|
| Claude Code | cmux Claude wrapper 自動注入,無須設定 | 無(wrapper 動態注入) |
| 其他 agent | cmux hooks setup <agent> | 各 agent 自己的 hook 檔(cmux 管理) |
Claude Code 只要 cmux 設定中 automation.claudeCodeIntegration 為 true 即生效:cmux 用 wrapper 包住 claude 執行檔(PATH 最前面的 cmux-cli-shims/),啟動時動態注入自己的 hooks, 提供 running/idle/needsInput 狀態、Feed 審批、session restore 與 PushNotification 橋接。
其他 agent(codex、opencode、gemini 等)用 cmux hooks setup 安裝,cmux 會寫入該 agent 自己的設定檔(Codex 為 ~/.codex/hooks.json 與 config.toml)。這些檔案由 cmux 管理,不納入 dotfiles——否則 cmux 更新格式時 repo 內的手寫版本會悄悄失效。
歷史:先前由 dotfiles 維護一支
cmux-notify腳本供各工具呼叫,因 cmux 變更 socket 路徑 (/tmp/cmux.sock→~/.local/state/cmux/cmux-<uid>.sock)而靜默失效。既然 cmux 已內建整合, 該腳本與其所屬的bin套件已一併移除。細節見cmux docs agents。
rpg-statusline.sh)claude 套件含 ~/.claude/rpg-statusline.sh(stow symlink),風格對齊 claude-mods 的遊戲 UI。 settings.json.example 的 statusLine 指向它,merge-settings.jq 會以範本覆蓋本機 statusLine。
COLUMNS 以段為單位自動換行:model + effort 星等、⚡ fast mode、目錄 / 分支 / ✎ dirty / 增刪行數 / PR、資源條、session 時長MP 為 context window(與 rpg-hud 人物面板的 MP 同義、同變色門檻),⌛5h / ⌛7d 為用量限額並附重置日期時間,⛁ cap 為 spend limit(僅 gateway)refreshInterval: 2:調整視窗寬度不會觸發 status line 更新,靠每 2 秒重跑讓換行跟上新寬度slime-band,不重複顯示~/.claude/settings.json 的 statusLine,並從 settings.json.example 移除,否則下次 stow-wrap.sh claude 會加回cc-notify.sh)claude 套件另含 ~/.config/claude/cc-notify.sh(由 stow 部署為 symlink),在 Claude Code 「工作真正完成」時發一則 Telegram 訊息。設計為 trailing-edge idle debounce:主 agent 閒置 CC_NOTIFY_IDLE_WINDOW 秒後才發,任何後續活動(新 prompt、工具呼叫、subagent 結束)都會 取消待發通知,因此多 subagent/多 turn 的工作會 collapse 成單一通知。相關 hooks (UserPromptSubmit/PreToolUse/SubagentStop/Stop)已寫入 settings.json.example,由 sync 合併。
設定:複製 ~/.config/claude/telegram.env.example 為 telegram.env 並填入 TELEGRAM_BOT_TOKEN、 TELEGRAM_CHAT_ID(此檔含 secret,不納入版控);可選用 CC_NOTIFY_IDLE_WINDOW(預設 25s)與 CC_NOTIFY_MIN_SECONDS(預設 30s,主 turn 短於此不通知)。未設定 telegram.env 時腳本靜默略過。
claude 套件含 config/claude/.claude/output-styles/,由 stow 以 --no-folding 逐檔 symlink 到 ~/.claude/output-styles/,因此 Claude Code 自己在該目錄產生的 style 不受影響。目前收錄:
| Style | 用途 |
|---|---|
eli5 | 極簡回覆,只講做了什麼、成不成功、下一步做什麼 |
ste | 受控技術語言(ASD-STE100 Issue 9 紀律):單一詞義、短句、主動語態、零歧義 |
新增 style:在 config/claude/.claude/output-styles/ 放入 <name>.md(frontmatter 需含 name、description),再跑 ./scripts/stow-wrap.sh claude。切換用 /output-style。
claude-mods 套件收錄 config/claude-mods/.claude/mods/<mod>/,由 CLAUDE_CODE_PLUGIN_DIRS (settings.json.example 的 env,經 sync-ai-cli-settings.sh 合併)載入。目前收錄 rpg-hud、 slime-band、skill-bar。
此套件刻意不用 --no-folding:plugin loader 讀 hooks/hooks.json 時不跟隨 symlink,且 module realpath 落在 plugin 目錄外會被判為 path traversal,逐檔 symlink 會讓 mod 整個載入失敗。folding 後 ~/.claude/mods/<mod> 是指向 repo 的目錄 symlink,engine 產生的 .claude-plugin/types/ 會寫進 repo (已 gitignore)。
新增 mod:在 config/claude-mods/.claude/mods/ 建目錄、把路徑加進 settings.json.example 的 CLAUDE_CODE_PLUGIN_DIRS,再跑 ./scripts/stow-wrap.sh claude claude-mods。驗證用 claude plugin validate ~/.claude/mods/<mod> 與 claude plugin test ~/.claude/mods/<mod>。
各工具的設定檔(config.toml、settings.json、config.json)包含機器專屬內容,不納入版控,各機器獨立維護。
git pull 只更新 repo 內的檔案,無法撤銷既有部署。從 repo 刪除一個檔案後,其他機器上仍會留下:
sync-ai-cli-settings.sh 合併進 live ~/.claude/settings.json 的項目(該腳本只增不減)[hooks.state] trusted hash)因此凡是「下架」性質的變更,都在 scripts/ 下附一支 migrate-<YYYYMMDD>-<描述>.sh,記錄該次變更需要在其他機器上執行的清理步驟。慣例:
--dry-run,預覽時不得寫入任何檔案cp -p 保留權限),並在輸出中告知備份路徑現有腳本:
./scripts/migrate-20260812-remove-cmux-notify.sh --dry-run # 預覽
./scripts/migrate-20260812-remove-cmux-notify.sh # 執行
若尚未 pull,優先在 pull 之前執行
./scripts/stow-wrap.sh -D <套件>解除部署——套件目錄一旦被 pull 刪除,stow -D就無法再運作。
config/ 下建立新目錄,結構反映 $HOME 下的相對路徑scripts/stow-wrap.sh 的 NO_FOLDING_PKGS(原因見上方「--no-folding 為什麼必要」)./scripts/stow-wrap.sh <套件名> 部署ls -ld ~/<目標目錄>hooks/register.tsx 444 lines1import { 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}
444hooks/context-bar.ts 172 lines1// 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}
172hooks/slime-sprite.ts 51 lines1// 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
51hooks/hud.ts 85 lines1// 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}
85types/index.d.ts 33 lines1export 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