Skip to content

feat: macOS menu bar app (Wails v3 + Svelte 5 + Tailwind 4)#4

Merged
nahime0 merged 10 commits intoillegalstudio:mainfrom
amargiovanni:feature/macos-menubar-app
Mar 7, 2026
Merged

feat: macOS menu bar app (Wails v3 + Svelte 5 + Tailwind 4)#4
nahime0 merged 10 commits intoillegalstudio:mainfrom
amargiovanni:feature/macos-menubar-app

Conversation

@amargiovanni
Copy link
Contributor

Fair warning: This is a deep architectural change that nobody asked for. I got excited and built it. If this is too much, too soon, or just not the direction you want — feel free to tell me to go pound sand. I'd rather discuss it in an issue first than force something unwanted. No hard feelings either way.


Summary

  • New macOS menu bar app (lazyagent-app) using Wails v3 + Svelte 5 + Tailwind 4, living alongside the existing TUI
  • Core library extraction (internal/core/) — shared session management, file watcher, activity state machine, config system, and helpers used by both TUI and app
  • TUI refactored to import from internal/core/ instead of local implementations — zero behavior changes, same binary size
  • Both entry points build from the same go.mod: go build ./cmd/tui/ (5MB) and go build ./cmd/app/ (18MB)

What the menu bar app does

  • System tray icon (no Dock icon) with click-to-toggle frameless floating panel
  • Real-time session list with SVG sparklines, activity badges, token/cost display
  • Detail panel with model, branch, messages, conversation preview, recent tools
  • Keyboard shortcuts: j/k navigate, / search, f filter, +/- time window, r refresh
  • FSEvents file watcher + 1s activity tick + 30s full reload (same strategy as TUI)
  • Catppuccin Mocha dark theme via Tailwind 4 @theme
  • Right-click context menu: Show Panel, Refresh Now, Quit

Architecture

cmd/tui/          → TUI entry point (unchanged behavior)
cmd/app/          → Wails v3 macOS app (new)
internal/core/    → Shared: watcher, activity, session, config, helpers (extracted)
internal/ui/      → TUI-only rendering (now thin, imports core)
internal/assets/  → Embedded frontend dist (go:embed)
frontend/         → Svelte 5 + Tailwind 4 (new)

Code quality fixes included

After the initial implementation, I ran a three-way review (reuse, quality, efficiency) and fixed:

  • CRITICAL: Added sync.RWMutex to SessionManager — prevents data races between the background watchLoop goroutine and Wails IPC handler goroutines
  • HIGH: watchLoop now selects on ctx.Done() for clean goroutine shutdown
  • MEDIUM: 1s ticker only emits sessions:updated when activity states actually changed (was unconditional)
  • MEDIUM: Extracted EffectiveCost() helper — cost fallback pattern was duplicated 3x
  • MEDIUM: Extracted buildSessionItem()SessionItem construction was copy-pasted between GetSessions and GetSessionDetail
  • MEDIUM: OpenInEditor now checks Config.Editor before falling back to $VISUAL/$EDITOR
  • LOW: Window-minutes bounds centralized in core.SetWindowMinutes() via Clamp() — was magic numbers in 3 files
  • LOW: Sparkline SVG coordinate math unified into single $derived

Intentionally left out of scope (and why)

Item Reason
formatTokens/formatCost duplicated in Go and TypeScript Both are display formatters. Pre-formatting server-side would couple presentation to backend. Acceptable duplication at the system boundary.
Activity filter list hardcoded in both Go and Svelte Matches AllActivities in Go. Generating from bindings would add build complexity for a list that changes ~never.
.catch(() => {}) on frontend IPC calls These are fire-and-forget UI actions (filter, search, window adjust). Errors are non-fatal — next tick retries via the event loop.
Events.On listener not cleaned up in onDestroy Single-page app in a single window that never unmounts. No practical leak.
GetActiveCount binding unused by frontend Kept for the planned dynamic tray label feature (show active count in menu bar).
Stale ago values in tool list Pre-computed server-side when detail is fetched. Refreshes on next sessions:updated event. Acceptable staleness for a monitoring tool.
GetConfig re-reads from disk every call Called rarely (on demand). Caching would add complexity for no measurable gain.
JSONL incremental parsing / mtime caching DiscoverSessions re-parses all files on reload. For typical session counts (5-20) this is fast enough. Worth optimizing later if session counts grow.
git rev-parse cache promotion to SessionManager Per-invocation cache in DiscoverSessions is rebuilt every reload. Fine for now, could be promoted if git subprocess cost becomes noticeable.

