Skip to content

Activity screen: live Claude Code session state from hooks#22

Open
tobby168 wants to merge 5 commits into
HermannBjorgvin:activity-streamfrom
tobby168:activity-and-hooks
Open

Activity screen: live Claude Code session state from hooks#22
tobby168 wants to merge 5 commits into
HermannBjorgvin:activity-streamfrom
tobby168:activity-and-hooks

Conversation

@tobby168

@tobby168 tobby168 commented May 18, 2026

Copy link
Copy Markdown
Contributor

Hi @HermannBjorgvin 👋 — back again with the next batch. Building on #18, this PR adds a third screen ("Activity") and a host-side hook integration that surfaces what every live Claude Code session is actually doing right now: which prompt the user just submitted, what tool is mid-execution, where each session is in its todo list, and whether the agent is still working or has stopped.

The whole approach — collecting session state via Claude Code's hooks and projecting it into a compact device-friendly view — is directly inspired by Open Island by @Octane0411, which does the same thing for a macOS Dynamic Island UI. The set of hook events to subscribe to, the per-session phase / current-tool / last-prompt model, and the IPC-via-file-handoff pattern are all things I learned from their architecture. No code copied — Open Island is GPLv3 Swift, our hook layer is a fresh Python + LVGL implementation written from scratch — but the design owes a lot to theirs.

Before / after

Today With this PR
Two screens: Usage (5h/7d quota) + Bluetooth Three screens. Activity shows headline, prompt, and todo list per session.
Daemon pushes API rate-limit numbers every 60s Daemon also forwards hook-collected session state immediately on every TodoWrite / prompt / Stop.
All-completed todo list reads as "session finished" Headline falls back to Doing: <tool> while the agent is still active.

Screenshots

