An MMO skill bar under the prompt, one slot per project skill and per most used skill; click one to start 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 142 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SkillNames } from '../types'
5import { labelOf, runeOf } from './hotbar-client'
6import type { HotbarProps, HotbarSlot } from './hotbar-client'
7import { PANEL, colorFor, mix, toHex } from './colors'
8
9const skills = atom({ plugin: 'skill-bar', key: 'skills' } as const, [] as SkillNames)
10
11// Project skills only: the folders under .claude/skills that hold a SKILL.md.
12export async function listProjectSkills($: EngineInterface): Promise<string[]> {
13 const root = `${await $.session.cwd()}/.claude/skills`
14 if (!(await $.fs.exists(root))) return []
15 const entries = await $.fs.list(root)
16 const found: string[] = []
17 for (const entry of entries) {
18 if (entry.kind !== 'dir') continue
19 if (await $.fs.exists(`${root}/${entry.name}/SKILL.md`)) found.push(entry.name)
20 }
21 return found.sort((a, b) => a.localeCompare(b))
22}
23
24// The most used skills that get a slot beside the project's own.
25export const FAVORITES = 4
26
27// The slots: the project's skills, then the skills cast most (rpg-hud's lifetime count) that the session still
28// has and the project does not already hold, most used first.
29export const slotsFor = (project: readonly string[], uses: Readonly<Record<string, number>>, known: readonly string[]): string[] => {
30 const favorites = Object.entries(uses)
31 .filter(([name, count]) => count > 0 && known.includes(name) && !project.includes(name))
32 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
33 .slice(0, FAVORITES)
34 .map(([name]) => name)
35 return [...project, ...favorites]
36}
37
38// rpg-hud's skill counts and the session's skills, when that mod runs; nothing otherwise. Read while drawing, so
39// a cast there redraws the slots here. Another plugin's state is read-only, and typed by its own contract.
40async function readFavorites($: EngineInterface) {
41 const [progress, loadout] = await Promise.all([
42 $.state.get({ plugin: 'rpg-hud', key: 'progress' } as never).catch(() => undefined),
43 $.state.get({ plugin: 'rpg-hud', key: 'loadout' } as never).catch(() => undefined),
44 ])
45 const uses = (progress?.value as { skillUses?: Record<string, number> } | undefined)?.skillUses ?? {}
46 const known = ((loadout?.value as { skills?: { name: string }[] } | undefined)?.skills ?? []).map(skill => skill.name)
47 return { uses, known }
48}
49
50// One hotbar slot in its own colour: the keycap in full, the cell a dark tint of it that deepens under the pointer.
51export const hotbarSlot = (name: string, slot: number): HotbarSlot => {
52 const label = labelOf(name)
53 const color = colorFor(slot)
54 return {
55 name,
56 label,
57 rune: runeOf(label),
58 color: toHex(color),
59 tint: toHex(mix(color, PANEL, 0.18)),
60 glow: toHex(mix(color, PANEL, 0.38)),
61 }
62}
63
64// Pressing a slot puts the command in the prompt box, so arguments (an issue number) can follow.
65export const commandText = (skill: string): string => `/${skill} `
66
67async function refresh($: EngineInterface) {
68 const found = await listProjectSkills($).catch(() => [] as string[])
69 await update($, skills, () => found)
70}
71
72export const register: Register = on => {
73 on('session.start', async ($, e, next) => {
74 await refresh($)
75 return next(e)
76 })
77
78 // A /clear starts a new session without session.start; refill the slots for it.
79 on('classic.SessionStart', async ($, e, next) => {
80 if (e.source === 'clear') await refresh($)
81 return next(e)
82 })
83
84 // A new or removed skill folder shows up after the next turn.
85 on('turn.complete', async ($, e, next) => {
86 if (e.agentId === undefined) await refresh($)
87 return next(e)
88 })
89
90 // The hotbar Client posts `{ cast }` when a slot is clicked.
91 on('ui.message', async ($, e, next) => {
92 const data = (e.data ?? {}) as { cast?: unknown }
93 if (e.element !== 'hotbar' || typeof data.cast !== 'string') return next(e)
94 await $.prompt.fill({ text: commandText(data.cast) })
95 return {}
96 })
97
98 // Under the prompt: the engine's hint line first, the item slots below it.
99 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
100 const engine = await next(e)
101 const favorites = await readFavorites($)
102 const project = await read($, skills)
103 const names = slotsFor(project, favorites.uses, favorites.known)
104 if (names.length === 0) return engine
105
106 const slots = names.map((name, slot) => hotbarSlot(name, slot))
107 const { Box, Button } = $.ui.resolve(e)
108
109 // The terminal draws an MMO skill bar: a rune keycap and a tinted cell a slot; a click casts it and it cools down.
110 if (e.surface === 'terminal') {
111 const { Client } = $.ui.resolve(e)
112 const hotbar = { slots } satisfies HotbarProps
113 return (
114 <Box flexDirection="column">
115 {engine}
116 <Box marginTop={1}>
117 <Client key="hotbar" module="./hotbar-client.tsx" props={hotbar} width="100%" />
118 </Box>
119 </Box>
120 )
121 }
122
123 // Other surfaces: a button a slot, wearing its rune.
124 return (
125 <Box flexDirection="column">
126 {engine}
127 <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginY={1}>
128 {slots.map(one => (
129 <Button
130 key={one.name}
131 label={`[${one.rune}] ${one.label}`}
132 plain
133 dimColor
134 onPress={() => void $.prompt.fill({ text: commandText(one.name) })}
135 />
136 ))}
137 </Box>
138 </Box>
139 )
140 })
141}
142hooks/hotbar-client.tsx 165 lines1import type { ClientModule } from 'claude-code'
2
3import { PANEL, toHex } from './colors'
4
5export type HotbarSlot = {
6 // The skill's full name, what a cast fills the prompt with.
7 name: string
8 // What the slot shows: the name past any `plugin:` prefix.
9 label: string
10 // The rune on the slot's keycap: the label's first letter or digit.
11 rune: string
12 // The slot's own colour, and its cell at rest and under the pointer, as hex.
13 color: string
14 tint: string
15 glow: string
16}
17
18export type HotbarProps = { slots: HotbarSlot[] }
19
20type HotbarState = {
21 tick: number
22 // The slot under the pointer.
23 hover?: number
24 // The slot just cast and the tick its cooldown began.
25 cast?: { index: number; start: number }
26}
27
28const FRAME_MS = 80
29// How long a cast slot cools down, dark to lit, left to right.
30export const COOLDOWN_TICKS = 15
31// Ticks between the hover sparks' twinkles.
32const TWINKLE_TICKS = 4
33// A slot is one row: `✦▐C▌ label ✦`, the sparks lit only under the pointer.
34export const SLOT_ROWS = 1
35const GAP = 2
36// The longest label a slot shows whole.
37const MAX_LABEL = 18
38// Around the label: spark, keycap (▐ rune ▌), space, space, spark.
39const CHROME = 1 + 3 + 1 + 1 + 1
40// The rune's ink on its coloured keycap, and the cooling cell's.
41const INK = toHex(PANEL)
42const COOLING = '#2a2533'
43// The width laid out before the first layout reports the region's.
44const FALLBACK_COLUMNS = 80
45
46// `name` past its `plugin:` prefix, cut to fit a slot.
47export const labelOf = (name: string): string => {
48 const bare = name.slice(name.lastIndexOf(':') + 1)
49 return bare.length > MAX_LABEL ? `${bare.slice(0, MAX_LABEL - 1)}…` : bare
50}
51
52export const runeOf = (label: string): string => (label.match(/[a-z0-9]/i)?.[0] ?? '?').toUpperCase()
53
54export const slotWidth = (slot: Pick<HotbarSlot, 'label'>): number => slot.label.length + CHROME
55
56// The cell's text while it cools: what has come back shows, the rest is shade, lightest at the sweep's front.
57export const cooldownText = (text: string, elapsed: number): string => {
58 const chars = [...text]
59 const lit = Math.min(chars.length, Math.floor((elapsed / COOLDOWN_TICKS) * chars.length))
60 return chars.map((ch, at) => (at < lit ? ch : at === lit ? '░' : at === lit + 1 ? '▒' : '▓')).join('')
61}
62
63// The slots in lines that fit `columns`: each line as many as fit, a slot wider than the whole bar alone on one.
64export const hotbarLines = (widths: readonly number[], columns: number): { index: number; x: number }[][] => {
65 const lines: { index: number; x: number }[][] = []
66 let line: { index: number; x: number }[] = []
67 let x = 0
68 widths.forEach((width, index) => {
69 if (line.length > 0 && x + width > columns) {
70 lines.push(line)
71 line = []
72 x = 0
73 }
74 line.push({ index, x })
75 x += width + GAP
76 })
77 if (line.length > 0) lines.push(line)
78 return lines
79}
80
81const Hotbar: ClientModule<HotbarProps, HotbarState> = (props, surface) => {
82 const { Box, Text } = surface.elements
83 if (surface.state === undefined) {
84 // Frames since the sparks last twinkled, while nothing cools.
85 let idle = 0
86 surface.every(FRAME_MS, () => {
87 const now = surface.state
88 // Only a cooldown or a hovered slot's sparks move; at rest nothing redraws.
89 if (now === undefined || (now.cast === undefined && now.hover === undefined)) return
90 // Sparks alone change once a twinkle: skip the frames between, then step the clock a whole twinkle.
91 if (now.cast === undefined) {
92 idle += 1
93 if (idle < TWINKLE_TICKS) return
94 idle = 0
95 surface.setState({ ...now, tick: now.tick + TWINKLE_TICKS })
96 return
97 }
98 const isCool = now.cast !== undefined && now.tick + 1 - now.cast.start >= COOLDOWN_TICKS
99 surface.setState({ ...now, tick: now.tick + 1, ...(isCool ? { cast: undefined } : {}) })
100 })
101 surface.setState({ tick: 0 })
102 }
103 const state = surface.state ?? { tick: 0 }
104 const widths = props.slots.map(slotWidth)
105 const lines = hotbarLines(widths, surface.columns > 0 ? surface.columns : FALLBACK_COLUMNS)
106
107 const cast = (index: number) => {
108 const slot = props.slots[index]
109 if (slot === undefined) return
110 surface.setState({ ...state, cast: { index, start: state.tick } })
111 surface.post({ cast: slot.name })
112 }
113
114 const slotAt = (x: number, y: number): number | undefined =>
115 lines[Math.floor(y / SLOT_ROWS)]?.find(one => x >= one.x && x < one.x + (widths[one.index] ?? 0))?.index
116
117 surface.onPointer(e => {
118 const index = e.type === 'leave' ? undefined : slotAt(e.x, e.y)
119 if (e.type === 'up' && e.button === 'left' && index !== undefined) return cast(index)
120 if (state.hover !== index && e.type !== 'down') surface.setState({ ...state, hover: index })
121 })
122
123 const drawSlot = (slot: HotbarSlot, index: number) => {
124 const elapsed = state.cast?.index === index ? state.tick - state.cast.start : undefined
125 const isCooling = elapsed !== undefined
126 const isHover = !isCooling && state.hover === index
127 const spark = isHover ? (Math.floor(state.tick / TWINKLE_TICKS) % 2 === 0 ? '✦' : '✧') : ' '
128 const cap = isCooling ? COOLING : slot.color
129 const cell = isCooling ? COOLING : isHover ? slot.glow : slot.tint
130 const text = ` ${slot.label} `
131
132 return (
133 <Box key={slot.name} flexDirection="row" width={widths[index] ?? 0} height={1}>
134 <Text color={slot.color}>{spark}</Text>
135 <Text color={cap}>▐</Text>
136 <Text bold color={isCooling ? slot.color : INK} backgroundColor={cap}>
137 {slot.rune}
138 </Text>
139 <Text color={cap} backgroundColor={cell}>
140 ▌
141 </Text>
142 <Text bold={isHover} color={isCooling || isHover ? slot.color : 'white'} dimColor={isCooling} backgroundColor={cell}>
143 {isCooling ? cooldownText(text, elapsed) : text}
144 </Text>
145 <Text color={slot.color}>{spark}</Text>
146 </Box>
147 )
148 }
149
150 return (
151 <Box flexDirection="column">
152 {lines.map((line, at) => (
153 <Box key={`line-${at}`} flexDirection="row" columnGap={GAP} height={SLOT_ROWS}>
154 {line.map(one => {
155 const slot = props.slots[one.index]
156 return slot === undefined ? null : drawSlot(slot, one.index)
157 })}
158 </Box>
159 ))}
160 </Box>
161 )
162}
163
164export default Hotbar
165hooks/colors.ts 22 lines1// Each slot's own colour, for its frame and label under the pointer: bright game-item colours, in a shuffled
2// order so neighbours never look like a gradient.
3export const SKILL_COLORS = [0x5ad27a, 0xff5a5a, 0x56c8dc, 0xffd23c, 0xc88cff, 0xffa53c, 0x5aa9ff, 0xff8caa] as const
4
5// By slot: up to eight skills each get a different colour, and the same list keeps the same colours.
6export const colorFor = (slot: number): number => SKILL_COLORS[slot % SKILL_COLORS.length] ?? SKILL_COLORS[0]
7
8export const toHex = (rgb: number): string => `#${rgb.toString(16).padStart(6, '0')}`
9
10// The panel colour the cells are tinted from, as rpg-hud's dark cards.
11export const PANEL = 0x14111c
12
13// `rgb` mixed into `base`: 0 is all base, 1 all rgb.
14export const mix = (rgb: number, base: number, amount: number): number => {
15 const channel = (shift: number) => {
16 const from = (base >> shift) & 0xff
17 const to = (rgb >> shift) & 0xff
18 return Math.round(from + (to - from) * amount) << shift
19 }
20 return channel(16) | channel(8) | channel(0)
21}
22types/index.d.ts 8 lines1export type SkillNames = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'skill-bar': { skills: SkillNames }
6 }
7}
8