Developer guide
How the codebase is organized, why each boundary exists, and where to change things safely. New here? Read the user-facing “how it works” first — this page explains the machinery behind it.
Mental model
Kivo is one Tauri process with four pre-created webview surfaces. React owns presentation and transient UI state — nothing else. Rust owns shortcuts, window placement, speech sessions, selected text, replacements, settings, credentials, Gemini requests, and tray lifecycle.
This split is deliberate: the webview is convenient for UI but untrusted for secrets. The Rust core (AppCore in src-tauri/src/commands/, wired in lib.rs) is the only thing allowed to touch credentials, the microphone path, or another app’s text.
Repo map
Kivo/
├── src/ # React UI — presentation only
│ ├── App.tsx # surface router (flow-bar | writing-tools | settings | onboarding)
│ ├── components/ # Button, Icon, Switch, ShortcutRecorder, …
│ ├── features/
│ │ ├── dictation/ # FlowBar + reducer state machine
│ │ ├── writing-tools/ # popup, actions.ts, SafeMarkdown, reducer
│ │ ├── settings/ # SettingsWindow (5 sections)
│ │ └── onboarding/ # first-run permission + setup flow
│ ├── hooks/ platform/ # nativeBridge, useNativeEvent, prefs
│ └── styles/ # tokens in globals.css, per-surface css
├── src-tauri/src/
│ ├── lib.rs main.rs # composition root, tray, shortcuts, surfaces
│ ├── commands/ # Tauri invoke handlers + AppCore
│ ├── speech/ # session management over platform engines
│ ├── text/ # selection capture + replacement
│ ├── ai/ # Gemini client (mod.rs), link_summary.rs
│ ├── platform/ # macos.rs, windows.rs, adapters.rs, mod.rs
│ ├── config/ # settings.json repository
│ ├── security/ # SecretString + redaction helpers
│ └── shell.rs # windows, tray, shortcuts, placement
└── website/ # this site (static, no build step)
The four surfaces
| Surface | Role |
|---|---|
flow-bar | Non-activating, always-on-top dictation pill. Tightly sized; never steals focus. |
writing-tools | Compact selection-aware command + result popup. Anchors to cursor / selection / fixed point. |
settings | Native-window preferences. Closing hides the window — it never quits the app. |
onboarding | Short first-run permission and setup flow. |
Windows are pre-created and shown/hidden rather than spawned per use — that’s what keeps the Flow Bar and popup instant. Placement logic lives in shell.rs (multi-monitor aware; verify on both OSes before release). UI reducers (features/dictation/state.ts, features/writing-tools/state.ts) model the popup lifecycle, including generation counters that discard late responses when a popup closes.
Platform boundary
All OS code is isolated under src-tauri/src/platform/ behind the adapter traits in adapters.rs (PlatformSpeechEngine, PlatformTextService, PlatformCredentialStore). The rest of Rust never touches an OS API directly.
| Concern | macOS 26 | Windows 11 24H2+ |
|---|---|---|
| Speech | SpeechAnalyzer + DictationTranscriber | WinRT speech (needs MSIX identity) |
| Text access | Accessibility / Core Graphics / AppKit | UI Automation + Win32 window/input |
| Secrets | Keychain | Credential Manager |
| Dictation key | Fn/Globe via Input Monitoring (best-effort) | Ctrl+Meta with Win-key suppression |
The boundary reserves application-specific and clipboard-fallback replacement strategies — but V1 fails safely when native Accessibility / UI Automation can’t preserve the selection, rather than risk mangling user text.
Dictation pipeline
speech/ manages sessions; platform/* feeds audio to the OS engine and streams levels back for the waveform (dictation-level events) and status snapshots (dictation-state: idle → listening → processing → success/error). The React side is a thin reducer over those events — see FlowBar.tsx.
On release, the transcript goes through optional AI cleanup, then text/ inserts it at the saved cursor. esc cancels; closing paths bump a generation counter so late engine callbacks are ignored.
Selection & replace pipeline
Invoking Writing Tools captures a SelectionContext (app name, bounds, initial text, canReplace) and emits writing-context. The popup runs its reducer (OPEN → RUN → RESULT / REPLACED / FAIL), calls runWritingAction, and on Replace asks Rust to write back through the platform text service. Summarize / Key Points / Chat are resultOnly — the core refuses to treat them as replacements, including through the native command path.
AI client
Pinned in src-tauri/src/ai/mod.rs:
GEMINI_MODEL = "gemini-3.8-flash" // low thinking, latency-sensitive
endpoint = .../v1beta/interactions
store = false // always — retention off
Prompts are built in writing_prompt() behind a guard: source text is treated as untrusted content, never instructions (system prefix in mod.rs), text summaries over 200,000 chars are rejected, and link summaries go through link_summary.rs (URL-context retrieval for pages, video input for YouTube) with a 90-second deadline vs. 20 seconds for ordinary writing calls. Successful URL-retrieval evidence is required before a webpage summary is shown.
src-tauri/src/ai/mod.rs and update its fixtures at the same time. That file is the single source of truth — there is intentionally no provider abstraction.Credentials & settings
Keys are saved via PlatformCredentialStore, tested against the Interactions API, and their status surfaced as configured / untested / testing / connected / invalid / rate-limited / offline. The key value itself is a SecretString that never serializes to the frontend. Plain preferences live in settings.json via config/SettingsRepository; FrontendSettings is the sanitized projection the UI may see.
Why it’s built this way
No provider abstraction. One model, one endpoint, one call site. Abstractions would widen the blast radius of a prompt/privacy mistake for zero current benefit.
No App Sandbox on macOS. System-wide Accessibility integration is incompatible with it, so Kivo ships direct (signed + notarized) instead of via the Mac App Store.
MSIX on Windows. Native speech recognition requires an installed package identity — that’s what forces the MSIX format, not fashion.
Audio never touches Gemini. The OS engine is good and already privileged; routing voice to a cloud model would add latency, cost, and a privacy liability for no quality win.
Short, vague user errors; rich-in-kind internal codes. User-facing strings stay concise; diagnostics record operation names, error categories, and non-sensitive OS codes only.
Vite-only harness for UI. bun dev renders all four surfaces with mock bridges (append ?state=listening|processing|success|error for Flow Bar states) so UI work never needs OS integration or a real key.
Conventions
| Rule | Detail |
|---|---|
| Logging | Never log selections, transcripts, responses, clipboard, or credentials. |
| Frontend | Reducers + nativeBridge; generation counters to drop stale async results. |
| Rust | OS calls stay in platform/; everything else goes through adapters. |
| Markdown | Render AI output only through SafeMarkdown. |
| Theming | Design tokens in globals.css; data-theme / data-platform on root. |
Dev workflow
bun install
bun tauri dev # full app (needs Xcode / VS Build Tools + OS perms)
bun dev # Vite-only UI harness, no OS integration
bun typecheck && bun lint && bun test
bun test:ui # Playwright; isolated port: KIVO_UI_TEST_PORT=1437 bun run test:ui
bun check:rust && cargo test --manifest-path src-tauri/Cargo.toml
Physical testing on both systems is required before a release: Fn/Globe handling, Windows-key suppression, speech model availability, multi-monitor placement, accessibility behavior in third-party apps, and signing.
Releases & updates
Pushing an app-v* tag runs .github/workflows/release.yml (macOS + Windows artifacts, drafted GitHub Release). bun run prepare:release generates the ignored release-only Tauri config and injects the updater public key — normal local builds have no trusted updater key on purpose. Configure TAURI_UPDATER_PUBKEY, TAURI_SIGNING_PRIVATE_KEY (+_PASSWORD), Apple signing/notarization secrets, and WINDOWS_CERTIFICATE_BASE64 / _PASSWORD / WINDOWS_PUBLISHER before tagging. Never commit updater private keys or certificates.