SLOPSHOPPER

skill-bar

An MMO skill bar under the prompt, one slot per project skill and per most used skill; click one to start it

newspinner
A shopper browsing a rack in a slop shop
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 4 files
hooks/register.tsx 142 lines
1import { 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}
142
hooks/hotbar-client.tsx 165 lines
1import 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
165
hooks/colors.ts 22 lines
1// 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}
22
types/index.d.ts 8 lines
1export type SkillNames = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'skill-bar': { skills: SkillNames }
6  }
7}
8