21 KiB
v0.4.0 Desktop App Implementation Plan
Goal
v0.4.0 adds a native desktop app for Cassady. A user can launch a windowed application that runs the real Cassady coding agent via the experimental Rust embedding API, with a chat composer, streaming assistant transcript, tool-call panels, approval dialogs, cancellation, and access-mode switching — all themed to match the cassady-web/ landing page's terminal aesthetic.
Success statement:
A user running the desktop app can start a chat, watch the assistant stream replies with tool calls, approve a shell command, cancel a running turn, and resume the same conversation later from the desktop app or from
cass --resume <id>in a terminal. The window looks like the landing page: dark#0b0d10background, scanlines and film grain, IBM Plex Mono chrome, blue accent glow, and a terminal-style status footer.
Scope
In scope
- Add a new Cargo workspace member
cassady-desktop/(Tauri 2 app) that depends on thecassadylibrary via a path dependency. - Port the
cassady-web/landing-page design system (CSS tokens, IBM Plex fonts, scanlines/grain/vignette overlays, glow effects, terminal styling) into the desktop frontend. - Build a chat surface: composer, streaming assistant transcript with full markdown rendering, tool-call blocks (
read/write/edit/shell/grep/ls), approval dialogs, status footer, access-mode switcher, reasoning toggle, and turn cancellation. - Build a Rust↔frontend streaming bridge over Tauri commands and
tauri::ipc::Channelthat drivesSession/Turnand forwardsEvents to the webview. - Session lifecycle: new session, resume by id, list chats for a cwd, interoperable with
~/.cass/conversations/*.jsonl. - macOS-first build; Linux and Windows follow via Tauri's cross-build story.
- Documentation: this plan, a
ROADMAP.mdentry, acassady-desktop/README.md, and a note indocs/embedding.mdreferencing the desktop app as a reference embedding.
Out of scope
- Setup wizard GUI (provider/model/key selection inside the app). A "run
cass setupfirst" card covers the no-config case for v0.4.0. - Branch/restore GUI, config editor GUI, and auto-updater wiring.
- Mobile (Tauri mobile) and app-store signing/packaging pipelines.
- Custom tools or custom provider traits (the embedding API does not allow these — only the six built-in tools and OpenAI-compatible / ChatGPT Codex providers).
- Replacing or modifying the
cass/cassadyCLI binaries or thesrc/tree. The desktop app embeds purely through the publiccassady::preludeAPI.
Context and Current State
Landing page (cassady-web/)
React 18 + Tailwind v4 (CSS @theme, no config file) + Framer Motion + Vite 6 + shadcn/ui (new-york). The full design system lives in cassady-web/src/index.css:
- Background tokens:
--color-bg #0b0d10,--color-bg-soft #111418,--color-bg-elev #161a1f. - Lines:
--color-line #232830,--color-line-soft #1a1f25. - Foreground:
--color-fg #c7ccd4,--color-fg-muted #8b929d,--color-fg-dim #5b626c. - Accent:
--color-accent #5b9fe0,--color-accent-glow rgba(91,159,224,0.18). - Amber:
--color-amber #d4a55a. - Fonts: IBM Plex Mono (400/500/600) for chrome/code/headings, IBM Plex Sans (400/500) for body. Loaded via
@fontsource/ibm-plex-monoand@fontsource/ibm-plex-sans. - Signature effects:
.vignette::before(radial dark corners),.scanlines::before(1px white lines every 3px at ~1.6% opacity, mix-blend overlay),.grain::after(SVG turbulence noise at 4% opacity),.text-glow(text-shadow: 0 0 12px var(--color-accent-glow)),.terminal-shadow(1px ring + deep drop + accent halo),.grid-fade(radial-masked 44px grid). - Shadcn primitives in
cassady-web/src/components/ui/:button.tsx(default/outline/ghost/link, sizes),card.tsx,separator.tsx,tooltip.tsx,accordion.tsx. - The animated
TerminalDemo.tsxis the visual reference for the chat surface: title bar with three gray dots, 380px body with internal scanlines and a blinking block cursor, dot-separated status footer whose color shifts (accent=running, amber=approval, dim=idle).
Embedding API (src/embedding.rs, exported via cassady::prelude)
SessionBuilder::new()with.config_root(),.cwd(),.access_mode(),.model(),.base_url(),.api_key_env(),.reasoning_effort(). Terminal methods:.build()/.new_session()and.resume(id).Sessionexposesid(),cwd(),model(),access_mode(),reasoning_effort(),conversation_path(),records(),resume_warning(),info() -> ConversationInfo.Session::start_turn(message)consumes the session and returns aTurn(type-level guarantee against overlapping turns).Turn::next_event() -> Option<Event>streams events untilEvent::Finished.Turn::finish()recovers theSession;Turn::cancel()aborts and repairs the JSONL transcript.Turn::approve(id)/deny(id)resolve pending approvals.Eventvariants:AssistantChunk,ReasoningChunk,ToolCallStarted { id, name, arguments },ToolOutputChunk { id, name, stream, content },ToolResult { id, name, ok, content },ApprovalRequested(ApprovalRequest),ApprovalResolved { request_id, approved },Status(String),Finished.ApprovalRequestcarriesrequest_id,name,arguments,reason.- Requires a Tokio runtime. Reuses
~/.cassconfig, providers, prompts, security policy, tools, and JSONL storage. No custom tools or providers.
Repo state
Single crate cassady (lib + bins cass/cassady). No existing desktop/GUI work. AGENTS.md documents release, npm, roadmap, and plan conventions. The root Cargo.toml is the cassady package; v0.4.0 adds a [workspace] block and a cassady-desktop/ member without moving the existing crate.
Tauri 2 integration points
- Vite frontend at
frontendDist: ../dist, dev server athttp://localhost:5173withstrictPort: true. - Commands are
#[tauri::command];asynccommands run on Tauri's async runtime (Tokio). State via.manage(...)andtauri::State. tauri::ipc::Channel<T>is the recommended streaming primitive — typed and ordered, better thanemit/listenfor token streams.
Design Principles
- Fidelity to the landing page. Reuse the exact CSS tokens, fonts, and effects by copying
cassady-web/src/index.cssand the IBM Plex@fontsourcesetup verbatim. The desktop chrome is a direct port, not a reinterpretation. - Drive the real agent. Embed
cassadyas a path dependency and callSessionBuilder/Turn/Eventdirectly — never spawn thecassbinary. The GUI is a peer of the TUI, reusing the same config, providers, tools, safety policy, and JSONL storage. - Typed streaming. Use
tauri::ipc::Channel<StreamEvent>whereStreamEventis a serde-friendly mirror ofcassady::Event. Channels are ordered and backpressure-friendly. - One Tokio runtime. Keep all
cassadycalls inside async commands on Tauri's async runtime; do not block the webview thread or spin a second runtime. - No fork, no library changes. Stay within the public embedding API. If a feature needs
Config/Conversationinternals, defer it rather than reaching into private modules. - Workspace, not a new repo. A Cargo workspace keeps one lockfile, shared
target/, and path-dependency access to the lib.
Design
Repo layout
cassady/
├── Cargo.toml # gains [workspace] members = ["cassady-desktop"]; stays the cassady package
├── src/ # existing cassady lib + bins (untouched)
├── cassady-web/ # landing page (untouched; design source of truth)
└── cassady-desktop/ # NEW
├── Cargo.toml # tauri deps + cassady = { path = ".." }
├── tauri.conf.json
├── build.rs # tauri::build()
├── icons/ # app icons (reuse cass-logo-transparent.png)
├── capabilities/ # default.json (permissions)
├── src/
│ ├── main.rs # tauri entry (calls lib::run)
│ ├── lib.rs # tauri::Builder + invoke_handler + manage(state)
│ ├── state.rs # DesktopState { sessions, active_turns }
│ ├── session.rs # new_session / resume_session / list_chats / session_info commands
│ ├── turn.rs # start_turn (returns Channel<StreamEvent>), approve, deny, cancel_turn
│ └── types.rs # StreamEvent (mirror of cassady::Event), ConversationInfoDto, ChatSummary, etc.
└── frontend/
├── package.json # vite, react, @tauri-apps/api, @fontsource/ibm-plex-*, motion, react-markdown, tailwindcss v4
├── vite.config.ts # port 5173 strict, watch ignore src-tauri
├── tsconfig.json
├── index.html # <html class="dark">
└── src/
├── main.tsx
├── App.tsx # chat shell
├── index.css # ported from cassady-web/src/index.css
├── lib/utils.ts # cn() helper
├── lib/tauri.ts # typed wrappers around invoke + Channel
├── hooks/useTurn.ts # subscribes to Channel<StreamEvent>, buckets into transcript blocks
├── components/
│ ├── ChatShell.tsx
│ ├── Transcript.tsx
│ ├── blocks/ # UserBlock, AssistantBlock, ReasoningBlock, ToolBlock, StatusBlock
│ ├── Composer.tsx
│ ├── ApprovalDialog.tsx
│ ├── StatusFooter.tsx
│ └── ui/ # button, card, separator, tooltip (copied from cassady-web)
└── assets/ # cass-logo-transparent.png
The root Cargo.toml gains a [workspace] table:
[workspace]
members = ["cassady-desktop"]
resolver = "2"
The existing cassady package remains the root package (the root is implicitly a workspace member), so existing cargo/cargo install/release/npm scripts keep working unchanged.
Design system port
Copy cassady-web/src/index.css verbatim into cassady-desktop/frontend/src/index.css. It defines the @theme token block, the global * { border-color: var(--color-line) } rule, scrollbars, ::selection, and the .vignette/.scanlines/.grain/.text-glow/.grid-fade/.terminal-shadow utilities plus @keyframes blink. Apply vignette scanlines grain classes to the ChatShell root so the whole app window has the landing-page atmosphere. Load IBM Plex via @fontsource/ibm-plex-mono (400/500/600) and @fontsource/ibm-plex-sans (400/500) in main.tsx, identical to the landing page. Copy the shadcn ui/ primitives (button, card, separator, tooltip, accordion) so buttons and cards match exactly. Reuse cass-logo-transparent.png for the app icon and in-app branding.
Rust↔frontend bridge
Commands (all async, registered via tauri::generate_handler!):
| Command | Args | Returns | Notes |
|---|---|---|---|
new_session |
{ cwd?, accessMode?, model?, baseUrl?, apiKeyEnv?, reasoningEffort? } |
ConversationInfoDto |
Wraps SessionBuilder::new_session. Stores Session in DesktopState keyed by id. |
resume_session |
{ chatId, cwd? } |
ConversationInfoDto |
Wraps SessionBuilder::resume. Surfaces resume_warning. |
list_chats |
{ cwd? } |
Vec<ChatSummary> |
Reads ~/.cass/conversations/ via config::cass_root() + conversations_dir(). |
start_turn |
{ message, onEvent: Channel<StreamEvent> } |
TurnHandle |
Spawns a Tokio task that loops turn.next_event(), maps each cassady::Event → StreamEvent, sends over the channel. Stores the Turn in state. Returns a turn id. |
approve |
{ turnId, requestId } |
() |
Calls turn.approve. |
deny |
{ turnId, requestId } |
() |
Calls turn.deny. |
cancel_turn |
{ turnId } |
ConversationInfoDto |
Calls turn.cancel().await, recovers the Session, returns updated info. |
session_info |
{ chatId } |
ConversationInfoDto |
Session::info(). |
State (DesktopState, managed via .manage()):
Mutex<HashMap<String, Session>>— sessions by chat id.Mutex<HashMap<String, TurnEntry>>— active turns by turn id.TurnEntryholds theTurnplus a handle to the worker task. BecauseSession::start_turnconsumes theSession, the worker task holds it transiently and re-inserts the recoveredSessioninto the sessions map onfinish/cancel.
StreamEvent (types.rs, derives Serialize, camelCase for TS):
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum StreamEvent {
AssistantChunk { text: String },
ReasoningChunk { text: String },
ToolCallStarted { id: String, name: String, arguments: serde_json::Value },
ToolOutputChunk { id: String, name: String, stream: String, content: String },
ToolResult { id: String, name: String, ok: bool, content: String },
ApprovalRequested { requestId: String, name: String, arguments: serde_json::Value, reason: String },
ApprovalResolved { requestId: String, approved: bool },
Status { text: String },
Finished,
Error { message: String },
}
A dedicated worker task per turn: loop { match turn.next_event().await { Ok(Some(ev)) => map & channel.send, Ok(None) => break, Err(e) => send Error } }. On normal completion the task calls turn.finish() and re-inserts the recovered Session; on cancellation the cancel_turn command calls turn.cancel() directly (the worker observes channel closure).
Frontend chat surface
ChatShell.tsx is a full-viewport flex column:
- Top bar (ported
Nav.tsxstyle, minus the landing links): logo + "cassady" wordmark, right side shows model + access-mode pill + reasoning toggle. - Transcript (center, flex-1, scrollable): renders
TranscriptBlock[]produced byuseTurn. Block types mirror the landing page'sTerminalDemo.tsxstyling:user→› youin accent, content in fg-muted.assistant→cassin accent with text-glow, markdown body rendered withreact-markdown(headings, lists, links, fenced code blocks). Inline code in accent, fenced code inbg-bg-soft.reasoning→ collapsible, dim, off by default.tool→· {name}in fg-dim, arguments as a<pre>; liveToolOutputChunks stream into the body; finalToolResultshows✓/✗in accent/amber + summary. MirrorsBlockViewinTerminalDemo.tsx.status→· {text}in amber.
- Approval dialog: when
ApprovalRequestedarrives, show a modal (the landing-page card style) with tool name, arguments (pretty-printed JSON), reason, and Approve / Deny buttons. Callsapprove/denycommands. - Composer (bottom): a textarea (mono font, accent
›prompt),Enterto send,Shift-Enter/Ctrl-J/Ctrl-Enterfor newline, Send button becomes Stop while a turn runs (callscancel_turn). Left side: access-mode cycle (Shift-Tab) and reasoning-effort cycle (Tab) as small mono pills. - Status footer (ported from
TerminalDemo.tsxfooter):cass · {mode} · {state} · {model} · {cwd} · {id} · reasoning:{effort}. State color shifts: running=accent, approval=amber, idle=fg-dim.
useTurn.ts subscribes to the Channel<StreamEvent> via @tauri-apps/api/core, appends chunks to the right block (assistant text accumulates, tool output streams into the open tool block until ToolResult closes it), and exposes { blocks, isRunning, pendingApproval, cancel }.
Session lifecycle UX
- On launch: if
~/.casshas no valid provider config, show a small card "Runcass setupfirst" with a copy-to-clipboardcass setupcommand (reuseInstallCommand.tsxstyling). Full setup wizard is deferred. - "New chat" and "Open chat…" (resume) in the top bar. Resume shows a list of
ChatSummary { id, cwd, recordCount, mtime }filtered to the current cwd (matchingcass --resumebehavior). - On turn finish, surface
session.id()and a "resume in terminal" hint, mirroring the CLI's resume-command printout.
Implementation Steps
- Write the plan and roadmap entry. This document and
ROADMAP.md## v0.4.0 — Desktop App. - Scaffold the workspace. Add
[workspace] members = ["cassady-desktop"]to the rootCargo.toml. Createcassady-desktop/withCargo.toml(tauri deps +cassadypath dep),tauri.conf.json,build.rs,capabilities/default.json,icons/, andfrontend/(package.json, vite.config.ts, tsconfig, index.html, src skeleton). Confirmcargo build -p cassady-desktopandnpm run tauri devreach a blank window. - Port the design system. Copy
index.css,lib/utils.ts,ui/primitives, IBM Plex@fontsourceimports, andcass-logo-transparent.pngintocassady-desktop/frontend/src/. Confirm the window opens with the dark themed background and scanlines/grain/vignette overlays rendering. - Build the Rust bridge. Implement
DesktopState,StreamEvent,ConversationInfoDto,ChatSummary, and the commands incassady-desktop/src/{lib,state,types,session,turn}.rs. Wiretauri::Builder::default().manage(...).invoke_handler(generate_handler![...]). Confirm anew_session→session_inforound-trip works from the devtools console. - Wire
start_turnstreaming. Spawn the per-turn worker ontauri::async_runtime::spawn, sendStreamEvents over theChannel, handlefinish/cancelre-insertion ofSession. Verify against a read-only session against the current repo (e.g. "list the files in this directory") — assistant text and tool blocks should stream live. - Build the chat UI. Implement
ChatShell,Transcript, the block components (UserBlock,AssistantBlockwithreact-markdown,ReasoningBlock,ToolBlock,StatusBlock),Composer,StatusFooter, andApprovalDialogusing the ported design tokens. WireuseTurnto the channel. - Add approvals + cancellation.
ApprovalDialog→approve/deny; Stop button →cancel_turn. Test inworkspace-editmode (shell command triggers an approval) andfull-accesswithconfirm_destructive_operations(write/edit triggers an approval). - Session management.
list_chats+ resume UI; new-chat; interop check: start a chat in the desktop app, resume it withcass --resume <id>in a terminal, and vice versa. - Polish. Scroll-to-bottom behavior, keyboard shortcuts matching the TUI where sensible (
Escto cancel,Shift-Tab/Tabfor mode/reasoning), window title showing chat id + cwd. - Build + verify.
cargo build -p cassady-desktop,cargo test --locked --all-targets(existing tests must still pass; add a small Rust test forStreamEventmapping and session round-trip against a tempconfig_root), andcd cassady-desktop/frontend && npm run build && tsc --noEmit.
Tests
- Rust: unit test that maps each
cassady::Eventvariant →StreamEventand asserts the JSON shape. Integration test usingSessionBuilder::new().config_root(tempdir).cwd(tempdir).access_mode(ReadOnly).build()+ a stub provider (or skip if no key in CI) to confirmnew_session/start_turn/canceldon't panic. Existingcargo test --locked --all-targetsmust stay green. - Frontend:
tsc --noEmittypecheck. Manual smoke-test checklist documented incassady-desktop/README.md: new chat, streaming text, tool block streaming, approval flow, cancel, resume interop. - Manual: run the desktop app against this repo, run
cass --resume <id>on a chat started in the GUI, and run the GUI on a chat started in the CLI.
Documentation
plans/V0_4_0_DESKTOP_APP_PLAN.md(this document).ROADMAP.md## v0.4.0 — Desktop Appentry with areas for Workspace Setup, Design System Port, Chat Surface, Session Lifecycle, and Verification.cassady-desktop/README.md: prerequisites (Tauri system deps),npm run tauri dev,npm run tauri build, and the "runcass setupfirst" requirement.docs/embedding.md: a short note pointing to the desktop app as a reference embedding of the library API.
Acceptance Criteria
cassady-desktop/builds withcargo build -p cassady-desktopandnpm run tauri buildproduces a runnable macOS app.- Launching the app, starting a chat, streaming an assistant reply with tool calls, approving a shell command, and cancelling a running turn all work end-to-end.
- A chat started in the desktop app is resumable from
cass --resume <id>, and a chat started in the CLI is openable in the desktop app. - The UI matches the landing-page theme: same color tokens, IBM Plex fonts, scanlines/grain/vignette overlays, accent glow, terminal-style status footer.
cargo test --locked --all-targetspasses across the workspace;cargo fmtis clean.- No changes to
src/— the desktop app embeds purely through the publiccassady::preludeAPI.