📎 The four PNGs to attach are at /tmp/pr-screenshots/0[1-4]-activity-*.png. They were captured on a 368×448 portrait sibling board (the layout adapts per-board via #ifdef — slightly different font sizes / Y offsets on the 480×480 target, but identical structure: title row + optional prompt + headline + rounded panel + footer).

  1. Running, with in-progress todo — counter in green, headline shows the current todo's activeForm, panel lists the 5-row window centered on the in-progress item.
01-activity-running
  1. Running, headline fallback — no in-progress todo, but a tool is mid-call, so the headline becomes Doing: Bash. Counter still green.
02-activity-doing-tool
  1. Idle — agent has Stopped. Counter goes dim, headline shows (idle). Todo state preserved so you can see what was done.
03-activity-idle
  1. Empty state — no sessions visible to the daemon.
04-activity-empty

What's in here

daemon/clawdmeter_hook.py (new) — hook model adapted from Open Island

A tiny Claude Code hook entry point. Registered in ~/.claude/settings.json for PreToolUse / PostToolUse / Stop / UserPromptSubmit / SessionStart events. On each fire it reads the JSON payload from stdin, updates ~/.clawdmeter/state.json under an advisory flock, and writes {} to stdout so Claude proceeds unaffected. Fail-open on parse error.

Per-session record captures:

  • phaserunning while a turn is in flight, idle after Stop / SessionStart.
  • current_tool — most recent PreToolUse tool name, cleared only on Stop / UserPromptSubmit (not on PostToolUse — clearing there would flicker the headline to "idle" between two tools in the same turn).
  • last_user_prompt — text of the most recent UserPromptSubmit, truncated to 120 chars.
  • todos — full TodoWrite payload when applicable.
  • cwd, project, abbreviated model.

Stale sessions (>15 min idle) pruned on next hook fire.

daemon/claude_usage_daemon.py

  • load_activity_sessions() projects state.json into a compact wire schema (p/m/la/td, status as int, optional u/t/ph). activeForm only included on the in-progress todo to save bytes.
  • Connection loop tracks both last_state_mtime and the existing 60s API poll independently. Either firing triggers a fresh BLE push.
  • _serialize_capped() gracefully adapts payload size to the BLE 512-byte single-attribute spec cap by dropping the tail session's u first (cheapest UX loss), then trimming its tail todos, then dropping t, finally dropping the whole session.
  • Byte counting via encoded.encode("utf-8") length — important once user prompts can be CJK (each CJK char is 3 bytes UTF-8) so the trim loop converges on the real on-wire size.
  • ensure_ascii=False halves bandwidth for non-Latin prompts.
  • write_payload uses response=True so bleak falls back to ATT Long Write for payloads up to the spec cap — the device sees one onWrite per payload rather than fragmented writes.

Firmware

  • data.h: new ActivityData / SessionData / TodoItem structs + phase_t / todo_status_t enums (cap 3 sessions × 10 todos × 64-byte content, ~5 KB total).
  • ui.h: SCREEN_ACTIVITY enum slot + ui_update_activity() public API.
  • ui.cpp: Activity screen widget tree, swipe-cycle handler, headline fallback ladder, windowed todo rendering, rounded panel for progress + list.
  • main.cpp: parse_json extended with optional ActivityData* out-param; reads sessions / u / t / ph if present. Old payloads still parse identically.
  • ble.cpp: BLE_BUF_SIZE 512 → 4096 so onWrite buffers fit multi-session payloads with headroom.

Tap behaviour

Previously a tap on Usage/Splash toggled splash; the daemon-end was that nothing in the cycle reached the Bluetooth screen via touch. Now tap cycles the full sequence Splash → Usage → Activity → Bluetooth → Splash. PWR keeps its existing per-screen cycle. (Tap inside the Bluetooth reset zone still clears bonds; that handler consumes the click first.)

Test plan

  • Existing waveshare_amoled_216 env still builds cleanly with no warnings beyond the pre-existing ones
  • Hook script unit-test: piping synthetic JSON for each of the five event types yields the expected state.json mutations
  • Daemon _serialize_capped shrinks a deliberately oversized 3-session payload below 480 bytes, preserves the most-recently-active session's full data
  • End-to-end with real Claude Code session: my own TodoWrite calls fired the hook, the daemon picked up state.json on its 5s tick and pushed {...,"sessions":[{"p":"…","ph":1,"t":"Bash","u":"…","td":[…]}]} to the firmware, Activity screen rendered the corresponding headline / prompt / todo list within a couple of seconds.
  • Three headline states verified by screenshot: in-progress todo's activeForm, "Doing: " fallback, "(idle)" fallback.
  • Counter color verified: green for phase=running, dim for phase=idle.
  • Windowed todo list verified at edges: in-progress at index 0 slides window to start; at last index slides to end; mid-list centers.
  • Empty state verified.
  • Hardware verification on a real 480×480 AMOLED-2.16 board — I only have the 368×448 portrait sibling on hand, so the linked PNGs are from that board. Layout is identical structurally; the 216-targeted constants (ACT_TITLE_Y=50, ACT_FOOTER_Y=440, etc.) live in the same ACT_* block in ui.cpp and the screen should render the same content one font tier larger.

Known follow-up (not in this PR)

  • The 28pt Styrene B headline doesn't carry CJK glyphs; Chinese / Japanese activeForms render as empty boxes. Mostly a non-issue because activeForms come from Claude's own TodoWrite calls and are typically English, but a 28pt CJK font subset would close the gap if anyone needs it. (I have a working 16pt CJK font for the prompt + todo body that we kept out of this PR to avoid the +1MB flash cost — happy to send as a follow-up.)
  • The daemon could refresh expired access tokens itself using the refreshToken already stored in the same keychain blob — currently the user has to claude /login again when the token expires.

Credits

  • Open Island (@Octane0411) — design inspiration for the hooks-as-data-source architecture (the five events to subscribe to, the phase / current_tool / last_prompt model, the local-file IPC handoff). Their codebase is GPLv3 Swift; this implementation was written from scratch in Python + LVGL to keep your repo's licensing intact.

🤖 Generated with Claude Code

tobby168 and others added 2 commits May 18, 2026 09:54
Adds a host-side data path that surfaces live Claude Code session
state to the daemon (and onward to the device) without touching the
Anthropic API. A tiny hook script captures every TodoWrite / tool
boundary / prompt into ~/.clawdmeter/state.json, and the daemon
merges it into its BLE payload whenever either the API window or
the local state changes.

New: `daemon/clawdmeter_hook.py`
  * Registered as a Claude Code hook for `PreToolUse`, `PostToolUse`,
    `Stop`, `UserPromptSubmit`, and `SessionStart` events. Claude
    Code re-reads `~/.claude/settings.json` on every hook fire, so
    installation takes effect mid-session with no restart.
  * Reads the hook JSON from stdin, updates state.json under an
    advisory flock (multiple parallel sessions can fire concurrently),
    writes `{}` to stdout so Claude proceeds unaffected. Fail-open on
    parse error.
  * Per-session record carries:
      - `phase`: "running" while a turn is in flight, "idle" after
        Stop / SessionStart. PreToolUse / PostToolUse /
        UserPromptSubmit set running; only Stop resets to idle.
      - `current_tool`: tool name from most recent PreToolUse,
        cleared only on Stop / UserPromptSubmit (not on PostToolUse
        — clearing there would flicker the headline to "(idle)"
        between two tool calls in the same turn).
      - `last_user_prompt`: text of the most recent UserPromptSubmit,
        truncated to 120 chars.
      - `todos`: full TodoWrite payload when tool_name == "TodoWrite".
      - `cwd`, `project` (cwd basename), `model` (abbreviated).
  * Sessions idle longer than 15 minutes are pruned on next hook fire.

`daemon/claude_usage_daemon.py`:
  * `load_activity_sessions()` projects state.json into a compact
    over-the-wire schema (`p`/`m`/`la`/`td`, status 0/1/2, plus
    optional `u`/`t`/`ph`). `activeForm` is only sent on the
    in-progress todo to save bytes.
  * Connection loop tracks both `last_state_mtime` and the 60s API
    poll independently; either firing triggers a fresh push. Cached
    API fields are reused across state-only ticks so the device
    always gets a coherent merged view.
  * `_serialize_capped()` adapts payload size to the BLE single-attr
    512-byte spec cap with graduated trim: drop tail session's `u`
    first (cheapest UX loss), then trim its tail todos, then drop
    `t`, finally drop the whole session.
  * Byte counting uses `encoded.encode("utf-8")` length, not Python
    `str` length — important once user prompts can be CJK (1 char =
    3 bytes UTF-8) so the trim loop converges on actual on-wire size.
  * `json.dumps(..., ensure_ascii=False)` keeps CJK as 2–3 byte UTF-8
    instead of 6-byte `\uXXXX` escapes. ArduinoJson decodes either
    transparently; this just halves bandwidth for non-Latin prompts.
  * `write_payload` uses `response=True` (Write Request → ATT Long
    Write) so the device sees one onWrite per payload up to the
    spec cap.

`install-mac.sh`:
  * New step idempotently appends the hook command to five hook
    events in `~/.claude/settings.json`, preserving any existing
    hooks from other tools (e.g. vibe-island). Re-running is a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A third screen between Usage and Bluetooth, cycled to with tap (or
PWR). Renders the session-keyed state collected by the daemon's hook
integration in the prior commit. The headline answers "what is this
agent doing right now," the panel below answers "what's the plan."

Tap behaviour widened: previously a tap on Usage/Splash toggled
splash; now it cycles the full screen sequence
Splash → Usage → Activity → Bluetooth → Splash. PWR keeps its
existing per-screen cycle.

Multi-session navigation:
  * Title bar shows project on line 1 and model on line 2 (subtitle
    style), with an `N/M` page counter on the right colored by the
    current session's phase (green=running, dim=idle).
  * Horizontal swipe left/right cycles between sessions via LVGL's
    LV_EVENT_GESTURE on the screen root. `lv_indev_wait_release`
    debounces so one swipe = one session change.
  * Empty-state placeholder when no sessions are active or all are
    stale (>10 min idle), prompting "Start Claude Code in any
    terminal".

Per-session render:
  * Optional user-prompt row directly below the title (the most
    recent UserPromptSubmit text, one dim ellipsized line). Hidden
    when the daemon hasn't reported a prompt.
  * Headline (the focal point) sources in priority order:
        in-progress todo's activeForm
      → "Doing: <current_tool>"
      → "(idle)" when phase=idle
      → "(no todos)"
    So an all-completed todo list with a live tool call now reads
    "Doing: Bash" instead of looking finished.
  * Todo list windowed to 5 rows, centered on the in-progress item
    (slides to start/end edge when centering would overflow). Status
    icons via ASCII prefix ([x] / [>] / [ ]) coloured green / accent
    / dim. List + progress counter sit inside a rounded `make_panel`
    card, matching the Usage and Bluetooth screen design language.
  * Footer reports time since the daemon last saw activity.

Data types (`firmware/src/data.h`):
  * New ActivityData / SessionData / TodoItem structs (cap 3
    sessions × 10 todos × 64-byte content, ~5 KB total).
  * `phase_t` enum (PHASE_IDLE / PHASE_RUNNING) mirrors the daemon's
    0/1 ints.

Wire format (`firmware/src/main.cpp`):
  * `parse_json` now also populates an ActivityData out-param from
    the `sessions` array if present. Old payloads without sessions
    still work — UsageData fields are read as before.
  * `ui_update_activity` invoked after `ui_update` on every
    successful parse.

`firmware/src/ble.cpp`:
  * `BLE_BUF_SIZE` bumped from 512 to 4096 so onWrite buffers
    comfortably fit multi-session payloads with headroom; the BLE
    spec still caps a single attribute write at 512 bytes, which
    the daemon's `_serialize_capped` enforces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@HermannBjorgvin

Copy link
Copy Markdown
Owner

Really cool concept. I played around a bit with making a control screen with some buttons on the touch screen but felt it was complicating the UI a bit.

I like this idea, especially showing the to-do list and being able to follow along with the session.

I'm wondering about one thing which is multiple sessions and how those are handled?

If they are not handled in this PR might I suggest using the side buttons to navigate between sessions? Or a swipe action on the touch screen

Also perhaps it could be good to make the install.sh script make the hook installation optional?

Thanks for the PR and the screenshots! I actually have one of the 1.8" boards btw I'll have to test this on that one as well, it's a really nice small form factor, I'm able to fit it in the small pocket of my jeans which is fun. Is yours the waveshare model with two buttons and the curved glass display?

Just thought of an extra feature, an optional sound played when a session idles could be fun, my colleague has his computer play the elevator doorbell sound from the TV show Severance which I thought was quite dystopian but funny.

@HermannBjorgvin

Copy link
Copy Markdown
Owner

I hope it's ok to leave your PR open for a bit to collaborate on it.

I want to flash the firmware on my end and test run this at work this week and get a feel for it on my end before merging but I'm totally sold on the idea!

@HermannBjorgvin

Copy link
Copy Markdown
Owner

Also just had another UX thought sorry but it might be a good UX experience if this were not a separate screen but part of the main screen which could transform when sessions are active and transform back to the default screen when idle.

Not sure what animations capabilities and limitations lvgl and the hardware have but it could be an interesting idea for a V2 of this feature don't think it's necessary for the initial version of this feature, maybe we'll get some ESP32 UX genius that does it for us for free 😄

tobby168 added a commit to tobby168/Clawdmeter that referenced this pull request May 18, 2026
Per upstream review feedback (HermannBjorgvin#22 ): rather
than parking the Activity view as a separately cycled screen, fold it
into the splash "default" screen. When the daemon reports active
sessions the splash transforms to show the per-session headline /
prompt / todo list; when all sessions go idle / stale the Clawd
animation cross-fades back in.

`firmware/src/ui.h`:
  * Drop `SCREEN_ACTIVITY` from the screen enum entirely. Activity now
    lives inside SCREEN_SPLASH.

`firmware/src/ui.cpp`:
  * New `apply_default_screen_state()` decides what's visible inside
    the splash slot based on `cached_activity.session_count`. Calls
    `lv_obj_fade_in/out` against splash root and activity container
    for a 280ms cross-fade transform.
  * `ui_show_screen()` resets opacity on both containers before
    switching screens so a leftover mid-fade doesn't leave anything
    invisible.
  * `ui_update_activity()` now triggers `apply_default_screen_state`
    so a freshly arriving session morphs the splash immediately
    rather than waiting for the next manual screen swap.
  * `ui_cycle_screen()` and `global_click_cb()` collapse from 4-way
    to 3-way cycles: Splash (morphable) → Usage → Bluetooth → Splash.
  * `activity_gesture_cb` swipe-to-cycle-sessions now gates on
    `current_screen == SCREEN_SPLASH` (since that's where Activity
    widgets live now).
  * Logo overlay still hidden on splash regardless of morph state —
    both the Clawd canvas and the Activity title want a clean left
    margin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tobby168 added a commit to tobby168/Clawdmeter that referenced this pull request May 18, 2026
Per upstream review (HermannBjorgvin#22 ): the hook write to ~/.claude/settings.json
should be optional. Some users will want only the 5h/7d usage panels
without the per-session Activity view, or already have a busy
settings.json they'd rather hand-edit.

Adds a `[Y/n]` prompt before step 3b runs. Default stays "install"
to match prior behaviour; pressing `n` skips the settings.json write
entirely and continues with the rest of the installer (venv, plist,
launchctl load) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tobby168 and others added 2 commits May 18, 2026 16:27
Per upstream review feedback (HermannBjorgvin#22 ): rather
than parking the Activity view as a separately cycled screen, fold it
into the splash "default" screen. When the daemon reports active
sessions the splash transforms to show the per-session headline /
prompt / todo list; when all sessions go idle / stale the Clawd
animation cross-fades back in.

`firmware/src/ui.h`:
  * Drop `SCREEN_ACTIVITY` from the screen enum entirely. Activity now
    lives inside SCREEN_SPLASH.

`firmware/src/ui.cpp`:
  * New `apply_default_screen_state()` decides what's visible inside
    the splash slot based on `cached_activity.session_count`. Calls
    `lv_obj_fade_in/out` against splash root and activity container
    for a 280ms cross-fade transform.
  * `ui_show_screen()` resets opacity on both containers before
    switching screens so a leftover mid-fade doesn't leave anything
    invisible.
  * `ui_update_activity()` now triggers `apply_default_screen_state`
    so a freshly arriving session morphs the splash immediately
    rather than waiting for the next manual screen swap.
  * `ui_cycle_screen()` and `global_click_cb()` collapse from 4-way
    to 3-way cycles: Splash (morphable) → Usage → Bluetooth → Splash.
  * `activity_gesture_cb` swipe-to-cycle-sessions now gates on
    `current_screen == SCREEN_SPLASH` (since that's where Activity
    widgets live now).
  * Logo overlay still hidden on splash regardless of morph state —
    both the Clawd canvas and the Activity title want a clean left
    margin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per upstream review (HermannBjorgvin#22 ): the hook write to ~/.claude/settings.json
should be optional. Some users will want only the 5h/7d usage panels
without the per-session Activity view, or already have a busy
settings.json they'd rather hand-edit.

Adds a `[Y/n]` prompt before step 3b runs. Default stays "install"
to match prior behaviour; pressing `n` skips the settings.json write
entirely and continues with the rest of the installer (venv, plist,
launchctl load) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tobby168

Copy link
Copy Markdown
Contributor Author

Thanks for the thoughtful review — pushed two more commits addressing the feedback. PR is now 4 commits total.

Main-screen transform → done

You convinced me. New commit e3edacd ("Default screen morphs between splash animation and Activity content"):

  • SCREEN_ACTIVITY enum is gone. Activity now lives inside the splash slot.
  • When the daemon reports active sessions, splash cross-fades to the Activity widget tree (project + model + prompt + headline + todo panel).
  • When all sessions go idle / stale, splash cross-fades back to the Clawd animation.
  • 280ms lv_obj_fade_in/fade_out for the transition.
  • PWR cycle / tap cycle collapse from 4-way to 3-way: Splash (morphable) → Usage → Bluetooth → Splash.

📎 Two new screenshots demonstrating the morph are at /tmp/pr-screenshots/05-morph-active.png (splash with sessions → Activity) and /tmp/pr-screenshots/06-morph-idle.png (idle → Clawd animation).

The "no sessions" Activity placeholder from earlier screenshot 04 is now redundant — the splash animation IS the empty state. Removed from the PR description's screenshot list mentally.

Optional hook install → done

New commit c66097d: install-mac.sh now prompts before writing to ~/.claude/settings.json. Default stays "install" so the happy path is one extra Enter; pressing n skips the settings.json write entirely and continues with the rest of the installer.

Multi-session navigation — already in this PR

Sorry I undersold this in the original description. Multi-session IS handled:

  • Title row right side shows an N/M counter coloured by the current session's phase (green = running, dim = idle), so you can tell at a glance how many sessions exist and which one's alive.
  • Horizontal swipe left/right cycles between sessions. The window of visible todos (5 rows, centered on the in-progress item with edge-clamping) re-renders for the new session.
  • Code is activity_gesture_cb in ui.cpp — attached to lv_screen_active() so the gesture event finds the handler regardless of which widget got the press.
  • Trim heuristic in the daemon's _serialize_capped keeps at least one session's data intact even when total payload would exceed the BLE 480-byte budget — so you never see "session 1/3" with nothing under it.

If you'd rather have side-button nav instead of / in addition to swipe (your suggestion in the review), happy to wire that — it'd be ~5 lines in main.cpp's PWR handler. Felt heavy-handed for the original cut but say the word.

Idle sound → fun, follow-up

Definitely fun. Open Island has the same feature (chime on tool finish). The 1.8" board has an ES8311 codec + PA + speaker pin populated so it's hardware-feasible there. I haven't checked your 2.16" board's audio path yet — does it have a speaker output, or just a piezo / nothing? Either way feels like its own scope, happy to ship as a separate PR once the rest of this lands.

1.8" board

Confirmed — mine is the Waveshare ESP32-S3-Touch-AMOLED-1.8: SH8601 display, FT3168 touch, XCA9554 IO expander, AXP2101 PMU, 2 buttons (BOOT + PWR), curved glass. Fits in a jeans coin pocket — I had the same "oh this is cute" moment.

I've got the board ported on my fork (tobby168/Clawdmeter main) — display + touch + IO expander wired, layout retuned for 368×448 portrait, PWR button routed through the XCA9554. Saw you have #5 open already so I haven't sent it as a PR; happy to either send a separate PR or just point you at specific commits if you want to crib from them. Your call.

V2 idea: dynamic-island-style transform on the Usage screen too?

Just thinking out loud: same apply_default_screen_state pattern could collapse a session indicator card into the bottom of the Usage screen (where the "Perusing…" spinner currently lives), so you'd see a one-line live status while still seeing the 5h/7d bars. Not in scope for this PR — but probably the right next move if anyone wants the per-session info without leaving the Usage screen.

@HermannBjorgvin

Copy link
Copy Markdown
Owner

This is brilliant! Can't wait to test this out tomorrow.

Thanks for the clarification on the multiple sessions I was reviewing this from my phone so I couldn't dive deep into the code sorry.

The 1.8" seems to be a popular device and I've got a couple pull requests from people porting it but I've held off on merging any alternative hardware pull requests since it was a bit of an effort to review them all.

I think it makes most sense to merge this PR as the 1.8" port.

The dynamic island type UX would be the way to go I agree I also had that thought about pushing the loading bar down, and thinking about it now the Bluetooth screen doesn't really need to be it's own screen either I think we could probably just show it by default when not connected and maybe use the hold-power-button action when powering on to clear any previous pairings, it's a pattern people are used to with Bluetooth headphones already and it would eliminate the concept of having different screens which is a plus.

I'll try this out tomorrow, really looking forward to seeing it in action!

@HermannBjorgvin

Copy link
Copy Markdown
Owner

Tested this at work today, vibe coded a bash equivalent of your python daemon session handling to get it to work temporarily, probably not ideal in the long run to have two daemons but I think tackling it a future PR makes more sense.

Maybe I'll create a PR into your branch with my bash Daemon.

Here's a picture of the activity screen on the 2.16" device. I think my main feedback would be UX related, functionally it's really good.

I noticed that my sessions didn't have a todo list a lot of the time.. I was kind of stuck doing more fine grained work today so didn't enter plan mode a lot unfortunately.

I also had a couple sessions going and the fading animation when idle auto switching to showing a different session kind of drew the eye a lot to grab my attention, not sure if that's good or bad tbh.

The other thing that stuck out was the text size, there's a bit of a readability issue that I think could be solved by increasing the text size a bit. Maybe consider removing the last active text on the bottom I feel like the information from that for me is less relevant generally but you may disagree?

On the Todo lists maybe into display if there is a Todo list, and consider removing the box from around it, more room for larger text.

Also consider scrolling text for the session title?

And a thought on the doing section, maybe we should show information with it such as the command being run? I would even consider removing the last prompt display to give more space to it?

Sorry for the barrage of comments, please tell me what you think, I'm kind of in brainstorming mode about what information to prioritize on such a small screen

image

image

Round of UX changes responding to PR HermannBjorgvin#22 review feedback:

  * Bump fonts across the panel — model/prompt/todos 20→24,
    todo row height 30→34. Title stays at 28pt; what changes is
    that the title now marquee-scrolls (LV_LABEL_LONG_SCROLL_CIRCULAR)
    so long project|model strings stay legible without truncation.
  * Drop the "last active Xs ago" footer — the title row, last
    prompt, and headline already make session liveness obvious.
  * Hide the todo panel entirely when a session has no todos
    instead of showing an empty frame.
  * Extend the "Doing:" headline to "<tool> | <args>" so the user
    sees what command is actually mid-flight. The hook now records
    a short summary per tool (Bash command, file path, glob
    pattern, etc) into state.json as `current_tool_args`; the
    daemon forwards it as `ta` and the firmware paints it after
    the tool name. Tool-name only when args summary is empty.
  * Don't fade the Activity↔Splash morph on session re-renders
    (swipe, new BLE data with sessions still present) — only fade
    on the actual state transition between "no sessions" and
    "has sessions". Tracked with a static bool in
    apply_default_screen_state().
  * Drop the standalone "No active sessions" placeholder — the
    splash animation now is the empty state, so the placeholder
    label is redundant.

Daemon _serialize_capped() now drops fields in priority order
last → first: `u` (prompt) → `ta` (args) → tail todos → `t` (tool
name) → whole session. Keeps as many sessions visible as possible
when 3× full sessions overflow the 512-byte BLE attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tobby168 added a commit to tobby168/Clawdmeter that referenced this pull request May 20, 2026
Round of UX changes responding to PR HermannBjorgvin#22 review feedback:

  * Bump fonts across the panel on both boards (1.8" title 20→24,
    2.16" model/prompt/todos 20→24, todo row height 24→26/30→34).
    On the 1.8" the prompt + todo rows stay at the 16pt CJK font;
    bumping them would require generating font_cjk_20 (~+1MB flash).
  * Drop the "last active Xs ago" footer — the title row, last
    prompt, and headline already make session liveness obvious.
  * Hide the todo panel entirely when a session has no todos
    instead of showing an empty frame.
  * Marquee-scroll the title row so long project/model strings
    survive without truncation.
  * Extend the "Doing:" headline to "<tool> | <args>" so the user
    sees what command is actually mid-flight. The hook now records
    a short summary per tool (Bash command, file path, glob
    pattern, etc) into state.json as `current_tool_args`; the
    daemon forwards it as `ta` and the firmware paints it after
    the tool name. Tool-name only when args summary is empty.
  * Don't fade the Activity↔Splash morph on session re-renders
    (swipe, new BLE data with sessions still present) — only fade
    on the actual state transition between "no sessions" and
    "has sessions". Tracked with a static bool in
    apply_default_screen_state().

Daemon _serialize_capped() now drops fields in priority order
last → first: `u` (prompt) → `ta` (args) → tail todos → `t` (tool
name) → whole session. Keeps as many sessions visible as possible
when 3× full sessions overflow the 512-byte BLE attribute.
@tobby168

tobby168 commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 4bbcfdd covering most of the UX feedback. Quick rundown:

Done

  • Bigger fonts — model/prompt/todos 20pt → 24pt, todo row height 30 → 34. Title stays at 28pt since it now marquee-scrolls (LV_LABEL_LONG_SCROLL_CIRCULAR), so long project | model strings stay legible without truncating.
  • No more "last active Xs ago" footer — agreed, the title row + prompt + headline already carry the liveness signal.
  • Hide the todo panel when empty — when the session has no todos, the whole framed card disappears so the headline gets all the visual weight on its own. Makes a big difference for short "just run a one-shot Bash" sessions.
  • Doing: <tool> | <args> — the hook now records a short summary per tool (Bash → first 80 chars of command, Read/Write/Edit → file basename, Grep/Glob → pattern, Task → description, etc) into state.json as current_tool_args. Daemon forwards it as ta. The Activity headline renders as e.g. >> Bash | git status --short.
  • No fade on session auto-switchapply_default_screen_state() now tracks a last_morph_active bool. The 280ms fade only fires on the actual transition between "no sessions" and "has sessions", not on every re-render. Swiping between sessions / new BLE data on existing sessions is instant.

Two things we can discuss

  • Kept the panel frame around todos. I think it's worth the trade for design-language consistency with the Usage and BT panels — without it the todo list reads as floating loose text against the background. With the empty-state now hidden, the panel only shows when there's real content inside, which I think reads cleaner than I expected.
  • Kept the last-prompt line. Genuinely useful to me — looking at the device from across the room, "what did I just ask it to do" anchors the rest of the screen. Especially handy when juggling multiple sessions. Cost is one extra row; with the fonts now bigger the screen still doesn't feel cramped. If you still feel it's not pulling its weight after living with it for a day or two happy to revisit.

Screenshots from the 1.8" board (368×448)

Running, no todos Running, with todos Idle
>> Bash | git status --short — args appended to the Doing line. Last-prompt quote, no panel frame because no todos. activeForm headline takes priority over Doing; panel frame returns because there's content inside; in-progress todo highlighted. (idle) headline + counter both dimmed; panel hidden; splash didn't fade-out because state stayed "has sessions".

1.8" port

"I think it makes most sense to merge this PR as the 1.8" port."

Happy to roll that in. The port is currently on my main. Want me to:

  1. Rebase / cherry-pick the port commits onto this PR branch (will balloon to ~10 commits but they're all coherent — display driver, IO expander, board-conditional layout, CJK font for the prompt), or
  2. Open a separate stacked PR off this one (cleaner review boundary but two PRs to land)?

I lean (1) since you said "merge this PR as the 1.8" port" but (2) is fine too.

Dynamic-island BT screen

"the Bluetooth screen doesn't really need to be its own screen either ... use the hold-power-button action when powering on to clear any previous pairings"

100% agreed, I'll prototype that after this PR settles. Hold-PWR-on-boot to clear pairings is a clean pattern. Probably a separate PR.

Single daemon

"probably not ideal in the long run to have two daemons but I think tackling it a future PR makes more sense"

Agreed. Either your bash daemon absorbs the hook protocol, or my python daemon picks up the OAuth + API polling, depending on which side has more to lose. Happy to take a look once you push the bash version.

Idle sound

Still up for it as a follow-up PR. Will check the 2.16" board's audio path before committing to a UX.

@HermannBjorgvin

Copy link
Copy Markdown
Owner

Kept the panel frame around todos

Fair point I hadn't considered the continuity of it.

Kept the last-prompt line

Also fair, if it's a useful signal for you then it's probably the same for others, and kind of helps to identify the session like you said.

I lean (1) since you said "merge this PR as the 1.8" port" but (2) is fine too.

I think merging #25 is the right call in hindsight yes.


I was thinking that perhaps we should merge this PR into a sort of staging branch for the feature? I created a branch called activity-stream where we could merge it first.

Reason being is I think I would want to merge it with the new dynamic island UX and I wanted to have a go at seeing if I could help out there maybe adding the Lucide icons for todo's or seeing if I can work on that screenless UX idea so it would be good to be able to create a PR myself on the branch and get you as a reviewer on those.

What do you think?

@tobby168 tobby168 changed the base branch from main to activity-stream May 21, 2026 01:18
@tobby168

Copy link
Copy Markdown
Contributor Author

Sounds great — done, just retargeted this PR from mainactivity-stream. Looking forward to seeing what you have in mind for the dynamic island + Lucide icons; happy to be a reviewer.

One small heads-up on the staging branch: once #25 lands on main, activity-stream will diverge from main quite a bit (refactor moves ui.cpp, splash.cpp, etc into the board-conditional-free shared layout). Easiest path is probably to rebase activity-stream onto main after #25 merges and let me re-resolve any conflicts in this PR's head. Just shout when you're ready.

I think merging #25 is the right call in hindsight yes.

🎉 — I'll wait for your review on #25.

@HermannBjorgvin

Copy link
Copy Markdown
Owner

Will rebase after #25 is merged yes, just approved sorry I'm used to Azure DevOps at work so I didn't see the "approve" button where it normally is for me :D

@eightsixeight

Copy link
Copy Markdown

i merged your PR to my local dev version of clawmeter and with a few ajustments it works great,

for Cli mode,

I wonder if we can also grab the norml chat or cowork

also had to create install windows and so on for this to register and trigger hooks i can supply if needed

dsk2k added a commit to dsk2k/Clawdmeter that referenced this pull request May 27, 2026
Branches three open PRs into one working stack on a Windows host with the
480x480 Waveshare AMOLED-2.16:

 - Base:           PR HermannBjorgvin#26 (dilbery's USB CDC transport)
 - Plus daemon:    cross-platform port + Windows hook-compat
 - Plus feature:   PR HermannBjorgvin#22 (tobby168's Activity screen / Claude Code hooks)
 - Plus 3 fixes:   firmware buffers + parse for multi-session payloads

Windows daemon port (vs PR HermannBjorgvin#26)
-------------------------------
daemon/claude_usage_daemon.py:
- find_port() on win32 uses pyserial list_ports filtered by USB VID
  (303A Espressif, 10C4 SiLabs, 1A86 WCH, 0403 FTDI). Locale-independent —
  works on non-English Windows where caption strings are translated.
- termios import + HUPCL clear gated to sys.platform != "win32".
- write_payload uses ensure_ascii=False so UTF-8 stays raw (em-dashes etc
  are 3 bytes instead of 6-byte \uXXXX escapes; smaller payloads, less
  pressure on ArduinoJson on the firmware side).

daemon/clawdmeter_hook.py (from PR HermannBjorgvin#22):
- fcntl is Unix-only; replace with msvcrt.locking on win32.

daemon/install_hooks_win.py (new):
- Windows analogue of install-mac.sh's inline-Python hook registration.
  Backs up settings.json before writing, idempotent.

Windows host scripts (from PR HermannBjorgvin#23): flash-win.ps1, install-win.ps1,
setup-win.ps1, uninstall-win.ps1, daemon/run-daemon.ps1. Brought along so
this branch is buildable + installable on Windows without cherry-picking.

Activity backport on USB CDC (vs PR HermannBjorgvin#22)
----------------------------------------
firmware/src/data.h:  PR HermannBjorgvin#22 ActivityData / SessionData / TodoItem structs.
firmware/src/ui.h:    +SCREEN_ACTIVITY enum + ui_update_activity() API.
firmware/src/ui.cpp:  PR HermannBjorgvin#22 init_activity_screen / render_activity /
                      activity_gesture_cb / ui_update_activity grafted onto
                      PR HermannBjorgvin#26's ui.cpp; cycle order Splash > Usage > Link >
                      Activity > Usage.
firmware/src/main.cpp: parse_json gets optional ActivityData* out-param;
                       sessions / todos extracted into the struct;
                       ui_update_activity() called after each successful parse.

daemon/claude_usage_daemon.py:
- load_activity_sessions() + state_file_mtime() from PR HermannBjorgvin#22.
- Main loop tracks last_state_mtime; payload includes "sessions" whenever
  the file changes OR a fresh API poll happens.

Three firmware fixes needed for multi-session payloads
------------------------------------------------------
Real-world payloads with 2 sessions x 5-10 todos each routinely hit
700-1000 bytes — well past anything PR HermannBjorgvin#26's defaults handled.

1. firmware/src/serial_link.cpp: LINE_BUF_SIZE / DATA_BUF_SIZE 192 -> 4096.
   192 was tuned for the ~120-byte Usage-only payload; longer lines were
   silently dropped at the line-accumulator level.

2. firmware/src/main.cpp: Serial.setRxBufferSize(4096) before Serial.begin.
   USB-CDC RX defaults to ~256 bytes; a 798-byte payload arrived as
   strlen=317 on the firmware side (truncated at receive). Bumping the
   buffer is the actual fix (CFG_TUD_CDC_RX_BUFSIZE redefine is ignored
   because Arduino-ESP32 overrides it via CONFIG_TINYUSB_CDC_RX_BUFSIZE).

3. firmware/src/main.cpp: parse_json is two-pass.
   - Pass 1 is a Filter()'d deserialization that only extracts the Usage
     scalars (s/sr/w/wr/st/ok). A tiny doc, always succeeds.
   - Pass 2 is a best-effort full parse for the "sessions" array. If it
     fails (oversized, mid-line truncation, unicode oddity) we leave
     ActivityData empty but still ACK the payload upstream — Usage stays
     intact, Activity falls back to its empty render state.

Tested
------
Waveshare ESP32-S3-Touch-AMOLED-2.16 on Windows 10 (Dutch locale, Realtek
BT 5.1 adapter unused, USB-C only). Two parallel Claude Code sessions on
the same host:
  - 11+ todos across two sessions, ~1KB payload, ACK'd reliably.
  - Activity screen renders project name, in-progress todo activeForm,
    counter coloured by phase, todo list windowed around the in-progress
    item. PWR cycles Usage <-> Link <-> Activity.
  - state.json updates on every PreToolUse/PostToolUse/UserPromptSubmit/
    Stop/SessionStart; daemon picks it up on its 5s tick.
dsk2k added a commit to dsk2k/Clawdmeter that referenced this pull request May 27, 2026
Branches three open PRs into one working stack on a Windows host with the
480x480 Waveshare AMOLED-2.16:

 - Base:           PR HermannBjorgvin#26 (dilbery's USB CDC transport)
 - Plus daemon:    cross-platform port + Windows hook-compat
 - Plus feature:   PR HermannBjorgvin#22 (tobby168's Activity screen / Claude Code hooks)
 - Plus 3 fixes:   firmware buffers + parse for multi-session payloads

Windows daemon port (vs PR HermannBjorgvin#26)
-------------------------------
daemon/claude_usage_daemon.py:
- find_port() on win32 uses pyserial list_ports filtered by USB VID
  (303A Espressif, 10C4 SiLabs, 1A86 WCH, 0403 FTDI). Locale-independent —
  works on non-English Windows where caption strings are translated.
- termios import + HUPCL clear gated to sys.platform != "win32".
- write_payload uses ensure_ascii=False so UTF-8 stays raw (em-dashes etc
  are 3 bytes instead of 6-byte \uXXXX escapes; smaller payloads, less
  pressure on ArduinoJson on the firmware side).

daemon/clawdmeter_hook.py (from PR HermannBjorgvin#22):
- fcntl is Unix-only; replace with msvcrt.locking on win32.

daemon/install_hooks_win.py (new):
- Windows analogue of install-mac.sh's inline-Python hook registration.
  Backs up settings.json before writing, idempotent.

Windows host scripts (from PR HermannBjorgvin#23): flash-win.ps1, install-win.ps1,
setup-win.ps1, uninstall-win.ps1, daemon/run-daemon.ps1. Brought along so
this branch is buildable + installable on Windows without cherry-picking.

Activity backport on USB CDC (vs PR HermannBjorgvin#22)
----------------------------------------
firmware/src/data.h:  PR HermannBjorgvin#22 ActivityData / SessionData / TodoItem structs.
firmware/src/ui.h:    +SCREEN_ACTIVITY enum + ui_update_activity() API.
firmware/src/ui.cpp:  PR HermannBjorgvin#22 init_activity_screen / render_activity /
                      activity_gesture_cb / ui_update_activity grafted onto
                      PR HermannBjorgvin#26's ui.cpp; cycle order Splash > Usage > Link >
                      Activity > Usage.
firmware/src/main.cpp: parse_json gets optional ActivityData* out-param;
                       sessions / todos extracted into the struct;
                       ui_update_activity() called after each successful parse.

daemon/claude_usage_daemon.py:
- load_activity_sessions() + state_file_mtime() from PR HermannBjorgvin#22.
- Main loop tracks last_state_mtime; payload includes "sessions" whenever
  the file changes OR a fresh API poll happens.

Three firmware fixes needed for multi-session payloads
------------------------------------------------------
Real-world payloads with 2 sessions x 5-10 todos each routinely hit
700-1000 bytes — well past anything PR HermannBjorgvin#26's defaults handled.

1. firmware/src/serial_link.cpp: LINE_BUF_SIZE / DATA_BUF_SIZE 192 -> 4096.
   192 was tuned for the ~120-byte Usage-only payload; longer lines were
   silently dropped at the line-accumulator level.

2. firmware/src/main.cpp: Serial.setRxBufferSize(4096) before Serial.begin.
   USB-CDC RX defaults to ~256 bytes; a 798-byte payload arrived as
   strlen=317 on the firmware side (truncated at receive). Bumping the
   buffer is the actual fix (CFG_TUD_CDC_RX_BUFSIZE redefine is ignored
   because Arduino-ESP32 overrides it via CONFIG_TINYUSB_CDC_RX_BUFSIZE).

3. firmware/src/main.cpp: parse_json is two-pass.
   - Pass 1 is a Filter()'d deserialization that only extracts the Usage
     scalars (s/sr/w/wr/st/ok). A tiny doc, always succeeds.
   - Pass 2 is a best-effort full parse for the "sessions" array. If it
     fails (oversized, mid-line truncation, unicode oddity) we leave
     ActivityData empty but still ACK the payload upstream — Usage stays
     intact, Activity falls back to its empty render state.

Tested
------
Waveshare ESP32-S3-Touch-AMOLED-2.16 on Windows 10 (Dutch locale, Realtek
BT 5.1 adapter unused, USB-C only). Two parallel Claude Code sessions on
the same host:
  - 11+ todos across two sessions, ~1KB payload, ACK'd reliably.
  - Activity screen renders project name, in-progress todo activeForm,
    counter coloured by phase, todo list windowed around the in-progress
    item. PWR cycles Usage <-> Link <-> Activity.
  - state.json updates on every PreToolUse/PostToolUse/UserPromptSubmit/
    Stop/SessionStart; daemon picks it up on its 5s tick.
@dsk2k

dsk2k commented May 27, 2026

Copy link
Copy Markdown

Thanks for this PR — the hook-driven session model is brilliant, and the cross-session "what is Claude actually doing right now" view turned out to be exactly the thing I wanted on my desk monitor. Even watching another concurrent Claude Code session's todo list update in real time on the device while I work is mildly addictive.

Tested on real hardware

Hardware: Waveshare ESP32-S3-Touch-AMOLED-2.16 (the 480×480 target — your PR description notes you only had the 368×448 portrait sibling on hand for screenshots).
Host: Windows 10 (Dutch locale), Python 3.14.
Transport: USB CDC (rebased onto @dilbery's PR #26 instead of BLE, because Windows BLE-stack hell on the same hardware sent me down a separate rabbit hole — that's a different conversation).

End-to-end verified: two parallel Claude Code sessions on the host stream simultaneously on the Activity screen — different cwds, both visible. PWR-button cycles Splash → Usage → Link → Activity. Counter colour switches green↔dim on phase changes. In-progress activeForm lands as the headline, todo list windows correctly around the in-progress item.

Branch with the working backport

https://github.com/dsk2k/Clawdmeter/tree/usb-transport-win — single combined commit on top of PR #26's usb-transport, plus my Windows-host scripts from PR #23. Three pieces relevant to this PR specifically:

1. Windows hook-script compat — fcntlmsvcrt

# fcntl is Unix-only; msvcrt.locking is the Windows counterpart.
if sys.platform == "win32":
    import msvcrt
    def _flock_exclusive(file_handle):
        msvcrt.locking(file_handle.fileno(), msvcrt.LK_LOCK, 1)
else:
    import fcntl
    def _flock_exclusive(file_handle):
        fcntl.flock(file_handle.fileno(), fcntl.LOCK_EX)

msvcrt.locking(..., LK_LOCK, ...) blocks for ~10 seconds before raising IOError. Hooks fire at most ~5×/min and complete in <100ms, so practical contention is effectively impossible. Three call sites in the hook script.

Symptom before: every hook fire crashed with ModuleNotFoundError: No module named 'fcntl'. Claude Code is fail-open on hook errors, so the workflow wasn't broken — state.json just never got written, and the daemon had nothing to forward.

2. Windows analogue of the inline-Python hook registration

install-mac.sh registers the hooks via inline Python. Windows doesn't run that shell — I split the same logic into daemon/install_hooks_win.py, called from install-win.ps1. Idempotent, timestamped settings.json backup before writing, same set of events you registered on macOS.

3. The sessions payload needs bigger buffers on the firmware

This isn't a daemon-side issue but worth flagging — your daemon and mine produce identical wire payloads. Once sessions shows up (especially with 2 sessions × 5-10 todos × Dutch unicode), the payload comfortably reaches 700–1000 bytes. On the upstream firmware (and on PR #26 unmodified) that hits multiple silent-truncation points:

Both fixed in my branch (LINE_BUF_SIZE=4096, Serial.setRxBufferSize(4096)). For the BLE transport on main I assume BLE_BUF_SIZE=4096 from your own PR already covered it — same insight, different layer.

Extra parse robustness

I also made parse_json two-pass on the firmware: a filtered Usage-only first pass (always succeeds even on malformed payloads), then a best-effort full parse for sessions. So Activity falls back to its empty render state instead of taking Usage down with it when something unexpected hits the parser. Probably a useful pattern on main too — the payload schema you defined is rich enough that the surface area for parse failures grows over time.

Visual confirmation

The 480×480 layout works fine with the ACT_* constants you tuned for 368×448 — PANEL_Y + PANEL_H = 474 ≤ 480 and the title row sits below the rounded corners. So the layout doesn't need a per-board #ifdef after all, at least on this 2.16 variant. (I won't go further into the 1.43 / 1.8 sibling boards since I don't have them.)

The earlier suspicion of "render_activity() hangs on 480×480" turned out to be downstream of the buffer-truncation issue — once parse succeeds, render runs cleanly with PR #22's UI code unmodified.

Happy to break this out into a clean PR against your branch if you'd like — let me know what's most useful.

@Chemaclass

Copy link
Copy Markdown

Looking promising!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants