A small screen for your desk that shows what Claude Code is doing. It tracks all your sessions at the same time, one row per terminal tab. Each row shows the status (working, done, or needs you), how full the context window is, and which model the session uses. When a session changes state, the screen flashes a colored circle so you notice it even when you are looking at something else.
It runs on a LilyGO T-Display-S3 (ESP32-S3 with a 1.9 inch ST7789 screen, 320x170). Your computer sends updates to it over WiFi using Claude Code hooks. There is no cloud service and no background program to keep running.
Claude Code sessions (your terminal tabs)
|
| each hook sends a small JSON update over your WiFi
v
ESP32 T-Display-S3
- HTTP server on port 80
- keeps a table of sessions in memory
- draws the dashboard
The link between Claude Code and the board is a Claude Code hook. So it works with any Claude Code that runs on your own machine and reads ~/.claude/settings.json.
| Surface | Works | Why |
|---|---|---|
| Terminal / CLI | Yes | Runs on your machine and fires hooks. |
| VS Code / JetBrains extension | Yes | Same local engine and settings.json. |
| Desktop app (Mac/Windows) | Yes | Runs locally and fires hooks. Tested and working. |
| Web app (claude.ai/code) | No | Runs on Anthropic servers, so the hook cannot run on your machine or reach a device on your network. |
Running several of these at once is fine. Every local session that fires hooks shows up, no matter which app started it.
- Tracks many sessions at once (up to 24), one row each. Sessions that need you are sorted to the top. If there are more sessions than fit, the extra ones are summed up in a footer like
+13 more: 5 work, 6 done, 2 idle. - Each row is labelled by its project folder. If two sessions share a folder, they get
#1and#2. - Status colors: gray for ready, amber for working, green for done, red (blinking) for needs you.
- A context bar and percentage per session. It goes green, then amber, then red as the window fills.
- The model name per session (opus, fable, and so on).
- On any state change the screen shows a big blinking circle in that color, with the session name, for about one second.
- Long folder names scroll left and right so you can read them. They pause for 5 seconds first.
- The 60 second "waiting for your input" nudge from Claude is ignored, so a finished session stays green instead of turning red on its own.
- Works with subagents and long agent runs. A session that keeps running tools stays marked working, and it goes back to working the moment the next tool starts, including after you approve a permission prompt or when a background agent keeps going after the main reply ends. Subagent activity counts toward the parent session, so you get one row per session, not one row per subagent.
- The display is set in one file (
src/display_config.h): driver, bus, pins, and resolution. The layout adjusts to the screen size. It ships set up for the T-Display-S3 and can be changed for other ESP32 boards with an ST7789 or ILI9341 screen.
| You need | Notes |
|---|---|
| LilyGO T-Display-S3 | ESP32-S3, 1.9 inch ST7789 IPS, 320x170. The non-touch version is fine. |
| USB-C cable | For flashing, and for power if you want. |
| Power | After flashing you can run it from any USB charger or power bank, or a LiPo battery on the JST connector. The USB cable only carries power and flashing. All data goes over WiFi. |
| 2.4 GHz WiFi | The ESP32-S3 radio only works on 2.4 GHz. Your computer can be on 5 GHz as long as it is on the same router. |
Does it work on any ESP32? Not by itself. The display driver, bus, and pins are set when you build the firmware, not at runtime. But they all live in one file (src/display_config.h). Set your bus, driver, pins, and resolution, flash again, and the layout adjusts to the new screen. See Porting.
- PlatformIO (the VS Code extension or the
piocommand line). jqandcurlon the computer that runs Claude Code (macOS or Linux).curlis already installed. Installjqwithbrew install jqorapt install jq.- Claude Code.
PlatformIO downloads Arduino_GFX and ArduinoJson for you. You do not install them by hand.
git clone <your-repo-url> claude-code-status-display
cd claude-code-status-display
cp src/secrets.h.example src/secrets.hPut your 2.4 GHz WiFi name and password in src/secrets.h. This file is git-ignored, so it is never committed.
#define WIFI_SSID "your-network"
#define WIFI_PASS "your-password"Plug in the board and flash:
pio run -e claude_display -t uploadIf upload fails with Invalid head of packet or a serial sync error, put the board in bootloader mode: hold BOOT, tap RST, release BOOT, then run upload again. Also close any serial monitor that is holding the port.
Open the serial monitor to see the IP it gets:
pio device monitor -b 115200You should see a line like WiFi: 192.168.x.y. The board also announces itself as claude-display.local over mDNS. Check that it answers:
curl http://claude-display.local/ # or curl http://192.168.x.y/
# -> claude-display okmkdir -p ~/.claude/hooks
cp hooks/claude-display.sh ~/.claude/hooks/
chmod +x ~/.claude/hooks/claude-display.shOpen ~/.claude/hooks/claude-display.sh and set BOARD. Keep the claude-display.local default if mDNS worked above. Otherwise use the IP:
BOARD="http://192.168.x.y"Add these seven events to ~/.claude/settings.json. If you already have a hooks block, add to it instead of replacing it. Use the full path, because Claude Code does not expand ~.
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/claude-display.sh", "async": true }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/claude-display.sh", "async": true }] }],
"PreToolUse": [{ "hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/claude-display.sh", "async": true }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/claude-display.sh", "async": true }] }],
"Notification": [{ "hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/claude-display.sh", "async": true }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/claude-display.sh", "async": true }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "/Users/YOU/.claude/hooks/claude-display.sh", "async": true }] }]
}
}Check the file is still valid JSON: jq . ~/.claude/settings.json >/dev/null && echo OK
Hooks load when a session starts, so open a new terminal tab and run claude. A row should appear with the folder name and move from working to done as you go. You can also send a fake event by hand:
curl -s http://claude-display.local/event -H 'Content-Type: application/json' \
-d '{"event":"Stop","session_id":"t1","cwd":"/x/demo","label":"demo","ctx":42,"model":"opus"}'| Claude Code hook | Row status | Color |
|---|---|---|
SessionStart |
ready | gray |
UserPromptSubmit |
working | amber |
PreToolUse (a tool is starting) |
working | amber |
PostToolUse (a tool ran) |
working | amber |
Stop |
done | green |
Notification (permission or attention) |
needs you | red, blinking, top |
Notification (60s idle nudge) |
ignored | none |
SessionEnd |
row removed | none |
PreToolUse and PostToolUse are what keep a busy session marked "working". They also clear a "needs you" once you approve a permission prompt and Claude runs the next tool. Long-running or agent sessions can go a long time without a Stop, so without this they would get stuck on their last state. PreToolUse matters for the tail end of a turn: Stop fires the moment Claude finishes its reply, even while background agents from that session are still running, so the row goes green. The next tool those agents start flips it back to amber right away instead of waiting for the tool to finish, which could be minutes into a long build. Tool activity from subagents carries the parent session id, so it updates the right row.
One mismatch is by design: Stop fires before any other stop hooks you have registered, so the row can turn green while your terminal still says "running stop hooks". The turn is over at that point; the board has no later event to wait for.
Display hardware is set in src/display_config.h. That is the file you edit for a different board:
- Bus:
BUS_PARALLEL8orBUS_SPI. - Driver:
DRIVER_ST7789orDRIVER_ILI9341. - Geometry:
TFT_WIDTH,TFT_HEIGHT,TFT_ROTATION,TFT_IPS, and the offsets. - Pins:
PIN_RST,PIN_BL,PIN_PWR(use-1if the board does not have one), and the bus pins.
The layout adjusts to the resolution. The number of rows comes from the screen height. The model, status, and context columns sit on the right, and the name fills the rest. Very narrow screens (under about 250px wide) will be tight, because the font is a fixed size.
Behaviour is set at the top of src/main.ino:
- Colors:
C_READY,C_WORKING,C_NEEDS,C_DONE(RGB565). - Rows and capacity:
ROW_H,MAX_SESSIONS. - Flash:
FLASH_MS(how long the circle shows). - Marquee:
HOLD_LandHOLD_R(pauses at each end) and the* 25// 25scroll speed indrawMarqueeLabel.
Host settings are in hooks/claude-display.sh:
BOARD: the board address (hostname or IP).- Context window: it is guessed as 200k, or 1M once a turn goes over 200k. Claude Code does not tell the hook the real window size. If you always use one size (for example a
[1m]model), setwindirectly.
Everything except the display works on any ESP32. To port, edit src/display_config.h and flash again:
- Pick the bus (
BUS_PARALLEL8orBUS_SPI) and driver (DRIVER_ST7789orDRIVER_ILI9341). - Set the pins for your wiring and the geometry (
TFT_WIDTH,TFT_HEIGHT,TFT_ROTATION, offsets). - If your board is not an ESP32-S3 dev variant, change
boardinplatformio.ini.
The layout re-flows to the new resolution on its own. To add a driver or bus that is not listed (for example ST7735, SSD1306, or software SPI), add one #elif branch to the bus and driver #if blocks in src/main.ino. That is the only code that touches the panel type.
A common 2.4 or 2.8 inch ILI9341 screen on a classic ESP32 board. Set these in src/display_config.h (the SPI pins go in the #ifdef BUS_SPI block):
// #define BUS_PARALLEL8
#define BUS_SPI // select SPI
// #define DRIVER_ST7789
#define DRIVER_ILI9341 // select ILI9341
// ILI9341 is fixed 240x320; rotation 1 = 320x240 landscape
#define TFT_WIDTH 240
#define TFT_HEIGHT 320
#define TFT_ROTATION 1
#define TFT_IPS false
#define TFT_COL_OFFSET 0
#define TFT_ROW_OFFSET 0
#define PIN_RST 4
#define PIN_BL 32 // backlight GPIO (or wire LED to 3V3 and use -1)
#define PIN_PWR -1 // no separate panel-power pin
// inside the #ifdef BUS_SPI block, classic ESP32 VSPI pins:
#define PIN_DC 2
#define PIN_CS 15
#define PIN_SCK 18
#define PIN_MOSI 23
#define PIN_MISO 19Then change platformio.ini to a plain ESP32 and remove the ESP32-S3 USB flags:
board = esp32dev
build_flags =
-DDISABLE_ALL_LIBRARY_WARNINGSWiring: panel VCC to 3V3, GND to GND, LED to PIN_BL (or 3V3), and SDI/MOSI, SCK, CS, DC, RESET, SDO/MISO to the pins above. The exact GPIOs depend on your wiring. These are common defaults.
- Upload fails with
Invalid head of packet: put the board in bootloader mode (hold BOOT, tap RST, release BOOT), and close any serial monitor first. claude-display.localdoes not resolve: mDNS does not work on some networks. Use the board IP inBOARD. Set a DHCP reservation on your router so the IP stays the same when it runs on its own.- Serial shows
WiFi: FAILED: the network must be 2.4 GHz. Check the name and password. A special character in the password can break the C string. WPA3-only routers may need WPA2/WPA3 mixed mode. - Nothing shows on the board: check the board answers (
curl http://<board>/), that you started a new session after adding the hooks, and thatjqis installed.
- Board and hardware: LilyGO T-Display-S3.
- Graphics: Arduino_GFX by moononournation.
- JSON parsing: ArduinoJson by Benoît Blanchon.
MIT. See LICENSE.