Test plan

  • go vet ./... passes clean
  • go build ./cmd/tui/ produces working TUI (verify sparkline, cost, spinner, filter, search, open)
  • go build ./cmd/app/ produces 18MB arm64 binary
  • App starts, tray icon appears, no Dock icon
  • Click tray → panel opens with real sessions
  • Click session → detail panel with sparkline, model, tokens, conversation
  • Keyboard shortcuts work (j/k, /, f, +/-, r, esc)
  • Right-click tray → context menu works
  • Panel closes on focus lost
  • go build . (root alias) still works

🤖 Generated with Claude Code

amargiovanni and others added 10 commits March 7, 2026 19:31
Wails v3 + Svelte 5 + Tailwind 4 architecture with shared Go core.
6 phases: core extraction, scaffold, backend, frontend, tray, distribution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Decisions: Wails v3 alpha pinned, arm64-only, single brand name,
config.yaml in Phase 0, Launch at Login in Phase 4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extract shared core library (internal/core/): watcher, activity state
  machine, session manager, config, helpers
- Add Wails v3 macOS app (cmd/app/): system tray, frameless floating
  panel, SessionService with 9 bound methods, FSEvents + event push
- Add Svelte 5 frontend: SessionList, SessionDetail, Sparkline,
  ActivityBadge, Catppuccin Mocha theme via @theme
- Refactor TUI (internal/ui/) to import from core instead of local code
- Add dedicated entry points: cmd/tui/ and cmd/app/
- Add Makefile with tui, app, frontend, bindings, dev, clean targets
- Add shared config system (~/.config/lazyagent/config.json)
- Update README with dual-interface docs, architecture, and dev guide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CRITICAL: Add sync.RWMutex to SessionManager to prevent data races
  between watchLoop goroutine and Wails IPC handlers
- Add context-based shutdown to watchLoop (select on ctx.Done())
- UpdateActivities returns bool; 1s ticker only emits when changed
- Extract EffectiveCost() helper, deduplicate cost fallback pattern
- Extract buildSessionItem() in service.go, remove duplication
- OpenInEditor now checks Config.Editor before env vars
- Centralize window-minutes clamping in core.SetWindowMinutes()
- Remove dead SessionView struct
- Unify Sparkline SVG coordinate computation into single derived

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move cmd/app/ service into internal/tray/ package with Run() entry point.
Single main.go dispatches between TUI (default) and tray (--tray flag).
Auto-detach on --tray so the terminal returns immediately.
Single-instance enforcement via PID file with automatic kill of previous.
Add HasFrontend() check to assets package.
Update Makefile: 'make build' for full, 'make tui' for TUI-only.
Update frontend imports to new bindings path.
Add build tag 'notray' to exclude Wails dependencies on platforms
without CGo. Linux builds use notray tag (CGO_ENABLED=0), macOS
builds include full tray support (CGO_ENABLED=1).
Add before.hooks to goreleaser for frontend build step.
Replace default Wails template icon with a custom scan-eye icon
embedded via go:embed. Icon is rendered at 36x36 with padding for
proper macOS menu bar sizing.
- lazyagent: TUI (default)
- lazyagent --tui: TUI (explicit)
- lazyagent --tray: tray only (detached)
- lazyagent --tui --tray: spawn tray in background, then run TUI
- Document --tui, --tray, --tui --tray flags
- Update architecture section (single binary, internal/tray/)
- Add tray.png screenshot, switch to screenshot.png
- Update build instructions (make build/tui)
@nahime0
Copy link
Member

nahime0 commented Mar 7, 2026

@amargiovanni I'd prefer having a single binary with flags for the different modes rather than two separate binaries. I've refactored the branch so that:

  • lazyagent → launches the TUI (default)
  • lazyagent --tray → launches the macOS menu bar app (auto-detaches)
  • lazyagent --tui --tray → launches both simultaneously

@nahime0 nahime0 merged commit b88ba43 into illegalstudio:main Mar 7, 2026
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.

2 participants