Claude Codeの状態を自作キーボードで物理表示させた話 (TinyGo + RP2040 + Gopher)
はじめに
Claude Code をバックグラウンドで動かしていると、応答中なのか完了したのかがよく分からなくなります。通知バナーは見落とすし、ターミナルのタブも裏に隠れがち。
そこで、手元にあった自作キーボード zero-kb02(Waveshare RP2040-Zero ベース)に TinyGo でファームを書いて、「Claude Code の状態を物理的に表示する装置」にしてみました。
完成形はこんな感じ

| Claude Code の状態 | LED | OLED |
|---|---|---|
| プロンプト送信中 | 🔵 外周ぐるぐる | 各文字の Gopher アニメ |
| 質問 UI 表示時 | 🔴 赤 | Gopher C "Choose" |
| 応答完了 | 🟢 緑 | Gopher D "Done" |
小さく作って試行錯誤しながら積み上げていったので、その過程ごと記事にしてみます。
環境
- マイコン: Waveshare RP2040-Zero
-
キーボード: tinygo-keeb/workshop で配布されている
zero-kb02- 12 個 WS2812 LED + SSD1306 OLED + 4×3 キーマトリクス
- 言語: TinyGo 0.40.1
- Mac: macOS (Apple Silicon)
ハードウェアのピン構成
キーマトリクス Cols : GPIO5, 6, 7, 8 (Output)
キーマトリクス Rows : GPIO9, 10, 11 (InputPulldown)
キーボード WS2812 ×12: GPIO1
オンボード WS2812 ×1 : GPIO16
SSD1306 OLED : I2C0 (SDA=GPIO12, SCL=GPIO13, 0x3C)
ステップ 1: まずは Claude Code フックで通知を出す
最初は「Claude が応答を返し終わった瞬間に何かさせる」ところから始めました。Claude Code には Stop というイベントがあって、応答完了時にコマンドを実行できます。
~/.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"応答が完了しました\" with title \"Claude Code\"'"
}
]
}
]
}
}
これで応答完了時に macOS の通知バナーが出ます。設定変更後は /hooks を一度開いて閉じればリロードされます。
「これは便利だ」となって、次にもっと派手な通知を作りたくなりました。
ステップ 2: TinyGo でオンボード LED を光らせる
RP2040-Zero にはオンボード WS2812 LED が GPIO16 に付いています。まずはこれを光らせます。
package main
import (
"image/color"
"machine"
"time"
"tinygo.org/x/drivers/ws2812"
)
func main() {
pin := machine.GPIO16
pin.Configure(machine.PinConfig{Mode: machine.PinOutput})
ws := ws2812.New(pin)
leds := []color.RGBA{{}}
colors := []color.RGBA{
{R: 30}, {G: 30}, {B: 30},
}
for i := 0; ; i++ {
leds[0] = colors[i%len(colors)]
ws.WriteColors(leds)
time.Sleep(500 * time.Millisecond)
}
}
ビルドして書き込み:
tinygo build -target=waveshare-rp2040-zero -o /tmp/main.uf2 main.go
# BOOTSEL ボタン押しながら USB 挿す
cp /tmp/main.uf2 /Volumes/RPI-RP2/
赤緑青で 0.5 秒ごとに点滅。あっさり光りました。
ステップ 3: USB シリアルで色を制御する
次に「Mac から動的に色を変えたい」となり、USB CDC(シリアル)経由でコマンドを受け取る形に。machine.Serial から行単位で読み取って、red / green / blue などをパースします。
var buf []byte
for {
if machine.Serial.Buffered() > 0 {
b, _ := machine.Serial.ReadByte()
if b == '\n' || b == '\r' {
handleCommand(string(buf))
buf = buf[:0]
} else {
buf = append(buf, b)
}
}
time.Sleep(5 * time.Millisecond)
}
Mac 側はこんな感じで送る:
stty -f /dev/cu.usbmodem2101 115200 raw -echo
printf 'red\n' >| /dev/cu.usbmodem2101
これで Mac からシリアルコマンド送信 → LED 色変更ができるように。
ステップ 4: Stop フックから LED を光らせる
ステップ 1 の通知バナーに加えて、Stop フックから LED を緑にするスクリプトを呼びます。
~/.claude/hooks/keyboard-led.sh:
#!/bin/bash
PORT=$(ls /dev/cu.usbmodem* 2>/dev/null | head -1)
[ -z "$PORT" ] && exit 0
stty -f "$PORT" 115200 raw -echo 2>/dev/null
for line in "$@"; do
printf '%s\n' "$line" >| "$PORT" 2>/dev/null
done
settings.json に追加:
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/keyboard-led.sh green"
}
]
}
]
Claude が応答完了するたびにオンボード LED が緑になりました 🟢
ステップ 5: 12 個の LED で外周くるくるアニメ
オンボード 1 個だけだと寂しいので、キーボードの 12 個の LED も使います。最初は同じ GPIO16 に書き込んでみたところ、オンボードしか光りませんでした。
workshop の 02_blinky2 を見たら GPIO1 に繋がっていました(オンボードと別ピン)。ws2812.Device を 2 つ持つように修正:
pinBoard := machine.GPIO16
pinBoard.Configure(machine.PinConfig{Mode: machine.PinOutput})
wsBoard := ws2812.New(pinBoard)
pinKeys := machine.GPIO1
pinKeys.Configure(machine.PinConfig{Mode: machine.PinOutput})
wsKeys := ws2812.New(pinKeys)
「考え中」状態(プロンプト送信時)には外周をくるくる回したいので、3×4 グリッドの外周巡回順序を定義してチェイサーアニメに:
// 3×4 グリッド:
// 0 3 6 9
// 1 4 7 10
// 2 5 8 11
var perimeter = []int{0, 3, 6, 9, 10, 11, 8, 5, 2, 1}
func tickSpin() {
// 全消灯
for i := range keys {
keys[i] = color.RGBA{}
}
head := perimeter[spinIdx%len(perimeter)]
tail := perimeter[(spinIdx-1+len(perimeter))%len(perimeter)]
keys[head] = color.RGBA{B: 60}
keys[tail] = color.RGBA{B: 15} // 尻尾フェード
wsKeys.WriteColors(keys)
spinIdx++
}
メインループで 70ms ごとに tickSpin() を呼ぶ。シリアル読み取りと並行動作させるために、ブロッキングしないよう自前ティック方式に。
settings.json に UserPromptSubmit フック追加:
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "$HOME/.claude/hooks/keyboard-led.sh spin" }
]
}
]
これで「プロンプト送信中=青くるくる」「応答完了=緑」が実現できました。視界の端で状態がわかるようになります。
ステップ 6: SSD1306 OLED にテキスト表示
LED だけだと色しかわからないので、OLED に状態テキストも表示します。zero-kb02 には SSD1306 128×64 OLED が I2C0 に繋がっています。
machine.I2C0.Configure(machine.I2CConfig{
Frequency: 400000,
SDA: machine.GPIO12,
SCL: machine.GPIO13,
})
display := ssd1306.NewI2C(machine.I2C0)
display.Configure(ssd1306.Config{
Address: 0x3C,
Width: 128,
Height: 64,
})
テキストは tinygo.org/x/tinyfont の freesans.Regular12pt7b で描画:
import "tinygo.org/x/tinyfont/freesans"
func showText(s string) {
display.ClearBuffer()
tinyfont.WriteLine(display, &freesans.Regular12pt7b, 0, 22, s, white)
display.Display()
}
text Thinking... のようなコマンドも追加して、フックから一緒に送るようにしました:
$HOME/.claude/hooks/keyboard-led.sh spin "text Thinking..."
ステップ 7: Gopher フォントで状態アイコン
ここで「OLED に Gopher を出したい」欲が高まり、tinygo.org/x/tinyfont/gophers パッケージを利用。gophers.Regular32pt という、A〜Z の各文字に異なるポーズの Gopher イラストが割り当てられた特殊フォントです。
import "tinygo.org/x/tinyfont/gophers"
func showGopher(letter, label string) {
display.ClearBuffer()
if label != "" {
tinyfont.WriteLine(display, &freesans.Regular9pt7b, 30, 14, label, white)
}
tinyfont.WriteLine(display, &gophers.Regular32pt, 50, 60, letter, white)
display.Display()
}
各状態に文字を割り当てて:
- Thinking → Gopher T
- Choose → Gopher C
- Waiting → Gopher W
- Done → Gopher D
26 種類の Gopher が見られて完全に Gopher 。かわよ❤️
ステップ 8: Gopher を順番に切り替えるアニメーション
「Thinking 状態の間は Gopher が次々変わったら楽しいよね」となり、seq <word> コマンドを追加。
func tickSeq() {
letter := strings.ToUpper(string(seqWord[seqIdx%len(seqWord)]))
renderGopher(letter, seqWord) // 大きな Gopher + ラベル
seqIdx++
}
メインループで 400ms ごとに tickSeq() を呼ぶ。seq THINKING を送ると T → H → I → N → K → I → N → G とポーズが切り替わるアニメーションに。
$HOME/.claude/hooks/keyboard-led.sh spin "seq THINKING"
これがめちゃくちゃ可愛い。
ステップ 9: 「選択肢が出た時」を検知する
Claude Code が AskUserQuestion で質問 UI を出した時にも反応させたい。最初は Notification イベントを使ってみたものの、AskUserQuestion では発火しないことが判明。
そこで PreToolUse の matcher を使って、ツール呼び出しの直前にフックを発火させる方法に変更:
"PreToolUse": [
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/keyboard-led.sh red 'gopher C Choose'"
}
]
}
]
これで質問 UI が出る瞬間に 赤 + Gopher C に切り替わるようになりました 🔴
ステップ 10: HID キーボード機能を追加
ここまで来たら「物理キーで質問 UI を操作したい」となります。TinyGo は machine/usb/hid/keyboard パッケージで USB HID キーボードを提供しています。USB CDC と HID は composite device として共存できます。
import "machine/usb/hid/keyboard"
var kb = keyboard.New()
// マトリクス index → HID キーコード
var keymap = [12]keyboard.Keycode{
keyboard.KeyUpArrow, keyboard.KeyDownArrow, keyboard.KeyEsc, keyboard.KeyReturn,
0, 0, 0, 0,
0, 0, 0, 0,
}
func scanMatrix() {
for c, colPin := range colPins {
colPin.High()
time.Sleep(1 * time.Millisecond) // ★ プルダウン安定待ち
for r, rowPin := range rowPins {
pressed := rowPin.Get()
idx := r*4 + c
if pressed != keyState[idx] {
keyState[idx] = pressed
if k := keymap[idx]; k != 0 {
if pressed {
kb.Down(k)
} else {
kb.Up(k)
}
}
}
}
colPin.Low()
}
}
これで物理キーボードの ↑↓ + Enter で Claude Code の質問 UI を選択 → 決定できるようになりました。
完成
| 状態 | LED | OLED |
|---|---|---|
| プロンプト送信中 | 🔵 spin | seq THINKING (アニメ) |
| 質問 UI 表示時 | 🔴 red | gopher C "Choose" |
| 通知 (権限/アイドル) | 🔴 red | gopher W "Waiting" |
| 応答完了 | 🟢 green + macOS 通知 | gopher D "Done" |
Claude Code の状態がキーボード上で物理的に見えるので、別ウィンドウで作業していても視界の端で気づけます。物理キーで質問 UI の操作もできるので、ラップトップのキーボードに手を伸ばさずに済むのも快適です。
やってみての所感
Claude Code フックは想像以上に使える
Stop / UserPromptSubmit / PreToolUse / Notification などのイベントに任意のシェルコマンドを紐付けられるので、外部ハードウェアとの連携が驚くほど簡単。設定ファイル数行で物理デバイスが Claude の状態を反映するようになります。
TinyGo + RP2040 はもう普通に書ける
ws2812 / ssd1306 / tinyfont / HID キーボードまで、必要なものがほぼ揃っています。Go の文法のままマイコンのコードが書けるので便利です。
Gopher フォントは隠れた名作
tinygo.org/x/tinyfont/gophers の Regular32pt は知名度が低い気がしますが、26 文字に違うポーズの Gopher が割り当てられた完全に遊びのフォントで、見ていて飽きません。OLED 付きマイコンを持っているなら使わない手はないです。
TinyGo Keeb ツアーについて
今回使った zero-kb02 は TinyGo Keeb ツアー で扱われている自作キーボードです。TinyGo を使ってマイコンプログラミングや自作キーボードを学べる勉強会で、初心者でも気軽に参加できます。
次回は 2026 年 5 月 20 日にシンガポールの GopherCon SG で開催されます!
🔗 GopherCon SG 2026 - TinyGo Keeb Workshop
ハードウェアを触りながら Go でファームを書ける機会は意外と少ないので、興味があればぜひチェックしてみてください。今回の記事みたいな「思いつきで機能を足していく」遊び方を体験するには最高の題材です。
ソースコード
リポジトリは にあります。
参考
- tinygo-keeb/workshop — zero-kb02 の各種サンプル
- sago35/keyboards — もっと本格的なファーム実装の参考に
- TinyGo Keeb サイト
- Claude Code Hooks ドキュメント
Discussion