Tauri 2 desktop shell for omp (oh-my-pi). React UI loaded from src/ by Tauri's asset server. Rust backend spawns omp --mode rpc per tab. No bundler — JSX is transpiled in-browser by @babel/standalone.
| Task | Command |
|---|---|
| Install Tauri CLI | npm install |
| Dev | npm run dev |
| Prod build | npm run build |
| Rust check (CI) | cd src-tauri && cargo check --locked |
| Rust fmt | cd src-tauri && cargo fmt |
| Rust lint (must stay clean) | cd src-tauri && cargo +nightly clippy --all-targets --all-features -- -W clippy::pedantic -W clippy::nursery -D warnings |
| Rust tests | cd src-tauri && cargo test |
| Probe omp RPC | node test-rpc.mjs |
omp must be on PATH (%LOCALAPPDATA%\omp\omp.exe on Win). CI = cargo check + cargo test on win/linux/mac.
Three layers:
-
Rust (
src-tauri/src/) —agent/module:mod.rs—AgentBridgepublic API (start/stop/send/last_error).inner.rs—BridgeInnerper-session: generation token,Arc<Mutex<ChildStdin>>, child handle.spawn.rs—spawn_ompcandidate resolution + WinCREATE_NO_WINDOW.reader.rs— stdout/stderr threads + boundedread_until_capped(16 MiB).
AgentBridge=HashMap<session_id, BridgeInner>. Per-session stdin lock so writes don't serialise through the map. Reader emitsagent://line/{id}per stdout line,agent://exit/{id}(empty payload = clean, non-empty = reason). Tauri commands inlib.rs:start_session,stop_session,send_command,session_status,open_project.Drop+stop_sessionkill children — no orphans on hot-reload. -
Bridge (
src/live.js) — listens toagent://line/{id}for active session only. Holds per-session live state and asessionRegistry(tabs). Tab switch: snapshot → tear down listeners → restore (or reset+_initFetch) → re-listen. Exposeswindow.OMP_BRIDGE(commands +onUpdate) and legacywindow.OMP_DATA. -
React (
src/app-live.jsx+src/app/+src/design/*/) — sole React root. UsesuseBridgeSnapshot(insrc/app/use-bridge-snapshot.jsx) to mirrorOMP_BRIDGE.onUpdateinto hooks. Cross-cutting effects (theme on<html>, ⌘K) live there. Constants/framing strings insrc/app/constants.js. Pure RPC↔UI shape transforms insrc/adapter.js(no side effects, depends onmodel-names.js).
One tab = one omp process. default session started in lib.rs::setup; new tabs via OMP_BRIDGE.openSession(cwd) → start_session. Tab switch preserves in-flight bubbles via sessionSnapshots; after re-listen, get_messages is called and _handleResponse merges persisted turns with cached streamingBubble (omp doesn't persist incomplete turns).
Script order is the dependency graph:
- Vendored libs: React, ReactDOM, Babel,
marked.min.js,highlight.min.js+ marked-wiring inline. - Tweaks:
tweaks/style.js,tweaks/use-tweaks.js(plain, IIFE) →tweaks/panel.jsx,tweaks/controls.jsx(Babel; controls depends on panel). - UI primitives:
ui/icons.jsx(definesIcon,TOOL_META) →ui/sparks.jsx→ui/markdown.jsx→ui/plan-annotations.jsx. - Chat:
chat/user-bubble.jsx→chat/eval-cell.jsx→chat/assistant-bubble.jsx→chat/tool-card.jsx→chat/chat-view.jsx. design/composer.jsx,design/chrome.jsx,design/panels.jsx.- Live data:
model-names.js→adapter.js→live.js. - App helpers:
app/constants.js(plain, IIFE) →app/use-bridge-snapshot.jsx. app-live.jsxlast.
When adding a file, insert at the correct point — there is no resolver to catch ordering bugs.
Plain <script> tags share document top-level scope; Babel type="text/babel" scripts intersect with it via destructures. Every plain script declaring top-level const/function/class MUST be (function(){ …; window.X = X; })(); — see app/constants.js, tweaks/style.js, tweaks/use-tweaks.js. Bare window.X = {…} assignments are fine (model-names.js). Babel-transformed files do not need wrapping.
src/design/ is the live-wired copy. Root-level design/ is a gitignored read-only prototype reference. Never regenerate src/design/ from design/ — it overwrites bridge wiring. Edit src/design/ directly.
Soft caps:
| Kind | Cap |
|---|---|
.jsx |
~250 lines |
.js |
~400 lines |
.rs |
~250 lines |
.css |
~300 lines |
Guidelines, not hard limits. Cohesion matters more than count.
Rules:
- Split by responsibility, not symbol count. Group component families (e.g.
chat/); never alphabetic splits. - One component per file when it has its own non-trivial state/effects (e.g.
EvalCell,ScrubbableDiff,AnnotablePlan). - Co-locate primitives only when one is a private helper of the other (
InlinePlanwithAssistantBubble). - CSS splits by visual layer, not component. Don't sub-split
chat.cssunless a layer exceeds ~150 lines. - Rust modules split by concern when there are multiple
pubsurfaces or a long private helper section. - After splitting, update
src/index.htmlscript order in dependency order — never append. - Don't extract for symmetry. Tightly-related layers (e.g.
chrome.jsx) stay together.
Trigger: 6th major component in one file, or 4th unrelated concern in one Rust module → split before further growth.
omp --mode rpc, notomp --rpc(latter falls through to TUI, floods stdout with ANSI).- Blank-line stdout:
agent/reader.rsdistinguishes EOF ((0,_)) from blank lines and strips CR/LF. Don't revert toreader.lines()with blanket_ => break— silently kills reader on first blank line. - Window controls use document-level click delegation (React mounts after
DOMContentLoaded);querySelectorin_setupWindowChromewould miss it. set_modelresponse must callnotify()immediately, else nextturn_startre-emits stalestate.modeland UI reverts.- Long
if/else ifchains in_handleResponse(live.js): a single misplaced}cascades —_handleResponsenever closes, IIFE syntax errors,window.OMP_DATAnever set. Re-verify brace structure when inserting branches. - Frameless window via DWM:
decorations: false+platform.cssstrips outer padding/shadow under.tauri-native. CSS usescolor-mix(in oklab, …)— needs WebView2 ≥ 101. - Strict CSP:
default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; …. Asset protocol disabled.tauri-plugin-shelldeliberately removed. Don't add CDN tags orconvertFileSrc()without revisiting both. - Thinking levels are RPC-driven. Cycle =
cycle_thinking_level(response carries new level). Set =set_thinking_level. Valid:off | minimal | low | medium | high | xhigh. Never invent fallbacks likeauto/extended— RPC silently ignores them. - No CDN dependencies. React/ReactDOM/Babel/marked/hljs are vendored. App must work offline.
General:
- Follow existing architectural patterns before introducing new ones. Optimize for clarity first, then allocation efficiency.
- Run fmt + lint locally before finalizing any change. Don't ship code that fails fmt or clippy.
- Only format files you actually modified. Never do bulk formatting-only rewrites.
- Prefer surgical
editover full-filewritewhen the file already exists. Full rewrites only when (a) creating a new file, (b) >~70% of lines genuinely change, or (c) restructuring would require so many anchors thateditbecomes brittle. Never rewrite a file just to change a few lines — it loses formatting, drops invariants you didn't notice, and bloats diffs.
Rust:
cargo fmt(stable) before commit; nightly clippypedantic+nurseryclean,-D warnings.- No
unwrap/expectin production paths unless failure is provably unrecoverable. - Prefer borrowing (
&str,&[T]) over owned.&strfor params unless ownership required. No needlessString↔&strconversions. - No
.clone()to bypass borrowck unless duplication is intentional. - No unnecessary
Arc/Mutex/async primitives. Keep lifetimes simple and idiomatic — no complex lifetime abstractions without clear benefit. - Iterators/slices over intermediate
Veccollections.Cowonly when it meaningfully reduces allocations. - Minimize temporary allocations in hot paths (reader loop, per-line dispatch, IPC payload construction).
- Idiomatic Rust over clever abstractions. Preserve existing module/naming conventions.
- Module-level
#![allow(clippy::needless_pass_by_value)]inlib.rsis intentional — Tauri#[command]requires owned types.
Frontend:
- Prettier for JS/TS; respect any present ESLint config. Use repo-configured npm scripts when present (none currently — no JS test/lint pipeline in this repo).
- Don't reformat unrelated files. Preserve existing import ordering/style.
- Prefer TS types over
any(when TS is present; this repo is JSX).
Tauri:
- Keep FE/BE boundaries explicit. Don't expose unnecessary commands.
- Validate/sanitise all inputs crossing the IPC boundary. Strongly typed payloads.
- No blocking ops inside async commands. Off-thread
kill+wait(seestart_session/stop_session). - Isolate platform-specific logic (e.g.
CREATE_NO_WINDOWlives inagent/spawn.rs).
Disallowed unless justified: clone-heavy ownership; owned String/Vec params where borrows suffice; collecting only to iterate once; unneeded boxing; async tasks without lifecycle justification; large formatting-only rewrites; formatting unrelated files.
All non-trivial code must have test coverage before committing. This is not optional.
Rust:
- Every pure/logic function gets a
#[cfg(test)] mod testsblock in the same file. - Integration behaviour (spawn, IPC, reader) gets at least one test verifying the happy path and one for the main failure mode.
- Run
cargo testbefore every commit. A commit that adds logic without tests is rejected.
Frontend (JS/JSX):
- Pure state-transformation functions (message mapping, event handlers, bridge methods) are extracted so they can be tested in isolation.
- Use the
evalkernel (===== js =====cells) to exercise logic inline when no test framework is wired. - Non-trivial
live.jsadditions (new event handlers, new bridge methods) must be accompanied by a notebook-style proof-of-correctness cell or a note explaining why the function is too side-effectful to test directly.
What counts:
- A test that imports the function and asserts on its output counts.
- A test that only verifies the function doesn't throw does not count.
- Snapshot tests and "it renders" checks do not count as logic coverage.
.github/workflows/ci.yml—cargo check --locked+cargo test --lockedon win/linux/mac forsrc-tauri/**,src/**, or workflow changes..github/workflows/release.yml— bundles viatauri build.
CHANGELOG.md follows Keep a Changelog.
During development: every user-facing change goes into the [Unreleased] section at the top, grouped under ### Added, ### Fixed, or ### Changed.
On release (triggered by the user saying "release X.Y.Z"):
- Rename
## [Unreleased]to## [X.Y.Z] - YYYY-MM-DD(today's date). - Insert a new empty
## [Unreleased]section above it. - Bump
versioninsrc-tauri/Cargo.tomlandsrc-tauri/tauri.conf.jsontoX.Y.Z. - Update
src-tauri/Cargo.lock:cargo update --manifest-path src-tauri/Cargo.toml --package omp-desktop. - Commit:
git add CHANGELOG.md src-tauri/Cargo.toml src-tauri/tauri.conf.json src-tauri/Cargo.lock && git commit -m "chore: release vX.Y.Z". - Tag:
git tag vX.Y.Z. - Push:
git push origin master --tags.