This file provides guidance to AI coding agents working with code in this repository.
Helmor is a local-first desktop app built with Tauri v2 (Rust backend) + React 19 + Vite + TypeScript. It provides a workspace management UI with its own SQLite database (~/helmor/ in release, ~/helmor-dev/ in debug), letting users browse workspaces/sessions/messages and send prompts to AI agents (Claude Code CLI, OpenAI Codex CLI, OpenCode) via streaming IPC.
bun install # Install deps (bun 1.3+). Also runs `bun install` in sidecar/ via postinstall.
bun run dev # dev:prepare + vite build + tauri dev (builds dist/ so companion serves current bundle)
bun run dev:analyze # Same as dev, with perf HUD (VITE_HELMOR_PERF_HUD=1)
bun run build # tsc + vite build (frontend bundle to dist/)
bun run typecheck # tsc --noEmit for frontend AND sidecar
bun run lint # biome check . + cargo clippy -- -D warnings
bun run lint:fix # biome --write + cargo clippy --fix + cargo fmtTests are three targets — bun run test runs all three (frontend -> sidecar -> rust). Pre-commit hook runs biome on JS/TS and clippy/fmt on Rust.
bun run test # All three suites
bun run test:frontend # vitest run (jsdom, @testing-library/react)
bun run test:sidecar # cd sidecar && bun test
bun run test:rust # cd src-tauri && cargo test
bun run test:rust:update-snapshots # INSTA_UPDATE=always
bun run test:watch # vitest watch (frontend only)Single test file: bun x vitest run src/App.test.tsx | cd sidecar && bun test src/foo.test.ts | cd src-tauri && cargo test --test pipeline_scenarios -- <name>
- Frontend (
src/): React 19 SPA in Tauri webview. State managed by focused hooks inshell/hooks/(useAppShellState,useSelectionController,useEditorEditMode,useGlobalShortcutHandlers,useAppBootstrap) + TanStack React Query + context providers. - Rust backend (
src-tauri/src/): Tauri host, SQLite database, spawns and supervises the sidecar. - Sidecar (
sidecar/): Bun + TypeScript, wraps@anthropic-ai/claude-agent-sdk,@openai/codex(CLI), and@opencode-ai/sdk. Built tosidecar/dist/helmor-sidecarviabun build --compile. JSON event stream over stdout.
Message flow: user prompt -> Rust agents::streaming -> sidecar -> SDK -> stdout events -> Rust accumulator -> adapter + collapse -> ThreadMessageLike[] -> tauri::ipc::Channel -> React.
Feature-based layout. Each feature folder follows: index.tsx (main) + container.tsx (data/state) + hooks/ + tests.
| Path | Role |
|---|---|
App.tsx |
Root (~18 lines). Composition layer that delegates to AppProviders and AppShell. |
features/panel/ |
Chat thread container, header, message components, thread viewport. |
features/conversation/ |
Conversation renderer + use-streaming hook. |
features/composer/ |
Lexical-based message input. Plugins in editor/plugins/. |
features/editor/ |
Monaco file editor surface. |
features/inspector/ |
Right-side inspector (actions, changes sections). |
features/navigation/ |
Sidebar workspace groups. |
features/commit/ |
Commit button + lifecycle hook. |
features/terminal/ |
Terminal Mode session panel, presets (boot command builders), session store with PTY lifecycle. |
features/settings/ |
Settings dialog + panels (CLI install, repo settings). |
shell/ |
Top-level layout, GitHub identity gate, panel resize hooks. State orchestration in hooks/: use-app-shell-state.tsx (central hub), use-selection-controllers.ts (selection/context/start TDZ ring), use-editor-edit-mode.ts, use-global-shortcut-handlers.ts, and use-app-bootstrap.ts (app initialization). All <300 lines/file. |
components/ai/ |
AI-specific components (code block, file tree, reasoning). |
components/ui/ |
shadcn/ui primitives (base-nova). |
components/terminal-output-scheduler.ts |
PTY output coalescing (8ms flush window, 16KB threshold) to reduce IPC cost. |
lib/api.ts |
IPC bridge -- every Tauri invoke() call wrapped as a typed function. |
lib/query-client.ts |
React Query keys + query options factories. |
lib/settings.ts |
App settings context with Tauri storage. |
| Module | Role |
|---|---|
lib.rs |
Tauri app builder. Registers commands, runs setup hook. |
commands/ |
Tauri command handlers split by domain (session, repository, workspace, editor, github, settings, system). terminal_commands.rs includes set_terminal_session_busy and create_session (now accepts session_kind and agent_type parameters). |
agents/ |
Agent streaming + persistence (catalog, persistence, queries, streaming, support). |
cli/ |
CLI entry point + subcommands. terminal_hook.rs provides the hidden terminal-hook command used by agent CLIs to communicate PTY lifecycle events (session id, busy/idle, prompt capture). |
pipeline/ |
Message pipeline: accumulator/ -> adapter/ + collapse -> ThreadMessageLike[]. Includes event_filter.rs, classify.rs, types.rs. |
workspace/ |
Workspace operations (branching, lifecycle, helpers) + files/ sub-module (editor, changes, types). scripts.rs includes PTY output coalescing (8ms flush window, 16KB threshold, UTF-8 safe flush). |
git/ |
Git operations (ops, watcher). |
github/ |
GitHub integration (auth, CLI, GraphQL). |
models/ |
Persistence layer (db, repos, sessions, settings, workspaces). Sessions table now has session_kind (distinguishes terminal vs GUI sessions) and agent_type (tracks which agent is being used). |
service.rs |
Service layer. |
sidecar.rs |
Sidecar process manager (spawn, stdio, graceful SIGTERM). |
schema.rs |
DB schema + idempotent migrations. |
mcp.rs |
MCP bridge integration. |
logging.rs |
Structured logging setup. |
data_dir.rs |
Data dir resolution. HELMOR_DATA_DIR env override. |
error.rs |
CommandError -- bridges anyhow::Error to Tauri IPC. |
index.ts (entry, stdin/stdout JSON) | session-manager.ts (base lifecycle) | claude-session-manager.ts | codex-session-manager.ts | opencode-protocol/session-manager.ts (shared protocol manager) | opencode-protocol/server.ts | opencode-protocol/opencode.ts (opencode config) | codex-skill-scanner.ts | request-parser.ts | emitter.ts | abort.ts | images.ts | title.ts | logger.ts
Live streaming sidecar events --> accumulator --> adapter + collapse --> ThreadMessageLike[]
Historical reload DB rows --> convert_historical ----^
Both paths converge at IntermediateMessage[] and share adapter + collapse.
Storage shape: session_messages.content is JSON. Top-level type discriminates: user_prompt, user, assistant, system, error, result, item.completed (Codex), turn.completed. DB stores post-accumulator form. Claude SDK delivers delta-style blocks; accumulator APPENDs them.
🚨 Any change touching pipeline/, agents/ persistence, schema.rs, or the storage shape MUST have snapshot test coverage in src-tauri/tests/.
Three insta-based targets sharing tests/common/mod.rs:
pipeline_scenarios.rs-- Handcrafted edge cases (70+ tests). Normalized snapshots.pipeline_fixtures.rs-- Real DB sessions intests/fixtures/pipeline/, auto-discovered viainsta::glob!.pipeline_streams.rs-- Raw SDK stream-event JSONL intests/fixtures/streams/. Three-stage round-trip.
cd src-tauri && cargo test --tests # All integration tests
cd src-tauri && INSTA_UPDATE=always cargo test --tests # Accept new snapshots
cd src-tauri && cargo insta review # Interactive accept/reject
cd src-tauri && cargo run --example gen_pipeline_fixture -- <session_id> <name> # Capture real fixtureWhen a snapshot drifts: look at the diff first. Only accept after confirming the new shape is intended, not a regression.
- Path alias:
@/maps tosrc/ - Styling: Tailwind CSS v4, oklch semantic tokens. Three-layer architecture in
src/styles/color-theme.css:- Base — atomic semantics (
--bg-base / --bg-surface / --bg-elevated / --bg-overlay,--fg-default / --fg-muted / --fg-subtle / --fg-disabled,--border-default / --border-subtle / --border-strong,--accent-default / --accent-hover / --accent-muted,--status-success/warning/danger/info). This is the only layer theme presets touch. - Shadcn/ui interface —
--background / --foreground / --primary / --muted / --sidebar / ...forward Base viavar(). These are the shadcn ecosystem protocol; shadcn primitives and copy-paste examples target them. Not a shim — a deliberate public surface. - Region — bg-only chrome tokens for each pane (
--titlebar-bg / --sidebar-bg / --panel-bg / --inspector-bg / --composer-bg / --editor-chrome-bg / --terminal-chrome-bg / --statusbar-bg / --settings-nav-bg). Region fg/border intentionally do not exist — pane text/borders consume Base globally so reading rhythm stays consistent across the shell. Sidebar is the lone exception (it gets--sidebar-fg*+--sidebar-item-*because Slack-style themes invert fg on dark sidebars).
- Base — atomic semantics (
- Typography: Semantic font-size tokens (
text-nano…text-heading) live insrc/styles/color-theme.css(Typography scale section), exposed viaApp.css@theme inline; pick by role, not px. Don't writetext-[Npx]in app code — fold it into a token (the comment incolor-theme.csslists the few allowed off-scale exceptions). New font-size tokens must also be registered with tailwind-merge insrc/lib/utils.ts, otherwisecn("text-{token} text-{color}")collapses to just the color. - Locked surfaces (do not put inside theme presets):
--panel-message-* / --panel-code-* / --editor-content-* / --editor-gutter-* / --editor-line-active-bg / --editor-selection-bg / --editor-cursor, all--terminal-*, the status palette (--chart-* / --subagent-* / --workspace-*,--status-success/warning/danger/info). These follow light/dark only — readability and semantic colors (red=danger, green=merged) must not change with the chrome theme. - Designing a new theme: add a
.theme-<name>:not(.dark) { ... }+.dark.theme-<name> { ... }block at the bottom ofcolor-theme.css. Override Base tokens only (typically--bg-base / --bg-surface / --bg-elevated / --accent-default / --accent-fg / --border-default / --ring-default— about 10 values). Region tokens auto-derive. Then add the name toColorTheme+VALID_COLOR_THEMESinsrc/lib/settings.tsand to the picker options insrc/features/settings/panels/appearance.tsx. Both.theme-<name>:not(.dark)and.dark.theme-<name>blocks are required becauselightThemeanddarkThemeare independent settings — users may pair your preset with either mode. Do NOT override locked surfaces in a preset. - Monaco theme:
src/lib/monaco-runtime.tsbuilds the editor theme by reading CSS variables through a hidden DOM probe (resolveCssColor). On any<html>class mutation (light/dark or preset switch) it re-defines both themes and re-applies. Syntax-highlight rules stay hard-coded so code colors are stable across themes — chrome (background/gutter/widgets/diff) follows the active theme. - xterm theme:
src/components/terminal-output.tsxresolves--terminal-*viagetComputedStyleand pushes a newIThemeintoterminal.options.themeon class mutation. The ANSI palette is locked (light/dark only). - UI: shadcn/ui (base-nova),
lucide-reacticons. No@assistant-ui/reactorreact-virtuoso-- removed, do not re-introduce. - Cursor: Clickable elements default to
cursor-pointer(baked into base UI components; keep it on custom<div onClick>too). Users can flip the whole app back to the platform arrow via Appearance settings → "Use pointer cursors" (adds.no-pointer-cursorson<html>); don't bypass with inlinestyle={{ cursor }}. - Chat rendering:
streamdown+use-stick-to-bottom. Markdown overrides insrc/components/streamdown-components.tsx. - Rich text input: Lexical in
src/features/composer/editor/. - File editor: Monaco, lazy via
src/lib/monaco-runtime.ts. - Linting: Biome (tab indent).
lint-stagedenforces on pre-commit. - Testing: Vitest + jsdom (frontend),
bun test(sidecar), cargo test + insta (Rust). Tests co-located with source. - Changesets: A
.changeset/*.mdbody uses the smallest shape that fits — a single prose sentence (default for simple patch-level changes) or a prose summary line followed by-sub-items (only when ≥2 distinct user-visible changes are worth enumerating). Never start the body with-. See thehelmor-releaseskill for full format and rationale. - Data dir:
~/helmor/(release) or~/helmor-dev/(debug). Override:HELMOR_DATA_DIR. - macOS chrome: Overlay title bar, traffic lights at (16, 24). Drag via
data-tauri-drag-region. - Serde:
#[serde(rename_all = "camelCase")]-- JSON fields match TypeScript directly. - Persisting React Query data: Every query is in-memory only by default. To persist a query across app restarts, set
meta: PERSIST_META(alias for{ persist: true }) on itsqueryOptions/useQuerycall. Only do this for data the user must see immediately on cold start (sidebar lists, identity chips). Never opt in large or fast-refetching queries — the persisted blob is read synchronously on boot. Seesrc/lib/query-client.tsfor the wiring; thereact-query.d.tsaugmentation closesmeta's shape so typos likepresistfail at compile time. - Backend → frontend notifications: Always go through
UiMutationEvent(src-tauri/src/ui_sync/events.rs). Add a typed variant, broadcast withcrate::ui_sync::publish(&app, ...), mirror the variant inUiMutationEventinsrc/lib/api.ts, and handle it insrc/shell/hooks/use-ui-sync-bridge.tsto invalidate the right React Query keys. Do NOT add ad-hocapp.emit("custom-event", ...)channels with their own component-levellisten(...)-- they fragment cache invalidation, skip the global bridge, and are easy to leak. Terminal session lifecycle usesTerminalActivityChanged(busy/idle transitions),TerminalSessionIdle(completion notification), andTerminalPromptCaptured(title and branch-rename generation). - Clippy: Must pass
cargo clippy --all-targets -- -D warningswith zero warnings. - Perf:
VITE_HELMOR_PERF_HUD=1enables HUD + react-scan + long-frame tracker. - Logging: Dev defaults to
debug. Override:HELMOR_LOG=info|debug|error. JSONL logs in{data_dir}/logs/. - Bundled forge CLIs (
gh,glab,cloudflared): Pinned + SHA256-verified insidecar/scripts/vendor-platform.ts.cloudflaredpowers the mobile-companion tunnel feature. To upgrade:- Bump
GH_VERSION/GLAB_VERSION/CLOUDFLARED_VERSION. - Pull the new SHA256 from
…/checksums.txt(URLs in the file's header comment) and updateGH_SHA256/GLAB_SHA256/CLOUDFLARED_SHA256. - Re-run
bun run buildinsidecar/— the changed SHA256 auto-forces a re-download + verify (no manual wipe). Downloaded archives now live in a shared, cross-worktree cache (the main worktree'ssidecar/.bundle-cache, overrideHELMOR_BUNDLE_CACHE); wipe that only if you want a forced clean fetch. Bump cadence: every release cycle if upstream has shipped a notable fix; immediately on security advisories. Pin so the auth-status JSON shape Helmor parses doesn't drift unexpectedly.
- Bump
- Bundled agent CLIs (
claude-code,codex,opencode): Pulled in viasidecar/package.jsonand staged intosidecar/dist/vendor/{claude-code,codex,opencode}/as platform-native binaries. All three upstreams ship per-platform npm sub-packages (@anthropic-ai/claude-code-darwin-{arm64,x64},@openai/codex-darwin-{arm64,x64},opencode-darwin-{arm64,x64}). Cross-arch CI staging downloads the tarball straight from the npm registry and verifies againstCLAUDE_CODE_SHA256/CODEX_SHA256/OPENCODE_SHA256invendor-platform.ts. Thestage-vendor.tsscript stages claude-code, codex, opencode, gh, glab, and cloudflared CLIs. To upgrade:- Bump the version in
sidecar/package.json,cd sidecar && bun install. - Compute the SHA256 of both arch tarballs (
shasum -a 256on the cached.tgz) and update the table instage-vendor.ts(key it under the new version string). - Run
bun run buildinsidecar/to verify — a changed SHA256 auto-forces a re-download (shared cache at the main worktree'ssidecar/.bundle-cache, overrideHELMOR_BUNDLE_CACHE; no manual wipe needed). Both binaries arebun build --compileoutput (~200 MB each on macOS), somaybeSignMacBinary(_, true)is required — JSC needsallow-jit/allow-unsigned-executable-memoryunder hardened runtime. Run pipeline snapshot tests after every claude-code bump (cd src-tauri && cargo test --tests); the SDK event shape is the contract Helmor's accumulator depends on.
- Bump the version in
Never let a single file grow into a monolith. In early 2026 the codebase went through a full refactor precisely because too much logic had been crammed into too few files (App.tsx alone was 1976 lines). The split delivered 22 focused files (all <300 lines, largest 299) that isolate re-render scopes and keep responsibilities clear. Follow these rules strictly:
- One responsibility per file. If a file handles two unrelated concerns, split it.
- Use module directories. When a module grows beyond ~300 lines, convert
foo.rstofoo/mod.rs+ sub-files, or splitfoo.tsxinto afoo/folder withindex.tsx+ focused sub-modules. Theagents/,pipeline/,workspace/,commands/directories are the reference pattern. - Frontend: feature folders. New features go into
src/features/<name>/withindex.tsx, optionalcontainer.tsx,hooks/, and tests. Shared components go intosrc/components/. Do NOT put feature-specific logic insrc/lib/orApp.tsx. - Backend: commands vs. domain logic. Tauri
#[command]handlers go incommands/. Business logic and domain operations go in their own modules (workspace/,agents/,git/, etc.). Do not mix IPC glue with domain logic. - When in doubt, split. It is always easier to merge two small files than to untangle a 1000-line monolith.
Terminal Mode is a composer toggle (settings opt-in) that sends prompts directly to Claude Code or Codex CLI in a live PTY terminal session, bypassing the SDK streaming pipeline. Terminal sessions render in the main panel alongside GUI chat sessions.
Key features:
- WebGL context management: only visible terminals hold GPU contexts, staying within Chromium/WebKit budget limits.
- Coalesced PTY output: 8ms flush window, 16KB threshold to reduce per-event IPC cost on high-throughput output.
- Lifecycle hooks: idle detection, title generation, and completion notifications matching GUI behavior. Hooks inject
helmor terminal-hookas a callback command into the agent's session lifecycle events (SessionStart, UserPromptSubmit, Stop). - Resume support: the agent's real session id is persisted via hooks, enabling
--resumeon relaunch.
Structure:
- Frontend:
src/features/terminal/(session panel, presets, boot command builder, session store with PTY lifecycle). - Backend:
src-tauri/src/cli/terminal_hook.rs(hook callback command),src-tauri/src/commands/terminal_commands.rs(spawn, resize, busy-state commands),src-tauri/src/workspace/scripts.rs(PTY output coalescing). - Components:
src/components/terminal-output-scheduler.ts(output coalescing).
Hard rule: Use the Tauri MCP bridge (
tauri-plugin-mcp-bridge) only. Nochrome-devtoolsMCP, no/agent-browser. Helmor runs in Tauri webview only.
When the user asks to use Tauri MCP (including misspellings like "towery MCP"), debug the local dev build, simulate user actions, switch workspaces/sessions, type or send composer text, inspect the webview, take screenshots, trace IPC, or run visual end-to-end checks against Helmor, first use the project skill helmor-debug-operate (.agents/skills/helmor-debug-operate/SKILL.md). Treat it as the operation manual for controlling the local desktop build. Use helmor-cli instead only for terminal-first data/workspace automation where the UI is not under test.
When the user asks for an autonomous local-dev bug loop, repeated reproduction, temporary logging/instrumentation, self-directed debugging, or "fix and verify through the app", first use helmor-debug-loop (.agents/skills/helmor-debug-loop/SKILL.md). That loop skill must use helmor-debug-operate for all Tauri MCP app operations and must explicitly handle not reproduced, flaky, and blocked outcomes rather than pretending a reproduction succeeded.
The Helmor local-dev debug skill is a prompt, not a source of truth. It can be stale if the local UI changed without the skill being updated. If a skill recipe fails three times, stop repeating it mechanically: take fresh screenshots/snapshots, reason from the visible UI, and inspect the relevant source code if needed. If you discover a better path, missing pitfall, stale selector, or unverified workaround, record a candidate update under .agent-contexts/<task-slug>/skill-update-candidates.md with evidence and verification status, then ask the user for confirmation before editing the skill unless the current user request explicitly asks you to update it.
- Debug build only. MCP bridge is behind
#[cfg(debug_assertions)]. Alwaysbun run dev. - Open driver session first. Call
driver_session action=statusbeforestart. Default port9223, windowmain. - Sanity-check. Call
ipc_get_backend_stateafter connecting to confirm the right instance.
- UI state:
webview_screenshot->webview_dom_snapshot type=accessibility(prefer ref IDs for follow-ups) - User input:
webview_interact+webview_keyboard. Never dispatch synthetic events viawebview_execute_js. - IPC tracing:
ipc_monitor start-> trigger flow ->ipc_get_captured filter=<cmd>->ipc_monitor stop. Always stop when done. - Direct backend call: do not use Tauri MCP
ipc_execute_commandfor Helmor app commands; current bridge dynamic command execution returnsUnsupported Tauri command. Usehelmor-debug-operate's click-triggered Call App Commands helper, or drive the UI. - Async waits:
webview_wait_for type=ipc-event value=<event>for streaming/pipeline events. - Console/system logs:
read_logs source=consoleorsource=system filter=helmor. - JS eval:
webview_execute_js script="(() => <expr>)()"(IIFE, JSON-serializable return). Cannot see React state. - Styles:
webview_get_styles selector=... properties=[...]. - Element picker:
webview_select_elementorwebview_get_pointed_element(Alt+Shift+Click). - Window geometry:
manage_window action=list|info|resize.
- Release builds have no MCP bridge.
webview_screenshot= visible viewport only. Scroll first if needed.ipc_monitoris sticky -- stop it explicitly.- Tauri MCP does not see sidecar HTTP/WS traffic. Check JSONL logs in
{data_dir}/logs/. - Ref IDs are per-snapshot. Re-snapshot after UI state changes.