commit 7a72d5be17acfa417f64e44661213daf86688c5e
parent 1920df7d0a5cdc100efd68a13669ba260372b9e1
Author: William Casarin <jb55@jb55.com>
Date: Mon, 9 Feb 2026 12:49:09 -0800
Merge Damus Agentium v0
Agentium: Multi-Agent Development Environment for Dave
======================================================
This PR transforms Dave from a simple Nostr chat assistant into a
full-featured multi-agent development environment with Claude Code
integration.
Summary
-------
- Dual AI Modes: Chat mode for simple Nostr queries, Agentic mode for
coding workflows
- Multi-Agent System: RTS-style scene view for managing multiple AI
agents simultaneously
- Claude Code Integration: Interactive permission handling with diff
views, auto-accept rules, and session management
- Keyboard-Driven UX: Comprehensive keybindings for efficient agent
management
- External Integration: IPC socket for spawning agents from external
tools
Key Features
------------
Multi-Agent Management
- RTS-style scene view with grid layout for visualizing multiple agents
- Focus queue with priority-based attention (NeedsInput > Error > Done)
- Auto-steal focus mode for automatically cycling through agents needing attention
- Per-session working directories and independent conversation contexts
Claude Code Integration
- Interactive Allow/Deny UI for tool permission requests
- Syntax-highlighted diff view for Edit/Write operations
- Auto-accept rules for safe operations (small edits, read-only commands, cargo)
- Plan mode toggle for architectural planning workflows
- Session resume from ~/.claude/projects/
- AskUserQuestion tool support with multi-choice answers
Keyboard Shortcuts
- 1/2 for quick permission responses
- Ctrl+Tab/Ctrl+Shift+Tab for agent cycling
- Ctrl+N/Ctrl+P for focus queue navigation
- Ctrl+T for new agent, Ctrl+Shift+T to clone with same cwd
- Escape (double-press) to interrupt AI operations
New UI Components
- Directory picker for selecting working directories
- Session picker for resuming Claude sessions
- Status badges with contextual keybind hints
- Compaction status indicator
Diffstat:
46 files changed, 10698 insertions(+), 581 deletions(-)
diff --git a/.beads/PRIME.md b/.beads/PRIME.md
@@ -0,0 +1,103 @@
+# Beads Workflow Context
+
+> **Context Recovery**: Run `bd prime` after compaction, clear, or new session
+> Hooks auto-call this in Claude Code when .beads/ detected
+
+## Project-Specific Rules
+
+This beads database is shared across multiple notedeck subprojects:
+- `columns` - notedeck-columns
+- `dave` - notedeck-dave
+- `dev` - notedeck-dev
+- `messages` - notedeck-messages
+- `core` - main notedeck repo
+
+**ALWAYS** add a subproject label when creating issues:
+```bash
+bd create --title="Fix bug" --type=bug
+bd label add <new-issue-id> columns # Add subproject label
+```
+
+Query issues for a specific subproject:
+```bash
+bd list -l columns
+bd list -l dave
+```
+
+# SESSION CLOSE PROTOCOL
+
+**CRITICAL**: Before saying "done" or "complete", you MUST run this checklist:
+
+```
+[ ] 1. git status (check what changed)
+[ ] 2. git add <files> (stage code changes)
+[ ] 3. bd sync (commit beads changes)
+[ ] 4. git commit -m "..." (commit code)
+[ ] 5. bd sync (commit any new beads changes)
+[ ] 6. git push (push to remote)
+```
+
+**NEVER skip this.** Work is not done until pushed.
+
+## Core Rules
+- Track strategic work in beads (multi-session, dependencies, discovered work)
+- Use `bd create` for issues, TodoWrite for simple single-session execution
+- When in doubt, prefer bd—persistence you don't need beats lost context
+- Git workflow: hooks auto-sync, run `bd sync` at session end
+- Session management: check `bd ready` for available work
+
+## Essential Commands
+
+### Finding Work
+- `bd ready` - Show issues ready to work (no blockers)
+- `bd list --status=open` - All open issues
+- `bd list --status=in_progress` - Your active work
+- `bd show <id>` - Detailed issue view with dependencies
+
+### Creating & Updating
+- `bd create --title="..." --type=task|bug|feature --priority=2` - New issue
+ - Priority: 0-4 or P0-P4 (0=critical, 2=medium, 4=backlog). NOT "high"/"medium"/"low"
+- `bd update <id> --status=in_progress` - Claim work
+- `bd update <id> --assignee=username` - Assign to someone
+- `bd update <id> --title/--description/--notes/--design` - Update fields inline
+- `bd close <id>` - Mark complete
+- `bd close <id1> <id2> ...` - Close multiple issues at once (more efficient)
+- `bd close <id> --reason="explanation"` - Close with reason
+- **Tip**: When creating multiple issues/tasks/epics, use parallel subagents for efficiency
+- **WARNING**: Do NOT use `bd edit` - it opens $EDITOR (vim/nano) which blocks agents
+
+### Dependencies & Blocking
+- `bd dep add <issue> <depends-on>` - Add dependency (issue depends on depends-on)
+- `bd blocked` - Show all blocked issues
+- `bd show <id>` - See what's blocking/blocked by this issue
+
+### Sync & Collaboration
+- `bd sync` - Sync with git remote (run at session end)
+- `bd sync --status` - Check sync status without syncing
+
+### Project Health
+- `bd stats` - Project statistics (open/closed/blocked counts)
+- `bd doctor` - Check for issues (sync problems, missing hooks)
+
+## Common Workflows
+
+**Starting work:**
+```bash
+bd ready # Find available work
+bd show <id> # Review issue details
+bd update <id> --status=in_progress # Claim it
+```
+
+**Completing work:**
+```bash
+bd close <id1> <id2> ... # Close all completed issues at once
+bd sync # Push to remote
+```
+
+**Creating dependent work:**
+```bash
+# Run bd create commands in parallel (use subagents for many items)
+bd create --title="Implement feature X" --type=feature
+bd create --title="Write tests for X" --type=task
+bd dep add beads-yyy beads-xxx # Tests depend on Feature (Feature blocks tests)
+```
diff --git a/Cargo.lock b/Cargo.lock
@@ -453,6 +453,28 @@ dependencies = [
]
[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.104",
+]
+
+[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1077,7 +1099,7 @@ dependencies = [
"num-traits",
"serde",
"wasm-bindgen",
- "windows-link",
+ "windows-link 0.1.3",
]
[[package]]
@@ -1109,6 +1131,29 @@ dependencies = [
]
[[package]]
+name = "claude-agent-sdk-rs"
+version = "0.6.3"
+source = "git+https://github.com/jb55/claude-agent-sdk-rs?rev=246ddc912e61b0e6892532e74673b1e86db5e7b0#246ddc912e61b0e6892532e74673b1e86db5e7b0"
+dependencies = [
+ "anyhow",
+ "async-stream",
+ "async-trait",
+ "dashmap",
+ "flume",
+ "futures",
+ "paste",
+ "path-absolutize",
+ "pin-project",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.12",
+ "tokio",
+ "tracing",
+ "typed-builder",
+ "uuid",
+]
+
+[[package]]
name = "clipboard-win"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1359,6 +1404,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04"
[[package]]
+name = "dashmap"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1997,6 +2056,9 @@ name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+dependencies = [
+ "getrandom 0.2.16",
+]
[[package]]
name = "fdeflate"
@@ -2123,6 +2185,18 @@ dependencies = [
]
[[package]]
+name = "flume"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2571,6 +2645,12 @@ checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
[[package]]
name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
@@ -2780,7 +2860,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2",
+ "socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -4058,6 +4138,9 @@ dependencies = [
"async-openai",
"bytemuck",
"chrono",
+ "claude-agent-sdk-rs",
+ "dashmap",
+ "dirs",
"eframe",
"egui",
"egui-wgpu",
@@ -4069,11 +4152,14 @@ dependencies = [
"notedeck",
"notedeck_ui",
"rand 0.9.2",
+ "rfd",
"serde",
"serde_json",
"sha2",
+ "similar",
"tokio",
"tracing",
+ "uuid",
]
[[package]]
@@ -4707,6 +4793,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
+name = "path-absolutize"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5"
+dependencies = [
+ "path-dedot",
+]
+
+[[package]]
+name = "path-dedot"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5042,7 +5146,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls",
- "socket2",
+ "socket2 0.5.10",
"thiserror 2.0.12",
"tokio",
"tracing",
@@ -5079,7 +5183,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2",
+ "socket2 0.5.10",
"tracing",
"windows-sys 0.59.0",
]
@@ -6125,6 +6229,25 @@ dependencies = [
]
[[package]]
+name = "socket2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
name = "spirv"
version = "0.3.0+sdk-1.3.268.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6457,25 +6580,25 @@ dependencies = [
[[package]]
name = "tokio"
-version = "1.45.1"
+version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
- "backtrace",
"bytes",
"libc",
"mio",
"pin-project-lite",
- "socket2",
+ "signal-hook-registry",
+ "socket2 0.6.2",
"tokio-macros",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
-version = "2.5.0"
+version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -6791,6 +6914,26 @@ dependencies = [
]
[[package]]
+name = "typed-builder"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda"
+dependencies = [
+ "typed-builder-macro",
+]
+
+[[package]]
+name = "typed-builder-macro"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.104",
+]
+
+[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7022,9 +7165,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
-version = "1.17.0"
+version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
+checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
dependencies = [
"getrandom 0.3.3",
"js-sys",
@@ -7556,7 +7699,7 @@ dependencies = [
"windows-collections",
"windows-core 0.61.2",
"windows-future",
- "windows-link",
+ "windows-link 0.1.3",
"windows-numerics",
]
@@ -7609,7 +7752,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement 0.60.0",
"windows-interface 0.59.1",
- "windows-link",
+ "windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
@@ -7621,7 +7764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
- "windows-link",
+ "windows-link 0.1.3",
"windows-threading",
]
@@ -7676,13 +7819,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.2",
- "windows-link",
+ "windows-link 0.1.3",
]
[[package]]
@@ -7709,7 +7858,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
- "windows-link",
+ "windows-link 0.1.3",
]
[[package]]
@@ -7728,7 +7877,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
- "windows-link",
+ "windows-link 0.1.3",
]
[[package]]
@@ -7777,6 +7926,15 @@ dependencies = [
]
[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7844,7 +8002,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
- "windows-link",
+ "windows-link 0.1.3",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
@@ -110,6 +110,7 @@ blurhash = "0.2.3"
android-activity = { git = "https://github.com/damus-io/android-activity", rev = "4ee16f1585e4a75031dc10785163d4b920f95805", features = [ "game-activity" ] }
keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "vendored"] }
android-keyring = "0.2.0"
+rfd = "0.15"
[profile.small]
inherits = 'release'
diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml
@@ -3,7 +3,7 @@ name = "notedeck_chrome"
version = { workspace = true }
authors = ["William Casarin <jb55@jb55.com>", "kernelkind <kernelkind@gmail.com>"]
edition = "2021"
-#default-run = "notedeck"
+default-run = "notedeck"
#rust-version = "1.60"
license = "GPLv3"
description = "The nostr browser"
diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs
@@ -155,7 +155,11 @@ impl Chrome {
stop_debug_mode(notedeck.options());
let context = &mut notedeck.app_context();
- let dave = Dave::new(cc.wgpu_render_state.as_ref());
+ let dave = Dave::new(
+ cc.wgpu_render_state.as_ref(),
+ context.ndb.clone(),
+ cc.egui_ctx.clone(),
+ );
let mut chrome = Chrome::default();
if !app_args.iter().any(|arg| arg == "--no-columns-app") {
diff --git a/crates/notedeck_columns/Cargo.toml b/crates/notedeck_columns/Cargo.toml
@@ -61,7 +61,7 @@ oot_bitset = { workspace = true }
human_format = "1.1.0"
[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies]
-rfd = "0.15"
+rfd = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml
@@ -5,6 +5,7 @@ version.workspace = true
[dependencies]
async-openai = { version = "0.28.0", features = ["rustls-webpki-roots"] }
+claude-agent-sdk-rs = { git = "https://github.com/jb55/claude-agent-sdk-rs", rev = "246ddc912e61b0e6892532e74673b1e86db5e7b0"}
egui = { workspace = true }
sha2 = { workspace = true }
notedeck = { workspace = true }
@@ -19,8 +20,22 @@ serde = { workspace = true }
nostrdb = { workspace = true }
hex = { workspace = true }
chrono = { workspace = true }
-rand = "0.9.0"
+rand = { workspace = true }
+uuid = { version = "1", features = ["v4"] }
bytemuck = "1.22.0"
futures = "0.3.31"
+dashmap = "6"
#reqwest = "0.12.15"
egui_extras = { workspace = true }
+similar = "2"
+dirs = "5"
+
+[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies]
+rfd = { workspace = true }
+
+[dev-dependencies]
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] }
+
+[[bin]]
+name = "notedeck-spawn"
+path = "src/bin/notedeck-spawn.rs"
diff --git a/crates/notedeck_dave/README.md b/crates/notedeck_dave/README.md
@@ -1,70 +1,159 @@
# Dave - The Nostr AI Assistant
-Dave is an AI-powered assistant for the Nostr protocol, built as a Notedeck application. It provides a conversational interface that can search, analyze, and present Nostr notes to users.
+Dave is an AI-powered assistant for the Nostr protocol, built as a Notedeck application. It provides both a simple conversational interface for querying Nostr data and a full-featured agentic coding environment with Claude Code integration.
<img src="https://cdn.jb55.com/s/73ebab8f43804da8.png" width="50%"/>
## Overview
-Dave demonstrates how to build a feature-rich application on the Notedeck platform that interacts with Nostr content. It serves both as a useful tool for Nostr users and as a reference implementation for developers building Notedeck apps.
+Dave serves two purposes:
+
+1. **Nostr Assistant** - A conversational interface that can search, analyze, and present Nostr notes using natural language
+2. **Agentic IDE** - A multi-agent development environment with Claude Code integration for coding tasks
+
+You can use either mode depending on your needs—simple chat for quick Nostr queries, or agentic mode for software development workflows.
## Features
+### Core Features
+
- [x] Interactive 3D avatar with WebGPU rendering
- [x] Natural language conversations with AI
- [x] Query and search the local Nostr database for notes
- [x] Present and render notes to the user
- [x] Tool-based architecture for AI actions
+- [x] Multiple AI providers (OpenAI, Anthropic, Ollama)
- [ ] Context-aware searching (home, profile, or global scope)
-- [ ] Chat history persistence
- [ ] Anonymous [lmzap](https://jb55.com/lmzap) backend
+### AI Modes
+
+- **Chat Mode** - Simple OpenAI-style conversational interface for Nostr queries
+- **Agentic Mode** - Full IDE with permissions, sessions, scene view, and Claude Code integration
+
+### Multi-Agent System (Agentic Mode)
+
+- **RTS-Style Scene View** - Visual grid-based view for managing multiple agents simultaneously
+- **Focus Queue** - Priority-based system for agent attention (NeedsInput > Error > Done)
+- **Auto-Steal Focus** - Automatically cycle through agents requiring attention
+- **Session Management** - Multiple independent AI sessions with per-session working directories
+- **Subagent Support** - Track and display Task tool subagents within chat history
+
+### Claude Code Integration (Agentic Mode)
+
+- **Interactive Permissions** - Allow/Deny tool calls with diff view for file changes
+- **Auto-Accept Rules** - Configurable rules for automatically accepting safe operations:
+ - Small edits (2 lines or less by default)
+ - Read-only bash commands (grep, ls, cat, find, etc.)
+ - Cargo commands (build, check, test, fmt, clippy)
+- **Plan Mode** - Toggle plan mode with `Ctrl+M` for architectural planning
+- **Session Resume** - Resume previous Claude Code sessions from filesystem
+- **AskUserQuestion Support** - Answer multi-choice questions from the AI
+
+### Keyboard-Driven Workflow (Agentic Mode)
+
+| Shortcut | Action |
+|----------|--------|
+| `1` / `2` | Accept / Deny permission requests |
+| `Shift+1` / `Shift+2` | Accept / Deny with custom message |
+| `Escape` | Interrupt AI (double-press to confirm) |
+| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle through agents |
+| `Ctrl+1-9` | Jump to agent by number |
+| `Ctrl+T` | New agent |
+| `Ctrl+Shift+T` | Clone agent (same working directory) |
+| `Ctrl+N` / `Ctrl+P` | Focus queue navigation (higher/lower priority) |
+| `Ctrl+D` | Toggle Done status in focus queue |
+| `Ctrl+M` | Toggle plan mode |
+| `Ctrl+\` | Toggle auto-steal focus |
+| `Ctrl+G` | Open external editor for input |
+| `Ctrl+V` | Toggle scene view |
+| `Delete` | Delete selected agent |
+
+### UI Components
+
+- **Diff View** - Syntax-highlighted diff for Edit/Write tool permission requests
+- **Status Badges** - Visual indicators for plan mode, agent status, and keybinds
+- **Keybind Hints** - Contextual hints shown when Ctrl is held
+- **Directory Picker** - Select working directory when creating sessions
+- **Session Picker** - Resume existing Claude Code sessions
+- **Compaction Status** - Visual indicator when `/compact` is running
+
+### External Integration
+
+- **IPC Spawn** - Create agents from external tools via Unix domain socket
+- **`notedeck-spawn` CLI** - Spawn agents from terminal: `notedeck-spawn /path/to/project`
+
## Technical Details
Dave uses:
- Egui for UI rendering
-- WebGPU for 3D avatar visualization
-- OpenAI API (or Ollama with compatible models)
-- NostrDB for efficient note storage and querying
-- Async Rust for non-blocking API interactions
+- WebGPU for 3D avatar visualization (optional)
+- Claude Agent SDK for agentic workflows
+- OpenAI API / Anthropic API / Ollama for chat mode
+- NostrDB for Nostr note storage and querying
+- Tokio for async operations
+- Unix domain sockets for IPC
## Architecture
Dave is structured around several key components:
-1. **UI Layer** - Handles rendering and user interactions
+1. **UI Layer** - Handles rendering with scene view and chat panels
2. **Avatar** - 3D representation with WebGPU rendering
-3. **AI Client** - Connects to language models via OpenAI or Ollama
-4. **Tools System** - Provides structured ways for the AI to interact with Nostr data
-5. **Message Handler** - Manages conversation state and message processing
-
-## Usage as a Reference
-
-Dave serves as an excellent reference for developers looking to:
-
-- Build conversational interfaces in Notedeck
-- Implement 3D rendering with WebGPU in Rust applications
-- Create tool-based AI agents that can take actions in response to user requests
-- Query and present Nostr content in custom applications
+3. **Session Manager** - Manages multiple independent AI sessions
+4. **Focus Queue** - Priority-based attention system for multi-agent workflows
+5. **AI Backend** - Pluggable backends (Claude, OpenAI, Ollama)
+6. **Tools System** - Provides structured ways for the AI to interact with Nostr data
+7. **Auto-Accept Rules** - Configurable permission auto-approval
+8. **IPC Listener** - External spawn requests via Unix socket
+9. **Session Discovery** - Finds resumable Claude sessions from `~/.claude/projects/`
## Getting Started
+### Chat Mode (OpenAI/Ollama)
+
1. Clone the repository
-2. Set up your API keys for OpenAI or configure Ollama
+2. Set up your API keys:
```
export OPENAI_API_KEY=your_api_key_here
# or for Ollama
export OLLAMA_HOST=http://localhost:11434
```
-3. Build and run the Notedeck application with Dave
+3. Build and run Notedeck with Dave
+
+### Agentic Mode (Claude Code)
+
+1. Install Claude Code CLI: `npm install -g @anthropic-ai/claude-code`
+2. Authenticate: `claude login`
+3. Run Dave and select a working directory when creating a new agent
## Configuration
Dave can be configured to use different AI backends:
-- OpenAI API (default) - Set the `OPENAI_API_KEY` environment variable
-- Ollama - Use a compatible model like `hhao/qwen2.5-coder-tools` and set the `OLLAMA_HOST` environment variable
+- **OpenAI API** (Chat mode) - Set the `OPENAI_API_KEY` environment variable
+- **Anthropic Claude** (Chat mode) - Set the `ANTHROPIC_API_KEY` environment variable
+- **Ollama** (Chat mode) - Use a compatible model and set the `OLLAMA_HOST` environment variable
+- **Claude Code** (Agentic mode) - Requires Claude Code CLI installed and authenticated
+
+## File Locations
+
+- IPC Socket:
+ - Linux: `$XDG_RUNTIME_DIR/notedeck/spawn.sock`
+ - macOS: `~/Library/Application Support/notedeck/spawn.sock`
+- Claude Sessions: `~/.claude/projects/<project-path>/`
+
+## Usage as a Reference
+
+Dave serves as an excellent reference for developers looking to:
+
+- Build conversational interfaces in Notedeck
+- Implement 3D rendering with WebGPU in Rust applications
+- Create tool-based AI agents that can take actions in response to user requests
+- Query and present Nostr content in custom applications
+- Build multi-agent systems with priority-based focus management
+- Integrate Claude Code into custom applications
## Contributing
@@ -77,3 +166,5 @@ GPL
## Related Projects
- [nostrdb](https://github.com/damus-io/nostrdb) - Embedded database for Nostr notes
+- [Claude Code](https://claude.ai/claude-code) - Anthropic's agentic coding tool
+- [Claude Agent SDK](https://github.com/anthropics/claude-code) - SDK for Claude Code integration
diff --git a/crates/notedeck_dave/src/agent_status.rs b/crates/notedeck_dave/src/agent_status.rs
@@ -0,0 +1,39 @@
+/// Represents the current status of an agent in the RTS scene
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum AgentStatus {
+ /// Agent is idle, no active work
+ #[default]
+ Idle,
+ /// Agent is actively processing (receiving tokens, executing tools)
+ Working,
+ /// Agent needs user input (permission request pending)
+ NeedsInput,
+ /// Agent encountered an error
+ Error,
+ /// Agent completed its task successfully
+ Done,
+}
+
+impl AgentStatus {
+ /// Get the color associated with this status
+ pub fn color(&self) -> egui::Color32 {
+ match self {
+ AgentStatus::Idle => egui::Color32::from_rgb(128, 128, 128), // Gray
+ AgentStatus::Working => egui::Color32::from_rgb(50, 205, 50), // Green
+ AgentStatus::NeedsInput => egui::Color32::from_rgb(255, 200, 0), // Yellow/amber
+ AgentStatus::Error => egui::Color32::from_rgb(220, 60, 60), // Red
+ AgentStatus::Done => egui::Color32::from_rgb(70, 130, 220), // Blue
+ }
+ }
+
+ /// Get a human-readable label for this status
+ pub fn label(&self) -> &'static str {
+ match self {
+ AgentStatus::Idle => "Idle",
+ AgentStatus::Working => "Working",
+ AgentStatus::NeedsInput => "Needs Input",
+ AgentStatus::Error => "Error",
+ AgentStatus::Done => "Done",
+ }
+ }
+}
diff --git a/crates/notedeck_dave/src/auto_accept.rs b/crates/notedeck_dave/src/auto_accept.rs
@@ -0,0 +1,319 @@
+//! Auto-accept rules for tool permission requests.
+//!
+//! This module provides a configurable rules-based system for automatically
+//! accepting certain tool calls without requiring user confirmation.
+
+use crate::file_update::FileUpdate;
+use serde_json::Value;
+
+/// A rule for auto-accepting tool calls
+#[derive(Debug, Clone)]
+pub enum AutoAcceptRule {
+ /// Auto-accept Edit tool calls that change at most N lines
+ SmallEdit { max_lines: usize },
+ /// Auto-accept Bash tool calls matching these command prefixes
+ BashCommand { prefixes: Vec<String> },
+ /// Auto-accept specific read-only tools unconditionally
+ ReadOnlyTool { tools: Vec<String> },
+}
+
+impl AutoAcceptRule {
+ /// Check if this rule matches the given tool call
+ fn matches(&self, tool_name: &str, tool_input: &Value) -> bool {
+ match self {
+ AutoAcceptRule::SmallEdit { max_lines } => {
+ if let Some(file_update) = FileUpdate::from_tool_call(tool_name, tool_input) {
+ file_update.is_small_edit(*max_lines)
+ } else {
+ false
+ }
+ }
+ AutoAcceptRule::BashCommand { prefixes } => {
+ if tool_name != "Bash" {
+ return false;
+ }
+ let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) else {
+ return false;
+ };
+ let command_trimmed = command.trim();
+ prefixes
+ .iter()
+ .any(|prefix| command_trimmed.starts_with(prefix))
+ }
+ AutoAcceptRule::ReadOnlyTool { tools } => tools.iter().any(|t| t == tool_name),
+ }
+ }
+}
+
+/// Collection of auto-accept rules
+#[derive(Debug, Clone)]
+pub struct AutoAcceptRules {
+ rules: Vec<AutoAcceptRule>,
+}
+
+impl Default for AutoAcceptRules {
+ fn default() -> Self {
+ Self {
+ rules: vec![
+ AutoAcceptRule::SmallEdit { max_lines: 2 },
+ AutoAcceptRule::BashCommand {
+ prefixes: vec![
+ // Cargo commands
+ "cargo build".into(),
+ "cargo check".into(),
+ "cargo test".into(),
+ "cargo fmt".into(),
+ "cargo clippy".into(),
+ "cargo run".into(),
+ "cargo doc".into(),
+ // Read-only bash commands
+ "grep ".into(),
+ "grep\t".into(),
+ "rg ".into(),
+ "rg\t".into(),
+ "find ".into(),
+ "find\t".into(),
+ "ls".into(),
+ "ls ".into(),
+ "ls\t".into(),
+ "cat ".into(),
+ "cat\t".into(),
+ "head ".into(),
+ "head\t".into(),
+ "tail ".into(),
+ "tail\t".into(),
+ "wc ".into(),
+ "wc\t".into(),
+ "file ".into(),
+ "file\t".into(),
+ "stat ".into(),
+ "stat\t".into(),
+ "which ".into(),
+ "which\t".into(),
+ "type ".into(),
+ "type\t".into(),
+ "pwd".into(),
+ "tree".into(),
+ "tree ".into(),
+ "tree\t".into(),
+ "du ".into(),
+ "du\t".into(),
+ "df ".into(),
+ "df\t".into(),
+ // Git read-only commands
+ "git status".into(),
+ "git log".into(),
+ "git diff".into(),
+ "git show".into(),
+ "git branch".into(),
+ "git remote".into(),
+ "git rev-parse".into(),
+ "git ls-files".into(),
+ "git describe".into(),
+ ],
+ },
+ AutoAcceptRule::ReadOnlyTool {
+ tools: vec!["Glob".into(), "Grep".into(), "Read".into()],
+ },
+ ],
+ }
+ }
+}
+
+impl AutoAcceptRules {
+ /// Check if any rule matches the given tool call
+ pub fn should_auto_accept(&self, tool_name: &str, tool_input: &Value) -> bool {
+ self.rules
+ .iter()
+ .any(|rule| rule.matches(tool_name, tool_input))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn default_rules() -> AutoAcceptRules {
+ AutoAcceptRules::default()
+ }
+
+ #[test]
+ fn test_small_edit_auto_accept() {
+ let rules = default_rules();
+ let input = json!({
+ "file_path": "/path/to/file.rs",
+ "old_string": "let x = 1;",
+ "new_string": "let x = 2;"
+ });
+ assert!(rules.should_auto_accept("Edit", &input));
+ }
+
+ #[test]
+ fn test_large_edit_not_auto_accept() {
+ let rules = default_rules();
+ let input = json!({
+ "file_path": "/path/to/file.rs",
+ "old_string": "line1\nline2\nline3\nline4",
+ "new_string": "a\nb\nc\nd"
+ });
+ assert!(!rules.should_auto_accept("Edit", &input));
+ }
+
+ #[test]
+ fn test_cargo_build_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "cargo build" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_cargo_check_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "cargo check" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_cargo_test_with_args_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "cargo test --release" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_cargo_fmt_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "cargo fmt" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_cargo_clippy_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "cargo clippy" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_rm_not_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "rm -rf /tmp/test" });
+ assert!(!rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_curl_not_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "curl https://example.com" });
+ assert!(!rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_read_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "file_path": "/path/to/file.rs" });
+ assert!(rules.should_auto_accept("Read", &input));
+ }
+
+ #[test]
+ fn test_glob_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "pattern": "**/*.rs" });
+ assert!(rules.should_auto_accept("Glob", &input));
+ }
+
+ #[test]
+ fn test_grep_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "pattern": "TODO", "path": "/src" });
+ assert!(rules.should_auto_accept("Grep", &input));
+ }
+
+ #[test]
+ fn test_write_not_auto_accept() {
+ let rules = default_rules();
+ let input = json!({
+ "file_path": "/path/to/file.rs",
+ "content": "new content"
+ });
+ assert!(!rules.should_auto_accept("Write", &input));
+ }
+
+ #[test]
+ fn test_unknown_tool_not_auto_accept() {
+ let rules = default_rules();
+ let input = json!({});
+ assert!(!rules.should_auto_accept("UnknownTool", &input));
+ }
+
+ #[test]
+ fn test_bash_with_leading_whitespace() {
+ let rules = default_rules();
+ let input = json!({ "command": " cargo build" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_grep_bash_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "grep -rn \"pattern\" /path" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_rg_bash_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "rg \"pattern\" /path" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_find_bash_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "find . -name \"*.rs\"" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_git_status_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "git status" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_git_log_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "git log --oneline -10" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_git_push_not_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "git push origin main" });
+ assert!(!rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_git_commit_not_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "git commit -m \"test\"" });
+ assert!(!rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_ls_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "ls -la /tmp" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+
+ #[test]
+ fn test_cat_auto_accept() {
+ let rules = default_rules();
+ let input = json!({ "command": "cat /path/to/file.txt" });
+ assert!(rules.should_auto_accept("Bash", &input));
+ }
+}
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -0,0 +1,691 @@
+use crate::auto_accept::AutoAcceptRules;
+use crate::backend::session_info::parse_session_info;
+use crate::backend::tool_summary::{
+ extract_response_content, format_tool_summary, truncate_output,
+};
+use crate::backend::traits::AiBackend;
+use crate::messages::{
+ CompactionInfo, DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse,
+ SubagentInfo, SubagentStatus, ToolResult,
+};
+use crate::tools::Tool;
+use crate::Message;
+use claude_agent_sdk_rs::{
+ ClaudeAgentOptions, ClaudeClient, ContentBlock, Message as ClaudeMessage, PermissionMode,
+ PermissionResult, PermissionResultAllow, PermissionResultDeny, ToolUseBlock, UserContentBlock,
+};
+use dashmap::DashMap;
+use futures::future::BoxFuture;
+use futures::StreamExt;
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::sync::Arc;
+use tokio::sync::mpsc as tokio_mpsc;
+use tokio::sync::oneshot;
+use uuid::Uuid;
+
+/// Commands sent to a session's actor task
+enum SessionCommand {
+ Query {
+ prompt: String,
+ response_tx: mpsc::Sender<DaveApiResponse>,
+ ctx: egui::Context,
+ },
+ /// Interrupt the current query - stops the stream but preserves session
+ Interrupt {
+ ctx: egui::Context,
+ },
+ /// Set the permission mode (Default or Plan)
+ SetPermissionMode {
+ mode: PermissionMode,
+ ctx: egui::Context,
+ },
+ Shutdown,
+}
+
+/// Handle to a session's actor
+struct SessionHandle {
+ command_tx: tokio_mpsc::Sender<SessionCommand>,
+}
+
+pub struct ClaudeBackend {
+ #[allow(dead_code)] // May be used in the future for API key validation
+ api_key: String,
+ /// Registry of active sessions (using dashmap for lock-free access)
+ sessions: DashMap<String, SessionHandle>,
+}
+
+impl ClaudeBackend {
+ pub fn new(api_key: String) -> Self {
+ Self {
+ api_key,
+ sessions: DashMap::new(),
+ }
+ }
+
+ /// Convert our messages to a prompt for Claude Code
+ fn messages_to_prompt(messages: &[Message]) -> String {
+ let mut prompt = String::new();
+
+ // Include system message if present
+ for msg in messages {
+ if let Message::System(content) = msg {
+ prompt.push_str(content);
+ prompt.push_str("\n\n");
+ break;
+ }
+ }
+
+ // Format conversation history
+ for msg in messages {
+ match msg {
+ Message::System(_) => {} // Already handled
+ Message::User(content) => {
+ prompt.push_str("Human: ");
+ prompt.push_str(content);
+ prompt.push_str("\n\n");
+ }
+ Message::Assistant(content) => {
+ prompt.push_str("Assistant: ");
+ prompt.push_str(content);
+ prompt.push_str("\n\n");
+ }
+ Message::ToolCalls(_)
+ | Message::ToolResponse(_)
+ | Message::Error(_)
+ | Message::PermissionRequest(_)
+ | Message::ToolResult(_)
+ | Message::CompactionComplete(_)
+ | Message::Subagent(_) => {
+ // Skip tool-related, error, permission, tool result, compaction, and subagent messages
+ }
+ }
+ }
+
+ prompt
+ }
+
+ /// Extract only the latest user message for session continuation
+ fn get_latest_user_message(messages: &[Message]) -> String {
+ messages
+ .iter()
+ .rev()
+ .find_map(|m| match m {
+ Message::User(content) => Some(content.clone()),
+ _ => None,
+ })
+ .unwrap_or_default()
+ }
+}
+
+/// Permission request forwarded from the callback to the actor
+struct PermissionRequestInternal {
+ tool_name: String,
+ tool_input: serde_json::Value,
+ response_tx: oneshot::Sender<PermissionResult>,
+}
+
+/// Session actor task that owns a single ClaudeClient with persistent connection
+async fn session_actor(
+ session_id: String,
+ cwd: Option<PathBuf>,
+ resume_session_id: Option<String>,
+ mut command_rx: tokio_mpsc::Receiver<SessionCommand>,
+) {
+ // Permission channel - the callback sends to perm_tx, actor receives on perm_rx
+ let (perm_tx, mut perm_rx) = tokio_mpsc::channel::<PermissionRequestInternal>(16);
+
+ // Create the can_use_tool callback that forwards to our permission channel
+ let can_use_tool: Arc<
+ dyn Fn(
+ String,
+ serde_json::Value,
+ claude_agent_sdk_rs::ToolPermissionContext,
+ ) -> BoxFuture<'static, PermissionResult>
+ + Send
+ + Sync,
+ > = Arc::new({
+ let perm_tx = perm_tx.clone();
+ move |tool_name: String,
+ tool_input: serde_json::Value,
+ _context: claude_agent_sdk_rs::ToolPermissionContext| {
+ let perm_tx = perm_tx.clone();
+ Box::pin(async move {
+ let (resp_tx, resp_rx) = oneshot::channel();
+ if perm_tx
+ .send(PermissionRequestInternal {
+ tool_name: tool_name.clone(),
+ tool_input,
+ response_tx: resp_tx,
+ })
+ .await
+ .is_err()
+ {
+ return PermissionResult::Deny(PermissionResultDeny {
+ message: "Session actor channel closed".to_string(),
+ interrupt: true,
+ });
+ }
+ // Wait for response from session actor (which forwards from UI)
+ match resp_rx.await {
+ Ok(result) => result,
+ Err(_) => PermissionResult::Deny(PermissionResultDeny {
+ message: "Permission response cancelled".to_string(),
+ interrupt: true,
+ }),
+ }
+ })
+ }
+ });
+
+ // A stderr callback to prevent the subprocess from blocking
+ let stderr_callback = Arc::new(|msg: String| {
+ tracing::trace!("Claude CLI stderr: {}", msg);
+ });
+
+ // Log if we're resuming a session
+ if let Some(ref resume_id) = resume_session_id {
+ tracing::info!(
+ "Session {} will resume Claude session: {}",
+ session_id,
+ resume_id
+ );
+ }
+
+ // Create client once - this maintains the persistent connection
+ // Using match to handle the TypedBuilder's strict type requirements
+ let options = match (&cwd, &resume_session_id) {
+ (Some(dir), Some(resume_id)) => ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::Default)
+ .stderr_callback(stderr_callback)
+ .can_use_tool(can_use_tool)
+ .include_partial_messages(true)
+ .cwd(dir)
+ .resume(resume_id)
+ .build(),
+ (Some(dir), None) => ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::Default)
+ .stderr_callback(stderr_callback)
+ .can_use_tool(can_use_tool)
+ .include_partial_messages(true)
+ .cwd(dir)
+ .build(),
+ (None, Some(resume_id)) => ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::Default)
+ .stderr_callback(stderr_callback)
+ .can_use_tool(can_use_tool)
+ .include_partial_messages(true)
+ .resume(resume_id)
+ .build(),
+ (None, None) => ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::Default)
+ .stderr_callback(stderr_callback)
+ .can_use_tool(can_use_tool)
+ .include_partial_messages(true)
+ .build(),
+ };
+ let mut client = ClaudeClient::new(options);
+
+ // Connect once - this starts the subprocess
+ if let Err(err) = client.connect().await {
+ tracing::error!("Session {} failed to connect: {}", session_id, err);
+ // Process any pending commands to report the error
+ while let Some(cmd) = command_rx.recv().await {
+ if let SessionCommand::Query {
+ ref response_tx, ..
+ } = cmd
+ {
+ let _ = response_tx.send(DaveApiResponse::Failed(format!(
+ "Failed to connect to Claude: {}",
+ err
+ )));
+ }
+ if matches!(cmd, SessionCommand::Shutdown) {
+ break;
+ }
+ }
+ return;
+ }
+
+ tracing::debug!("Session {} connected successfully", session_id);
+
+ // Process commands
+ while let Some(cmd) = command_rx.recv().await {
+ match cmd {
+ SessionCommand::Query {
+ prompt,
+ response_tx,
+ ctx,
+ } => {
+ // Send query using session_id for context
+ if let Err(err) = client.query_with_session(&prompt, &session_id).await {
+ tracing::error!("Session {} query error: {}", session_id, err);
+ let _ = response_tx.send(DaveApiResponse::Failed(err.to_string()));
+ continue;
+ }
+
+ // Track pending tool uses: tool_use_id -> (tool_name, tool_input)
+ let mut pending_tools: HashMap<String, (String, serde_json::Value)> =
+ HashMap::new();
+
+ // Stream response with select! to handle stream, permission requests, and interrupts
+ let mut stream = client.receive_response();
+ let mut stream_done = false;
+
+ while !stream_done {
+ tokio::select! {
+ biased;
+
+ // Check for interrupt command (highest priority)
+ Some(cmd) = command_rx.recv() => {
+ match cmd {
+ SessionCommand::Interrupt { ctx: interrupt_ctx } => {
+ tracing::debug!("Session {} received interrupt", session_id);
+ if let Err(err) = client.interrupt().await {
+ tracing::error!("Failed to send interrupt: {}", err);
+ }
+ // Let the stream end naturally - it will send a Result message
+ // The session history is preserved by the CLI
+ interrupt_ctx.request_repaint();
+ }
+ SessionCommand::Query { response_tx: new_tx, .. } => {
+ // A new query came in while we're still streaming - shouldn't happen
+ // but handle gracefully by rejecting it
+ let _ = new_tx.send(DaveApiResponse::Failed(
+ "Query already in progress".to_string()
+ ));
+ }
+ SessionCommand::SetPermissionMode { mode, ctx: mode_ctx } => {
+ // Permission mode change during query - apply it
+ tracing::debug!("Session {} setting permission mode to {:?} during query", session_id, mode);
+ if let Err(err) = client.set_permission_mode(mode).await {
+ tracing::error!("Failed to set permission mode: {}", err);
+ }
+ mode_ctx.request_repaint();
+ }
+ SessionCommand::Shutdown => {
+ tracing::debug!("Session actor {} shutting down during query", session_id);
+ // Drop stream and disconnect - break to exit loop first
+ drop(stream);
+ if let Err(err) = client.disconnect().await {
+ tracing::warn!("Error disconnecting session {}: {}", session_id, err);
+ }
+ tracing::debug!("Session {} actor exited", session_id);
+ return;
+ }
+ }
+ }
+
+ // Handle permission requests (they're blocking the SDK)
+ Some(perm_req) = perm_rx.recv() => {
+ // Check auto-accept rules
+ let auto_accept_rules = AutoAcceptRules::default();
+ if auto_accept_rules.should_auto_accept(&perm_req.tool_name, &perm_req.tool_input) {
+ tracing::debug!("Auto-accepting {}: matched auto-accept rule", perm_req.tool_name);
+ let _ = perm_req.response_tx.send(PermissionResult::Allow(PermissionResultAllow::default()));
+ continue;
+ }
+
+ // Forward permission request to UI
+ let request_id = Uuid::new_v4();
+ let (ui_resp_tx, ui_resp_rx) = oneshot::channel();
+
+ let request = PermissionRequest {
+ id: request_id,
+ tool_name: perm_req.tool_name.clone(),
+ tool_input: perm_req.tool_input.clone(),
+ response: None,
+ answer_summary: None,
+ };
+
+ let pending = PendingPermission {
+ request,
+ response_tx: ui_resp_tx,
+ };
+
+ if response_tx.send(DaveApiResponse::PermissionRequest(pending)).is_err() {
+ tracing::error!("Failed to send permission request to UI");
+ let _ = perm_req.response_tx.send(PermissionResult::Deny(PermissionResultDeny {
+ message: "UI channel closed".to_string(),
+ interrupt: true,
+ }));
+ continue;
+ }
+
+ ctx.request_repaint();
+
+ // Wait for UI response inline - blocking is OK since stream is
+ // waiting for permission result anyway
+ let tool_name = perm_req.tool_name.clone();
+ let result = match ui_resp_rx.await {
+ Ok(PermissionResponse::Allow { message }) => {
+ if let Some(msg) = &message {
+ tracing::debug!("User allowed tool {} with message: {}", tool_name, msg);
+ // Inject user message into conversation so AI sees it
+ if let Err(err) = client.query_with_content_and_session(
+ vec![UserContentBlock::text(msg.as_str())],
+ &session_id
+ ).await {
+ tracing::error!("Failed to inject user message: {}", err);
+ }
+ } else {
+ tracing::debug!("User allowed tool: {}", tool_name);
+ }
+ PermissionResult::Allow(PermissionResultAllow::default())
+ }
+ Ok(PermissionResponse::Deny { reason }) => {
+ tracing::debug!("User denied tool {}: {}", tool_name, reason);
+ PermissionResult::Deny(PermissionResultDeny {
+ message: reason,
+ interrupt: false,
+ })
+ }
+ Err(_) => {
+ tracing::error!("Permission response channel closed");
+ PermissionResult::Deny(PermissionResultDeny {
+ message: "Permission request cancelled".to_string(),
+ interrupt: true,
+ })
+ }
+ };
+ let _ = perm_req.response_tx.send(result);
+ }
+
+ stream_result = stream.next() => {
+ match stream_result {
+ Some(Ok(message)) => {
+ match message {
+ ClaudeMessage::Assistant(assistant_msg) => {
+ for block in &assistant_msg.message.content {
+ if let ContentBlock::ToolUse(ToolUseBlock { id, name, input }) = block {
+ pending_tools.insert(id.clone(), (name.clone(), input.clone()));
+
+ // Emit SubagentSpawned for Task tool calls
+ if name == "Task" {
+ let description = input
+ .get("description")
+ .and_then(|v| v.as_str())
+ .unwrap_or("task")
+ .to_string();
+ let subagent_type = input
+ .get("subagent_type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown")
+ .to_string();
+
+ let subagent_info = SubagentInfo {
+ task_id: id.clone(),
+ description,
+ subagent_type,
+ status: SubagentStatus::Running,
+ output: String::new(),
+ max_output_size: 4000,
+ };
+ let _ = response_tx.send(DaveApiResponse::SubagentSpawned(subagent_info));
+ ctx.request_repaint();
+ }
+ }
+ }
+ }
+ ClaudeMessage::StreamEvent(event) => {
+ if let Some(event_type) = event.event.get("type").and_then(|v| v.as_str()) {
+ if event_type == "content_block_delta" {
+ if let Some(text) = event
+ .event
+ .get("delta")
+ .and_then(|d| d.get("text"))
+ .and_then(|t| t.as_str())
+ {
+ if response_tx.send(DaveApiResponse::Token(text.to_string())).is_err() {
+ tracing::error!("Failed to send token to UI");
+ // Setting stream_done isn't needed since we break immediately
+ break;
+ }
+ ctx.request_repaint();
+ }
+ }
+ }
+ }
+ ClaudeMessage::Result(result_msg) => {
+ if result_msg.is_error {
+ let error_text = result_msg
+ .result
+ .unwrap_or_else(|| "Unknown error".to_string());
+ let _ = response_tx.send(DaveApiResponse::Failed(error_text));
+ }
+ stream_done = true;
+ }
+ ClaudeMessage::User(user_msg) => {
+ if let Some(tool_use_result) = user_msg.extra.get("tool_use_result") {
+ let tool_use_id = user_msg
+ .extra
+ .get("message")
+ .and_then(|m| m.get("content"))
+ .and_then(|c| c.as_array())
+ .and_then(|arr| arr.first())
+ .and_then(|item| item.get("tool_use_id"))
+ .and_then(|id| id.as_str());
+
+ if let Some(tool_use_id) = tool_use_id {
+ if let Some((tool_name, tool_input)) = pending_tools.remove(tool_use_id) {
+ // Check if this is a Task tool completion
+ if tool_name == "Task" {
+ let result_text = extract_response_content(tool_use_result)
+ .unwrap_or_else(|| "completed".to_string());
+ let _ = response_tx.send(DaveApiResponse::SubagentCompleted {
+ task_id: tool_use_id.to_string(),
+ result: truncate_output(&result_text, 2000),
+ });
+ }
+
+ let summary = format_tool_summary(&tool_name, &tool_input, tool_use_result);
+ let tool_result = ToolResult { tool_name, summary };
+ let _ = response_tx.send(DaveApiResponse::ToolResult(tool_result));
+ ctx.request_repaint();
+ }
+ }
+ }
+ }
+ ClaudeMessage::System(system_msg) => {
+ // Handle system init message - extract session info
+ if system_msg.subtype == "init" {
+ let session_info = parse_session_info(&system_msg);
+ let _ = response_tx.send(DaveApiResponse::SessionInfo(session_info));
+ ctx.request_repaint();
+ } else if system_msg.subtype == "status" {
+ // Handle status messages (compaction start/end)
+ let status = system_msg.data.get("status")
+ .and_then(|v| v.as_str());
+ if status == Some("compacting") {
+ let _ = response_tx.send(DaveApiResponse::CompactionStarted);
+ ctx.request_repaint();
+ }
+ // status: null means compaction finished (handled by compact_boundary)
+ } else if system_msg.subtype == "compact_boundary" {
+ // Compaction completed - extract token savings info
+ let pre_tokens = system_msg.data.get("pre_tokens")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(0);
+ let info = CompactionInfo { pre_tokens };
+ let _ = response_tx.send(DaveApiResponse::CompactionComplete(info));
+ ctx.request_repaint();
+ } else {
+ tracing::debug!("Received system message subtype: {}", system_msg.subtype);
+ }
+ }
+ ClaudeMessage::ControlCancelRequest(_) => {
+ // Ignore internal control messages
+ }
+ }
+ }
+ Some(Err(err)) => {
+ tracing::error!("Claude stream error: {}", err);
+ let _ = response_tx.send(DaveApiResponse::Failed(err.to_string()));
+ stream_done = true;
+ }
+ None => {
+ stream_done = true;
+ }
+ }
+ }
+ }
+ }
+
+ tracing::debug!("Query complete for session {}", session_id);
+ // Don't disconnect - keep the connection alive for subsequent queries
+ }
+ SessionCommand::Interrupt { ctx } => {
+ // Interrupt received when not in a query - just request repaint
+ tracing::debug!(
+ "Session {} received interrupt but no query active",
+ session_id
+ );
+ ctx.request_repaint();
+ }
+ SessionCommand::SetPermissionMode { mode, ctx } => {
+ tracing::debug!(
+ "Session {} setting permission mode to {:?}",
+ session_id,
+ mode
+ );
+ if let Err(err) = client.set_permission_mode(mode).await {
+ tracing::error!("Failed to set permission mode: {}", err);
+ }
+ ctx.request_repaint();
+ }
+ SessionCommand::Shutdown => {
+ tracing::debug!("Session actor {} shutting down", session_id);
+ break;
+ }
+ }
+ }
+
+ // Disconnect when shutting down
+ if let Err(err) = client.disconnect().await {
+ tracing::warn!("Error disconnecting session {}: {}", session_id, err);
+ }
+ tracing::debug!("Session {} actor exited", session_id);
+}
+
+impl AiBackend for ClaudeBackend {
+ fn stream_request(
+ &self,
+ messages: Vec<Message>,
+ _tools: Arc<HashMap<String, Tool>>,
+ _model: String,
+ _user_id: String,
+ session_id: String,
+ cwd: Option<PathBuf>,
+ resume_session_id: Option<String>,
+ ctx: egui::Context,
+ ) -> (
+ mpsc::Receiver<DaveApiResponse>,
+ Option<tokio::task::JoinHandle<()>>,
+ ) {
+ let (response_tx, response_rx) = mpsc::channel();
+
+ // Determine if this is the first message in the session
+ let is_first_message = messages
+ .iter()
+ .filter(|m| matches!(m, Message::User(_)))
+ .count()
+ == 1;
+
+ // For first message, send full prompt; for continuation, just the latest message
+ let prompt = if is_first_message {
+ Self::messages_to_prompt(&messages)
+ } else {
+ Self::get_latest_user_message(&messages)
+ };
+
+ tracing::debug!(
+ "Sending request to Claude Code: session={}, is_first={}, prompt length: {}, preview: {:?}",
+ session_id,
+ is_first_message,
+ prompt.len(),
+ &prompt[..prompt.len().min(100)]
+ );
+
+ // Get or create session actor
+ let command_tx = {
+ let entry = self.sessions.entry(session_id.clone());
+ let handle = entry.or_insert_with(|| {
+ let (command_tx, command_rx) = tokio_mpsc::channel(16);
+
+ // Spawn session actor with cwd and optional resume session ID
+ let session_id_clone = session_id.clone();
+ let cwd_clone = cwd.clone();
+ let resume_session_id_clone = resume_session_id.clone();
+ tokio::spawn(async move {
+ session_actor(
+ session_id_clone,
+ cwd_clone,
+ resume_session_id_clone,
+ command_rx,
+ )
+ .await;
+ });
+
+ SessionHandle { command_tx }
+ });
+ handle.command_tx.clone()
+ };
+
+ // Spawn a task to send the query command
+ let handle = tokio::spawn(async move {
+ if let Err(err) = command_tx
+ .send(SessionCommand::Query {
+ prompt,
+ response_tx,
+ ctx,
+ })
+ .await
+ {
+ tracing::error!("Failed to send query command to session actor: {}", err);
+ }
+ });
+
+ (response_rx, Some(handle))
+ }
+
+ fn cleanup_session(&self, session_id: String) {
+ if let Some((_, handle)) = self.sessions.remove(&session_id) {
+ tokio::spawn(async move {
+ if let Err(err) = handle.command_tx.send(SessionCommand::Shutdown).await {
+ tracing::warn!("Failed to send shutdown command: {}", err);
+ }
+ });
+ }
+ }
+
+ fn interrupt_session(&self, session_id: String, ctx: egui::Context) {
+ if let Some(handle) = self.sessions.get(&session_id) {
+ let command_tx = handle.command_tx.clone();
+ tokio::spawn(async move {
+ if let Err(err) = command_tx.send(SessionCommand::Interrupt { ctx }).await {
+ tracing::warn!("Failed to send interrupt command: {}", err);
+ }
+ });
+ }
+ }
+
+ fn set_permission_mode(&self, session_id: String, mode: PermissionMode, ctx: egui::Context) {
+ if let Some(handle) = self.sessions.get(&session_id) {
+ let command_tx = handle.command_tx.clone();
+ tokio::spawn(async move {
+ if let Err(err) = command_tx
+ .send(SessionCommand::SetPermissionMode { mode, ctx })
+ .await
+ {
+ tracing::warn!("Failed to send set_permission_mode command: {}", err);
+ }
+ });
+ } else {
+ tracing::debug!(
+ "Session {} not active, permission mode will apply on next query",
+ session_id
+ );
+ }
+ }
+}
diff --git a/crates/notedeck_dave/src/backend/mod.rs b/crates/notedeck_dave/src/backend/mod.rs
@@ -0,0 +1,9 @@
+mod claude;
+mod openai;
+mod session_info;
+mod tool_summary;
+mod traits;
+
+pub use claude::ClaudeBackend;
+pub use openai::OpenAiBackend;
+pub use traits::{AiBackend, BackendType};
diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs
@@ -0,0 +1,186 @@
+use crate::backend::traits::AiBackend;
+use crate::messages::DaveApiResponse;
+use crate::tools::{PartialToolCall, Tool, ToolCall};
+use crate::Message;
+use async_openai::{
+ config::OpenAIConfig,
+ types::{ChatCompletionRequestMessage, CreateChatCompletionRequest},
+ Client,
+};
+use claude_agent_sdk_rs::PermissionMode;
+use futures::StreamExt;
+use nostrdb::{Ndb, Transaction};
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::sync::Arc;
+
+pub struct OpenAiBackend {
+ client: Client<OpenAIConfig>,
+ ndb: Ndb,
+}
+
+impl OpenAiBackend {
+ pub fn new(client: Client<OpenAIConfig>, ndb: Ndb) -> Self {
+ Self { client, ndb }
+ }
+}
+
+impl AiBackend for OpenAiBackend {
+ fn stream_request(
+ &self,
+ messages: Vec<Message>,
+ tools: Arc<HashMap<String, Tool>>,
+ model: String,
+ user_id: String,
+ _session_id: String,
+ _cwd: Option<PathBuf>,
+ _resume_session_id: Option<String>,
+ ctx: egui::Context,
+ ) -> (
+ mpsc::Receiver<DaveApiResponse>,
+ Option<tokio::task::JoinHandle<()>>,
+ ) {
+ let (tx, rx) = mpsc::channel();
+
+ let api_messages: Vec<ChatCompletionRequestMessage> = {
+ let txn = Transaction::new(&self.ndb).expect("txn");
+ messages
+ .iter()
+ .filter_map(|c| c.to_api_msg(&txn, &self.ndb))
+ .collect()
+ };
+
+ let client = self.client.clone();
+ let tool_list: Vec<_> = tools.values().map(|t| t.to_api()).collect();
+
+ let handle = tokio::spawn(async move {
+ let mut token_stream = match client
+ .chat()
+ .create_stream(CreateChatCompletionRequest {
+ model,
+ stream: Some(true),
+ messages: api_messages,
+ tools: Some(tool_list),
+ user: Some(user_id),
+ ..Default::default()
+ })
+ .await
+ {
+ Err(err) => {
+ tracing::error!("openai chat error: {err}");
+ let _ = tx.send(DaveApiResponse::Failed(err.to_string()));
+ return;
+ }
+
+ Ok(stream) => stream,
+ };
+
+ let mut all_tool_calls: HashMap<u32, PartialToolCall> = HashMap::new();
+
+ while let Some(token) = token_stream.next().await {
+ let token = match token {
+ Ok(token) => token,
+ Err(err) => {
+ tracing::error!("failed to get token: {err}");
+ let _ = tx.send(DaveApiResponse::Failed(err.to_string()));
+ return;
+ }
+ };
+
+ for choice in &token.choices {
+ let resp = &choice.delta;
+
+ // if we have tool call arg chunks, collect them here
+ if let Some(tool_calls) = &resp.tool_calls {
+ for tool in tool_calls {
+ let entry = all_tool_calls.entry(tool.index).or_default();
+
+ if let Some(id) = &tool.id {
+ entry.id_mut().get_or_insert(id.clone());
+ }
+
+ if let Some(name) = tool.function.as_ref().and_then(|f| f.name.as_ref())
+ {
+ entry.name_mut().get_or_insert(name.to_string());
+ }
+
+ if let Some(argchunk) =
+ tool.function.as_ref().and_then(|f| f.arguments.as_ref())
+ {
+ entry
+ .arguments_mut()
+ .get_or_insert_with(String::new)
+ .push_str(argchunk);
+ }
+ }
+ }
+
+ if let Some(content) = &resp.content {
+ if let Err(err) = tx.send(DaveApiResponse::Token(content.to_owned())) {
+ tracing::error!("failed to send dave response token to ui: {err}");
+ }
+ ctx.request_repaint();
+ }
+ }
+ }
+
+ let mut parsed_tool_calls = vec![];
+ for (_index, partial) in all_tool_calls {
+ let Some(unknown_tool_call) = partial.complete() else {
+ tracing::error!("could not complete partial tool call: {:?}", partial);
+ continue;
+ };
+
+ match unknown_tool_call.parse(&tools) {
+ Ok(tool_call) => {
+ parsed_tool_calls.push(tool_call);
+ }
+ Err(err) => {
+ tracing::error!(
+ "failed to parse tool call {:?}: {}",
+ unknown_tool_call,
+ err,
+ );
+
+ if let Some(id) = partial.id() {
+ parsed_tool_calls.push(ToolCall::invalid(
+ id.to_string(),
+ partial.name,
+ partial.arguments,
+ err.to_string(),
+ ));
+ }
+ }
+ };
+ }
+
+ if !parsed_tool_calls.is_empty()
+ && tx
+ .send(DaveApiResponse::ToolCalls(parsed_tool_calls))
+ .is_ok()
+ {
+ ctx.request_repaint();
+ }
+
+ tracing::debug!("stream closed");
+ });
+
+ (rx, Some(handle))
+ }
+
+ fn cleanup_session(&self, _session_id: String) {
+ // OpenAI backend doesn't maintain persistent connections per session
+ // No cleanup needed
+ }
+
+ fn interrupt_session(&self, _session_id: String, _ctx: egui::Context) {
+ // OpenAI backend doesn't support interrupts - requests complete atomically
+ // The JoinHandle can be aborted from the session side if needed
+ }
+
+ fn set_permission_mode(&self, _session_id: String, _mode: PermissionMode, _ctx: egui::Context) {
+ // OpenAI backend doesn't support permission modes / plan mode
+ tracing::warn!("Plan mode is not supported with the OpenAI backend");
+ }
+}
diff --git a/crates/notedeck_dave/src/backend/session_info.rs b/crates/notedeck_dave/src/backend/session_info.rs
@@ -0,0 +1,46 @@
+/// Session info parsing utilities for Claude backend responses.
+use crate::messages::SessionInfo;
+
+/// Parse a System message into SessionInfo
+pub fn parse_session_info(system_msg: &claude_agent_sdk_rs::SystemMessage) -> SessionInfo {
+ let data = &system_msg.data;
+
+ // Extract slash_commands from data
+ let slash_commands = data
+ .get("slash_commands")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(String::from))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ // Extract agents from data
+ let agents = data
+ .get("agents")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(String::from))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ // Extract CLI version
+ let cli_version = data
+ .get("claude_code_version")
+ .and_then(|v| v.as_str())
+ .map(String::from);
+
+ SessionInfo {
+ tools: system_msg.tools.clone().unwrap_or_default(),
+ model: system_msg.model.clone(),
+ permission_mode: system_msg.permission_mode.clone(),
+ slash_commands,
+ agents,
+ cli_version,
+ cwd: system_msg.cwd.clone(),
+ claude_session_id: system_msg.session_id.clone(),
+ }
+}
diff --git a/crates/notedeck_dave/src/backend/tool_summary.rs b/crates/notedeck_dave/src/backend/tool_summary.rs
@@ -0,0 +1,156 @@
+//! Formatting utilities for tool execution summaries shown in the UI.
+//!
+//! These functions convert raw tool inputs and outputs into human-readable
+//! summary strings that are displayed to users after tool execution.
+
+/// Extract string content from a tool response, handling various JSON structures
+pub fn extract_response_content(response: &serde_json::Value) -> Option<String> {
+ // Try direct string first
+ if let Some(s) = response.as_str() {
+ return Some(s.to_string());
+ }
+ // Try "content" field (common wrapper)
+ if let Some(s) = response.get("content").and_then(|v| v.as_str()) {
+ return Some(s.to_string());
+ }
+ // Try file.content for Read tool responses
+ if let Some(s) = response
+ .get("file")
+ .and_then(|f| f.get("content"))
+ .and_then(|v| v.as_str())
+ {
+ return Some(s.to_string());
+ }
+ // Try "output" field
+ if let Some(s) = response.get("output").and_then(|v| v.as_str()) {
+ return Some(s.to_string());
+ }
+ // Try "result" field
+ if let Some(s) = response.get("result").and_then(|v| v.as_str()) {
+ return Some(s.to_string());
+ }
+ // Fallback: serialize the whole response if it's not null
+ if !response.is_null() {
+ return Some(response.to_string());
+ }
+ None
+}
+
+/// Format a human-readable summary for tool execution results
+pub fn format_tool_summary(
+ tool_name: &str,
+ input: &serde_json::Value,
+ response: &serde_json::Value,
+) -> String {
+ match tool_name {
+ "Read" => format_read_summary(input, response),
+ "Write" => format_write_summary(input),
+ "Bash" => format_bash_summary(input, response),
+ "Grep" => format_grep_summary(input),
+ "Glob" => format_glob_summary(input),
+ "Edit" => format_edit_summary(input),
+ "Task" => format_task_summary(input),
+ _ => String::new(),
+ }
+}
+
+fn format_read_summary(input: &serde_json::Value, response: &serde_json::Value) -> String {
+ let file = input
+ .get("file_path")
+ .and_then(|v| v.as_str())
+ .unwrap_or("?");
+ let filename = file.rsplit('/').next().unwrap_or(file);
+ // Try to get numLines directly from file metadata (most accurate)
+ let lines = response
+ .get("file")
+ .and_then(|f| f.get("numLines").or_else(|| f.get("totalLines")))
+ .and_then(|v| v.as_u64())
+ .map(|n| n as usize)
+ // Fallback to counting lines in content
+ .or_else(|| {
+ extract_response_content(response)
+ .as_ref()
+ .map(|s| s.lines().count())
+ })
+ .unwrap_or(0);
+ format!("{} ({} lines)", filename, lines)
+}
+
+fn format_write_summary(input: &serde_json::Value) -> String {
+ let file = input
+ .get("file_path")
+ .and_then(|v| v.as_str())
+ .unwrap_or("?");
+ let filename = file.rsplit('/').next().unwrap_or(file);
+ let bytes = input
+ .get("content")
+ .and_then(|v| v.as_str())
+ .map(|s| s.len())
+ .unwrap_or(0);
+ format!("{} ({} bytes)", filename, bytes)
+}
+
+fn format_bash_summary(input: &serde_json::Value, response: &serde_json::Value) -> String {
+ let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
+ // Truncate long commands
+ let cmd_display = if cmd.len() > 40 {
+ format!("{}...", &cmd[..37])
+ } else {
+ cmd.to_string()
+ };
+ let output_len = extract_response_content(response)
+ .as_ref()
+ .map(|s| s.len())
+ .unwrap_or(0);
+ if output_len > 0 {
+ format!("`{}` ({} chars)", cmd_display, output_len)
+ } else {
+ format!("`{}`", cmd_display)
+ }
+}
+
+fn format_grep_summary(input: &serde_json::Value) -> String {
+ let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?");
+ format!("'{}'", pattern)
+}
+
+fn format_glob_summary(input: &serde_json::Value) -> String {
+ let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?");
+ format!("'{}'", pattern)
+}
+
+fn format_edit_summary(input: &serde_json::Value) -> String {
+ let file = input
+ .get("file_path")
+ .and_then(|v| v.as_str())
+ .unwrap_or("?");
+ let filename = file.rsplit('/').next().unwrap_or(file);
+ filename.to_string()
+}
+
+fn format_task_summary(input: &serde_json::Value) -> String {
+ let description = input
+ .get("description")
+ .and_then(|v| v.as_str())
+ .unwrap_or("task");
+ let subagent_type = input
+ .get("subagent_type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown");
+ format!("{} ({})", description, subagent_type)
+}
+
+/// Truncate output to a maximum size, keeping the end (most recent) content
+pub fn truncate_output(output: &str, max_size: usize) -> String {
+ if output.len() <= max_size {
+ output.to_string()
+ } else {
+ let start = output.len() - max_size;
+ // Find a newline near the start to avoid cutting mid-line
+ let adjusted_start = output[start..]
+ .find('\n')
+ .map(|pos| start + pos + 1)
+ .unwrap_or(start);
+ format!("...\n{}", &output[adjusted_start..])
+ }
+}
diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs
@@ -0,0 +1,52 @@
+use crate::messages::DaveApiResponse;
+use crate::tools::Tool;
+use claude_agent_sdk_rs::PermissionMode;
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::sync::Arc;
+
+/// Backend type selection
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BackendType {
+ OpenAI,
+ Claude,
+}
+
+/// Trait for AI backend implementations
+pub trait AiBackend: Send + Sync {
+ /// Stream a request to the AI backend
+ ///
+ /// Returns a receiver that will receive tokens and tool calls as they arrive,
+ /// plus an optional JoinHandle to the spawned task for cleanup on session deletion.
+ ///
+ /// If `resume_session_id` is Some, the backend should resume the specified Claude
+ /// session instead of starting a new conversation.
+ #[allow(clippy::too_many_arguments)]
+ fn stream_request(
+ &self,
+ messages: Vec<crate::Message>,
+ tools: Arc<HashMap<String, Tool>>,
+ model: String,
+ user_id: String,
+ session_id: String,
+ cwd: Option<PathBuf>,
+ resume_session_id: Option<String>,
+ ctx: egui::Context,
+ ) -> (
+ mpsc::Receiver<DaveApiResponse>,
+ Option<tokio::task::JoinHandle<()>>,
+ );
+
+ /// Clean up resources associated with a session.
+ /// Called when a session is deleted to allow backends to shut down any persistent connections.
+ fn cleanup_session(&self, session_id: String);
+
+ /// Interrupt the current query for a session.
+ /// This stops any in-progress work but preserves the session history.
+ fn interrupt_session(&self, session_id: String, ctx: egui::Context);
+
+ /// Set the permission mode for a session.
+ /// Plan mode makes Claude plan actions without executing them.
+ fn set_permission_mode(&self, session_id: String, mode: PermissionMode, ctx: egui::Context);
+}
diff --git a/crates/notedeck_dave/src/bin/notedeck-spawn.rs b/crates/notedeck_dave/src/bin/notedeck-spawn.rs
@@ -0,0 +1,96 @@
+//! CLI tool to spawn a new agent in a running notedeck instance.
+//!
+//! Usage:
+//! notedeck-spawn # spawn with current directory
+//! notedeck-spawn /path/to/dir # spawn with specific directory
+
+#[cfg(unix)]
+fn main() {
+ use std::io::{BufRead, BufReader, Write};
+ use std::os::unix::net::UnixStream;
+ use std::path::PathBuf;
+
+ // Parse arguments
+ let cwd = std::env::args()
+ .nth(1)
+ .map(PathBuf::from)
+ .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"));
+
+ // Canonicalize path
+ let cwd = match cwd.canonicalize() {
+ Ok(p) => p,
+ Err(e) => {
+ eprintln!("Error: Invalid path '{}': {}", cwd.display(), e);
+ std::process::exit(1);
+ }
+ };
+
+ // Validate it's a directory
+ if !cwd.is_dir() {
+ eprintln!("Error: '{}' is not a directory", cwd.display());
+ std::process::exit(1);
+ }
+
+ let socket_path = notedeck_dave::ipc::socket_path();
+
+ // Connect to the running notedeck instance
+ let mut stream = match UnixStream::connect(&socket_path) {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("Could not connect to notedeck at {}", socket_path.display());
+ eprintln!("Error: {}", e);
+ eprintln!();
+ eprintln!("Is notedeck running? Start it first with `notedeck`");
+ std::process::exit(1);
+ }
+ };
+
+ // Send spawn request
+ let request = serde_json::json!({
+ "type": "spawn_agent",
+ "cwd": cwd
+ });
+
+ if let Err(e) = writeln!(stream, "{}", request) {
+ eprintln!("Failed to send request: {}", e);
+ std::process::exit(1);
+ }
+
+ // Read response
+ let mut reader = BufReader::new(&stream);
+ let mut response = String::new();
+ if let Err(e) = reader.read_line(&mut response) {
+ eprintln!("Failed to read response: {}", e);
+ std::process::exit(1);
+ }
+
+ // Parse and display response
+ match serde_json::from_str::<serde_json::Value>(&response) {
+ Ok(json) => {
+ if json.get("status").and_then(|s| s.as_str()) == Some("ok") {
+ if let Some(id) = json.get("session_id") {
+ println!("Agent spawned (session {})", id);
+ } else {
+ println!("Agent spawned");
+ }
+ } else if let Some(msg) = json.get("message").and_then(|m| m.as_str()) {
+ eprintln!("Error: {}", msg);
+ std::process::exit(1);
+ } else {
+ eprintln!("Unknown response: {}", response);
+ std::process::exit(1);
+ }
+ }
+ Err(e) => {
+ eprintln!("Invalid response: {}", e);
+ eprintln!("Raw: {}", response);
+ std::process::exit(1);
+ }
+ }
+}
+
+#[cfg(not(unix))]
+fn main() {
+ eprintln!("notedeck-spawn is only supported on Unix systems (Linux, macOS)");
+ std::process::exit(1);
+}
diff --git a/crates/notedeck_dave/src/config.rs b/crates/notedeck_dave/src/config.rs
@@ -1,11 +1,144 @@
+use crate::backend::BackendType;
use async_openai::config::OpenAIConfig;
+/// AI interaction mode - determines UI complexity and feature set
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AiMode {
+ /// Simple chat interface (OpenAI-style) - no permissions, no CWD, no scene view
+ Chat,
+ /// Full IDE with permissions, sessions, scene view, etc. (Claude backend)
+ Agentic,
+}
+
+/// Available AI providers for Dave
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum AiProvider {
+ #[default]
+ OpenAI,
+ Anthropic,
+ Ollama,
+}
+
+impl AiProvider {
+ pub const ALL: [AiProvider; 3] = [
+ AiProvider::OpenAI,
+ AiProvider::Anthropic,
+ AiProvider::Ollama,
+ ];
+
+ pub fn name(&self) -> &'static str {
+ match self {
+ AiProvider::OpenAI => "OpenAI",
+ AiProvider::Anthropic => "Anthropic",
+ AiProvider::Ollama => "Ollama",
+ }
+ }
+
+ pub fn default_model(&self) -> &'static str {
+ match self {
+ AiProvider::OpenAI => "gpt-4o",
+ AiProvider::Anthropic => "claude-sonnet-4-20250514",
+ AiProvider::Ollama => "hhao/qwen2.5-coder-tools:latest",
+ }
+ }
+
+ pub fn default_endpoint(&self) -> Option<&'static str> {
+ match self {
+ AiProvider::OpenAI => None,
+ AiProvider::Anthropic => Some("https://api.anthropic.com/v1"),
+ AiProvider::Ollama => Some("http://localhost:11434/v1"),
+ }
+ }
+
+ pub fn requires_api_key(&self) -> bool {
+ match self {
+ AiProvider::OpenAI | AiProvider::Anthropic => true,
+ AiProvider::Ollama => false,
+ }
+ }
+
+ pub fn available_models(&self) -> &'static [&'static str] {
+ match self {
+ AiProvider::OpenAI => &["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
+ AiProvider::Anthropic => &[
+ "claude-sonnet-4-20250514",
+ "claude-opus-4-20250514",
+ "claude-3-5-sonnet-20241022",
+ "claude-3-5-haiku-20241022",
+ ],
+ AiProvider::Ollama => &[
+ "hhao/qwen2.5-coder-tools:latest",
+ "llama3.2:latest",
+ "mistral:latest",
+ "codellama:latest",
+ ],
+ }
+ }
+}
+
+/// User-configurable settings for Dave AI
+#[derive(Debug, Clone)]
+pub struct DaveSettings {
+ pub provider: AiProvider,
+ pub model: String,
+ pub endpoint: Option<String>,
+ pub api_key: Option<String>,
+}
+
+impl Default for DaveSettings {
+ fn default() -> Self {
+ DaveSettings {
+ provider: AiProvider::default(),
+ model: AiProvider::default().default_model().to_string(),
+ endpoint: None,
+ api_key: None,
+ }
+ }
+}
+
+impl DaveSettings {
+ /// Create settings with provider defaults applied
+ pub fn with_provider(provider: AiProvider) -> Self {
+ DaveSettings {
+ provider,
+ model: provider.default_model().to_string(),
+ endpoint: provider.default_endpoint().map(|s| s.to_string()),
+ api_key: None,
+ }
+ }
+
+ /// Create settings from an existing ModelConfig (preserves env var values)
+ pub fn from_model_config(config: &ModelConfig) -> Self {
+ let provider = match config.backend {
+ BackendType::OpenAI => AiProvider::OpenAI,
+ BackendType::Claude => AiProvider::Anthropic,
+ };
+
+ let api_key = match provider {
+ AiProvider::Anthropic => config.anthropic_api_key.clone(),
+ _ => config.api_key().map(|s| s.to_string()),
+ };
+
+ DaveSettings {
+ provider,
+ model: config.model().to_string(),
+ endpoint: config
+ .endpoint()
+ .map(|s| s.to_string())
+ .or_else(|| provider.default_endpoint().map(|s| s.to_string())),
+ api_key,
+ }
+ }
+}
+
#[derive(Debug)]
pub struct ModelConfig {
pub trial: bool,
+ pub backend: BackendType,
endpoint: Option<String>,
model: String,
api_key: Option<String>,
+ pub anthropic_api_key: Option<String>,
}
// short-term trial key for testing
@@ -31,32 +164,119 @@ impl Default for ModelConfig {
.ok()
.or(std::env::var("OPENAI_API_KEY").ok());
+ let anthropic_api_key = std::env::var("ANTHROPIC_API_KEY")
+ .ok()
+ .or(std::env::var("CLAUDE_API_KEY").ok());
+
+ // Determine backend: explicit env var takes precedence, otherwise auto-detect
+ let backend = if let Ok(backend_str) = std::env::var("DAVE_BACKEND") {
+ match backend_str.to_lowercase().as_str() {
+ "claude" | "anthropic" => BackendType::Claude,
+ "openai" => BackendType::OpenAI,
+ _ => {
+ tracing::warn!(
+ "Unknown DAVE_BACKEND value: {}, defaulting to OpenAI",
+ backend_str
+ );
+ BackendType::OpenAI
+ }
+ }
+ } else {
+ // Auto-detect: prefer Claude if key is available, otherwise OpenAI
+ if anthropic_api_key.is_some() {
+ BackendType::Claude
+ } else {
+ BackendType::OpenAI
+ }
+ };
+
// trial mode?
- let trial = api_key.is_none();
- let api_key = api_key.or(Some(DAVE_TRIAL.to_string()));
+ let trial = api_key.is_none() && backend == BackendType::OpenAI;
+ let api_key = if backend == BackendType::OpenAI {
+ api_key.or(Some(DAVE_TRIAL.to_string()))
+ } else {
+ api_key
+ };
+
+ let model = std::env::var("DAVE_MODEL")
+ .ok()
+ .unwrap_or_else(|| match backend {
+ BackendType::OpenAI => "gpt-4o".to_string(),
+ BackendType::Claude => "claude-sonnet-4.5".to_string(),
+ });
ModelConfig {
trial,
+ backend,
endpoint: std::env::var("DAVE_ENDPOINT").ok(),
- model: std::env::var("DAVE_MODEL")
- .ok()
- .unwrap_or("gpt-4o".to_string()),
+ model,
api_key,
+ anthropic_api_key,
}
}
}
impl ModelConfig {
+ pub fn ai_mode(&self) -> AiMode {
+ match self.backend {
+ BackendType::Claude => AiMode::Agentic,
+ BackendType::OpenAI => AiMode::Chat,
+ }
+ }
+
pub fn model(&self) -> &str {
&self.model
}
+ pub fn endpoint(&self) -> Option<&str> {
+ self.endpoint.as_deref()
+ }
+
+ pub fn api_key(&self) -> Option<&str> {
+ self.api_key.as_deref()
+ }
+
pub fn ollama() -> Self {
ModelConfig {
trial: false,
+ backend: BackendType::OpenAI, // Ollama uses OpenAI-compatible API
endpoint: std::env::var("OLLAMA_HOST").ok().map(|h| h + "/v1"),
model: "hhao/qwen2.5-coder-tools:latest".to_string(),
api_key: None,
+ anthropic_api_key: None,
+ }
+ }
+
+ /// Create a ModelConfig from DaveSettings
+ pub fn from_settings(settings: &DaveSettings) -> Self {
+ // If settings have an API key, we're not in trial mode
+ // For Ollama, trial is always false since no key is required
+ let trial = settings.provider.requires_api_key() && settings.api_key.is_none();
+
+ let backend = match settings.provider {
+ AiProvider::OpenAI | AiProvider::Ollama => BackendType::OpenAI,
+ AiProvider::Anthropic => BackendType::Claude,
+ };
+
+ let anthropic_api_key = if settings.provider == AiProvider::Anthropic {
+ settings.api_key.clone()
+ } else {
+ None
+ };
+
+ let api_key = if settings.provider != AiProvider::Anthropic {
+ settings.api_key.clone()
+ } else {
+ None
+ };
+
+ ModelConfig {
+ trial,
+ backend,
+ endpoint: settings.endpoint.clone(),
+ model: settings.model.clone(),
+ api_key,
+ anthropic_api_key,
}
}
diff --git a/crates/notedeck_dave/src/file_update.rs b/crates/notedeck_dave/src/file_update.rs
@@ -0,0 +1,352 @@
+use serde_json::Value;
+use similar::{ChangeTag, TextDiff};
+
+/// Represents a proposed file modification from an AI tool call
+#[derive(Debug, Clone)]
+pub struct FileUpdate {
+ pub file_path: String,
+ pub update_type: FileUpdateType,
+ /// Cached diff lines (computed eagerly at construction)
+ diff_lines: Vec<DiffLine>,
+}
+
+#[derive(Debug, Clone)]
+pub enum FileUpdateType {
+ /// Edit: replace old_string with new_string
+ Edit {
+ old_string: String,
+ new_string: String,
+ },
+ /// Write: create/overwrite entire file
+ Write { content: String },
+}
+
+/// A single line in a diff
+#[derive(Debug, Clone)]
+pub struct DiffLine {
+ pub tag: DiffTag,
+ pub content: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DiffTag {
+ Equal,
+ Delete,
+ Insert,
+}
+
+impl From<ChangeTag> for DiffTag {
+ fn from(tag: ChangeTag) -> Self {
+ match tag {
+ ChangeTag::Equal => DiffTag::Equal,
+ ChangeTag::Delete => DiffTag::Delete,
+ ChangeTag::Insert => DiffTag::Insert,
+ }
+ }
+}
+
+impl FileUpdate {
+ /// Create a new FileUpdate, computing the diff eagerly
+ pub fn new(file_path: String, update_type: FileUpdateType) -> Self {
+ let diff_lines = Self::compute_diff_for(&update_type);
+ Self {
+ file_path,
+ update_type,
+ diff_lines,
+ }
+ }
+
+ /// Get the cached diff lines
+ pub fn diff_lines(&self) -> &[DiffLine] {
+ &self.diff_lines
+ }
+
+ /// Try to parse a FileUpdate from a tool name and tool input JSON
+ pub fn from_tool_call(tool_name: &str, tool_input: &Value) -> Option<Self> {
+ let obj = tool_input.as_object()?;
+
+ match tool_name {
+ "Edit" => {
+ let file_path = obj.get("file_path")?.as_str()?.to_string();
+ let old_string = obj.get("old_string")?.as_str()?.to_string();
+ let new_string = obj.get("new_string")?.as_str()?.to_string();
+
+ Some(FileUpdate::new(
+ file_path,
+ FileUpdateType::Edit {
+ old_string,
+ new_string,
+ },
+ ))
+ }
+ "Write" => {
+ let file_path = obj.get("file_path")?.as_str()?.to_string();
+ let content = obj.get("content")?.as_str()?.to_string();
+
+ Some(FileUpdate::new(
+ file_path,
+ FileUpdateType::Write { content },
+ ))
+ }
+ _ => None,
+ }
+ }
+
+ /// Returns true if this is an Edit that changes at most `max_lines` lines
+ /// (deleted + inserted lines). Never returns true for Write operations.
+ ///
+ /// This counts actual changed lines using a diff, not total lines in the
+ /// strings. This is important because Claude Code typically includes
+ /// surrounding context lines for matching, so even a 1-line change may
+ /// have multi-line old_string/new_string.
+ pub fn is_small_edit(&self, max_lines: usize) -> bool {
+ match &self.update_type {
+ FileUpdateType::Edit {
+ old_string,
+ new_string,
+ } => {
+ let diff = TextDiff::from_lines(old_string.as_str(), new_string.as_str());
+ let mut deleted_lines = 0;
+ let mut inserted_lines = 0;
+ for change in diff.iter_all_changes() {
+ match change.tag() {
+ ChangeTag::Delete => deleted_lines += 1,
+ ChangeTag::Insert => inserted_lines += 1,
+ ChangeTag::Equal => {}
+ }
+ }
+ deleted_lines <= max_lines && inserted_lines <= max_lines
+ }
+ FileUpdateType::Write { .. } => false,
+ }
+ }
+
+ /// Compute the diff lines for an update type (internal helper)
+ fn compute_diff_for(update_type: &FileUpdateType) -> Vec<DiffLine> {
+ match update_type {
+ FileUpdateType::Edit {
+ old_string,
+ new_string,
+ } => {
+ let diff = TextDiff::from_lines(old_string.as_str(), new_string.as_str());
+ diff.iter_all_changes()
+ .map(|change| DiffLine {
+ tag: change.tag().into(),
+ content: change.value().to_string(),
+ })
+ .collect()
+ }
+ FileUpdateType::Write { content } => {
+ // For writes, everything is an insertion
+ content
+ .lines()
+ .map(|line| DiffLine {
+ tag: DiffTag::Insert,
+ content: format!("{}\n", line),
+ })
+ .collect()
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ #[test]
+ fn test_is_small_edit_single_line() {
+ let update = FileUpdate::new(
+ "test.rs".to_string(),
+ FileUpdateType::Edit {
+ old_string: "foo".to_string(),
+ new_string: "bar".to_string(),
+ },
+ );
+ assert!(
+ update.is_small_edit(2),
+ "Single line without newline should be small"
+ );
+ }
+
+ #[test]
+ fn test_is_small_edit_single_line_with_newline() {
+ let update = FileUpdate::new(
+ "test.rs".to_string(),
+ FileUpdateType::Edit {
+ old_string: "foo\n".to_string(),
+ new_string: "bar\n".to_string(),
+ },
+ );
+ assert!(
+ update.is_small_edit(2),
+ "Single line with trailing newline should be small"
+ );
+ }
+
+ #[test]
+ fn test_is_small_edit_two_lines() {
+ let update = FileUpdate::new(
+ "test.rs".to_string(),
+ FileUpdateType::Edit {
+ old_string: "foo\nbar".to_string(),
+ new_string: "baz\nqux".to_string(),
+ },
+ );
+ assert!(
+ update.is_small_edit(2),
+ "Two lines without trailing newline should be small"
+ );
+ }
+
+ #[test]
+ fn test_is_small_edit_two_lines_with_newline() {
+ let update = FileUpdate::new(
+ "test.rs".to_string(),
+ FileUpdateType::Edit {
+ old_string: "foo\nbar\n".to_string(),
+ new_string: "baz\nqux\n".to_string(),
+ },
+ );
+ assert!(
+ update.is_small_edit(2),
+ "Two lines with trailing newline should be small"
+ );
+ }
+
+ #[test]
+ fn test_is_small_edit_three_lines_not_small() {
+ let update = FileUpdate::new(
+ "test.rs".to_string(),
+ FileUpdateType::Edit {
+ old_string: "foo\nbar\nbaz".to_string(),
+ new_string: "a\nb\nc".to_string(),
+ },
+ );
+ assert!(!update.is_small_edit(2), "Three lines should NOT be small");
+ }
+
+ #[test]
+ fn test_is_small_edit_write_never_small() {
+ let update = FileUpdate::new(
+ "test.rs".to_string(),
+ FileUpdateType::Write {
+ content: "x".to_string(),
+ },
+ );
+ assert!(
+ !update.is_small_edit(2),
+ "Write operations should never be small"
+ );
+ }
+
+ #[test]
+ fn test_is_small_edit_old_small_new_large() {
+ let update = FileUpdate::new(
+ "test.rs".to_string(),
+ FileUpdateType::Edit {
+ old_string: "foo".to_string(),
+ new_string: "a\nb\nc\nd".to_string(),
+ },
+ );
+ assert!(
+ !update.is_small_edit(2),
+ "Large new_string should NOT be small"
+ );
+ }
+
+ #[test]
+ fn test_is_small_edit_old_large_new_small() {
+ let update = FileUpdate::new(
+ "test.rs".to_string(),
+ FileUpdateType::Edit {
+ old_string: "a\nb\nc\nd".to_string(),
+ new_string: "foo".to_string(),
+ },
+ );
+ assert!(
+ !update.is_small_edit(2),
+ "Large old_string should NOT be small"
+ );
+ }
+
+ #[test]
+ fn test_from_tool_call_edit() {
+ let input = json!({
+ "file_path": "/path/to/file.rs",
+ "old_string": "old",
+ "new_string": "new"
+ });
+ let update = FileUpdate::from_tool_call("Edit", &input).unwrap();
+ assert_eq!(update.file_path, "/path/to/file.rs");
+ assert!(update.is_small_edit(2));
+ }
+
+ #[test]
+ fn test_from_tool_call_write() {
+ let input = json!({
+ "file_path": "/path/to/file.rs",
+ "content": "content"
+ });
+ let update = FileUpdate::from_tool_call("Write", &input).unwrap();
+ assert_eq!(update.file_path, "/path/to/file.rs");
+ assert!(!update.is_small_edit(2));
+ }
+
+ #[test]
+ fn test_from_tool_call_unknown_tool() {
+ let input = json!({
+ "file_path": "/path/to/file.rs"
+ });
+ assert!(FileUpdate::from_tool_call("Bash", &input).is_none());
+ }
+
+ #[test]
+ fn test_is_small_edit_realistic_claude_edit_with_context() {
+ // Claude Code typically sends old_string/new_string with context lines
+ // for matching. Even a "small" 1-line change includes context.
+ // This tests what an actual Edit tool call might look like.
+ let input = json!({
+ "file_path": "/path/to/file.rs",
+ "old_string": " fn foo() {\n let x = 1;\n }",
+ "new_string": " fn foo() {\n let x = 2;\n }"
+ });
+ let update = FileUpdate::from_tool_call("Edit", &input).unwrap();
+ // Only 1 line actually changed (let x = 1 -> let x = 2)
+ // The context lines (fn foo() and }) are the same
+ assert!(
+ update.is_small_edit(2),
+ "1-line actual change should be small, even with 3 lines of context"
+ );
+ }
+
+ #[test]
+ fn test_is_small_edit_minimal_change() {
+ // A truly minimal single-line change
+ let input = json!({
+ "file_path": "/path/to/file.rs",
+ "old_string": "let x = 1;",
+ "new_string": "let x = 2;"
+ });
+ let update = FileUpdate::from_tool_call("Edit", &input).unwrap();
+ assert!(
+ update.is_small_edit(2),
+ "Single-line change should be small"
+ );
+ }
+
+ #[test]
+ fn test_line_count_behavior() {
+ // Document how lines().count() behaves
+ assert_eq!("foo".lines().count(), 1);
+ assert_eq!("foo\n".lines().count(), 1); // trailing newline doesn't add line
+ assert_eq!("foo\nbar".lines().count(), 2);
+ assert_eq!("foo\nbar\n".lines().count(), 2);
+ assert_eq!("foo\nbar\nbaz".lines().count(), 3);
+ assert_eq!("foo\nbar\nbaz\n".lines().count(), 3);
+ // Empty strings
+ assert_eq!("".lines().count(), 0);
+ assert_eq!("\n".lines().count(), 1); // just a newline counts as 1 empty line
+ }
+}
diff --git a/crates/notedeck_dave/src/focus_queue.rs b/crates/notedeck_dave/src/focus_queue.rs
@@ -0,0 +1,539 @@
+use crate::agent_status::AgentStatus;
+use crate::session::SessionId;
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum FocusPriority {
+ Done = 0,
+ Error = 1,
+ NeedsInput = 2,
+}
+
+impl FocusPriority {
+ pub fn from_status(status: AgentStatus) -> Option<Self> {
+ match status {
+ AgentStatus::NeedsInput => Some(Self::NeedsInput),
+ AgentStatus::Error => Some(Self::Error),
+ AgentStatus::Done => Some(Self::Done),
+ AgentStatus::Idle | AgentStatus::Working => None,
+ }
+ }
+
+ pub fn color(&self) -> egui::Color32 {
+ match self {
+ Self::NeedsInput => egui::Color32::from_rgb(255, 200, 0),
+ Self::Error => egui::Color32::from_rgb(220, 60, 60),
+ Self::Done => egui::Color32::from_rgb(70, 130, 220),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct QueueEntry {
+ pub session_id: SessionId,
+ pub priority: FocusPriority,
+}
+
+pub struct FocusQueue {
+ entries: Vec<QueueEntry>, // kept sorted: NeedsInput -> Error -> Done
+ cursor: Option<usize>, // index into entries
+ previous_statuses: HashMap<SessionId, AgentStatus>,
+}
+
+impl Default for FocusQueue {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl FocusQueue {
+ pub fn new() -> Self {
+ Self {
+ entries: Vec::new(),
+ cursor: None,
+ previous_statuses: HashMap::new(),
+ }
+ }
+
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+
+ fn sort_key(p: FocusPriority) -> i32 {
+ // want NeedsInput first, then Error, then Done
+ -(p as i32)
+ }
+
+ fn find(&self, session_id: SessionId) -> Option<usize> {
+ self.entries.iter().position(|e| e.session_id == session_id)
+ }
+
+ fn normalize_cursor_after_remove(&mut self, removed_idx: usize) {
+ match self.cursor {
+ None => {}
+ Some(_cur) if self.entries.is_empty() => self.cursor = None,
+ Some(cur) if removed_idx < cur => self.cursor = Some(cur - 1),
+ Some(cur) if removed_idx == cur => {
+ // keep cursor pointing at a valid item (same index if possible, else last)
+ let new_cur = cur.min(self.entries.len().saturating_sub(1));
+ self.cursor = Some(new_cur);
+ }
+ Some(_) => {}
+ }
+ }
+
+ /// Insert entry in priority order (stable within same priority).
+ fn insert_sorted(&mut self, entry: QueueEntry) {
+ let key = Self::sort_key(entry.priority);
+ let pos = self
+ .entries
+ .iter()
+ .position(|e| Self::sort_key(e.priority) > key)
+ .unwrap_or(self.entries.len());
+ self.entries.insert(pos, entry);
+
+ // initialize cursor if this is the first item
+ if self.cursor.is_none() && self.entries.len() == 1 {
+ self.cursor = Some(0);
+ } else if let Some(cur) = self.cursor {
+ // if we inserted before the cursor, shift cursor right
+ if pos <= cur {
+ self.cursor = Some(cur + 1);
+ }
+ }
+ }
+
+ pub fn enqueue(&mut self, session_id: SessionId, priority: FocusPriority) {
+ if let Some(i) = self.find(session_id) {
+ if self.entries[i].priority == priority {
+ return;
+ }
+ // remove old entry, then reinsert at correct spot
+ self.entries.remove(i);
+ self.normalize_cursor_after_remove(i);
+ }
+ self.insert_sorted(QueueEntry {
+ session_id,
+ priority,
+ });
+ }
+
+ pub fn dequeue(&mut self, session_id: SessionId) {
+ if let Some(i) = self.find(session_id) {
+ self.entries.remove(i);
+ self.normalize_cursor_after_remove(i);
+ }
+ }
+
+ pub fn next(&mut self) -> Option<SessionId> {
+ if self.entries.is_empty() {
+ self.cursor = None;
+ return None;
+ }
+ let cur = self.cursor.unwrap_or(0);
+ let current_priority = self.entries[cur].priority;
+
+ // Find all entries with the same priority
+ let same_priority_indices: Vec<usize> = self
+ .entries
+ .iter()
+ .enumerate()
+ .filter(|(_, e)| e.priority == current_priority)
+ .map(|(i, _)| i)
+ .collect();
+
+ // Find current position within same-priority items
+ let pos_in_group = same_priority_indices
+ .iter()
+ .position(|&i| i == cur)
+ .unwrap_or(0);
+
+ // If at last item in group, try to go up to highest priority level
+ if pos_in_group == same_priority_indices.len() - 1 {
+ // Check if there's a higher priority group (entries are sorted highest first)
+ let first_same_priority = same_priority_indices[0];
+ if first_same_priority > 0 {
+ // Go to the very first item (highest priority)
+ self.cursor = Some(0);
+ return Some(self.entries[0].session_id);
+ }
+ // No higher priority group, wrap to first item in current group
+ let next = same_priority_indices[0];
+ self.cursor = Some(next);
+ Some(self.entries[next].session_id)
+ } else {
+ // Move forward within the same priority group
+ let next = same_priority_indices[pos_in_group + 1];
+ self.cursor = Some(next);
+ Some(self.entries[next].session_id)
+ }
+ }
+
+ pub fn prev(&mut self) -> Option<SessionId> {
+ if self.entries.is_empty() {
+ self.cursor = None;
+ return None;
+ }
+ let cur = self.cursor.unwrap_or(0);
+ let current_priority = self.entries[cur].priority;
+
+ // Find all entries with the same priority
+ let same_priority_indices: Vec<usize> = self
+ .entries
+ .iter()
+ .enumerate()
+ .filter(|(_, e)| e.priority == current_priority)
+ .map(|(i, _)| i)
+ .collect();
+
+ // Find current position within same-priority items
+ let pos_in_group = same_priority_indices
+ .iter()
+ .position(|&i| i == cur)
+ .unwrap_or(0);
+
+ // If at first item in group, try to drop to next lower priority level
+ if pos_in_group == 0 {
+ // Find first item with lower priority (higher index since sorted by priority desc)
+ let last_same_priority = *same_priority_indices.last().unwrap();
+ if last_same_priority + 1 < self.entries.len() {
+ // There's a lower priority group, go to first item of it
+ let next_idx = last_same_priority + 1;
+ self.cursor = Some(next_idx);
+ return Some(self.entries[next_idx].session_id);
+ }
+ // No lower priority group, wrap to last item in current group
+ let prev = *same_priority_indices.last().unwrap();
+ self.cursor = Some(prev);
+ Some(self.entries[prev].session_id)
+ } else {
+ // Move backward within the same priority group
+ let prev = same_priority_indices[pos_in_group - 1];
+ self.cursor = Some(prev);
+ Some(self.entries[prev].session_id)
+ }
+ }
+
+ pub fn current(&self) -> Option<QueueEntry> {
+ let i = self.cursor?;
+ self.entries.get(i).copied()
+ }
+
+ pub fn current_position(&self) -> Option<usize> {
+ Some(self.cursor? + 1) // 1-indexed
+ }
+
+ /// Get the raw cursor index (0-indexed)
+ pub fn cursor_index(&self) -> Option<usize> {
+ self.cursor
+ }
+
+ /// Set the cursor to a specific index, clamping to valid range
+ pub fn set_cursor(&mut self, index: usize) {
+ if self.entries.is_empty() {
+ self.cursor = None;
+ } else {
+ self.cursor = Some(index.min(self.entries.len() - 1));
+ }
+ }
+
+ /// Find the first entry with NeedsInput priority and return its index
+ pub fn first_needs_input_index(&self) -> Option<usize> {
+ self.entries
+ .iter()
+ .position(|e| e.priority == FocusPriority::NeedsInput)
+ }
+
+ /// Check if there are any NeedsInput items in the queue
+ pub fn has_needs_input(&self) -> bool {
+ self.entries
+ .iter()
+ .any(|e| e.priority == FocusPriority::NeedsInput)
+ }
+
+ pub fn ui_info(&self) -> Option<(usize, usize, FocusPriority)> {
+ let entry = self.current()?;
+ Some((self.current_position()?, self.len(), entry.priority))
+ }
+
+ pub fn update_from_statuses(
+ &mut self,
+ sessions: impl Iterator<Item = (SessionId, AgentStatus)>,
+ ) {
+ for (session_id, status) in sessions {
+ let prev = self.previous_statuses.get(&session_id).copied();
+ if prev != Some(status) {
+ if let Some(priority) = FocusPriority::from_status(status) {
+ self.enqueue(session_id, priority);
+ } else {
+ self.dequeue(session_id);
+ }
+ }
+ self.previous_statuses.insert(session_id, status);
+ }
+ }
+
+ pub fn get_session_priority(&self, session_id: SessionId) -> Option<FocusPriority> {
+ self.entries
+ .iter()
+ .find(|e| e.session_id == session_id)
+ .map(|e| e.priority)
+ }
+
+ pub fn remove_session(&mut self, session_id: SessionId) {
+ self.dequeue(session_id);
+ self.previous_statuses.remove(&session_id);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn session(id: u32) -> SessionId {
+ id
+ }
+
+ #[test]
+ fn test_empty_queue() {
+ let mut queue = FocusQueue::new();
+ assert!(queue.is_empty());
+ assert_eq!(queue.next(), None);
+ assert_eq!(queue.prev(), None);
+ assert_eq!(queue.current(), None);
+ }
+
+ #[test]
+ fn test_priority_ordering() {
+ // Items should be sorted: NeedsInput -> Error -> Done
+ let mut queue = FocusQueue::new();
+
+ queue.enqueue(session(1), FocusPriority::Done);
+ queue.enqueue(session(2), FocusPriority::NeedsInput);
+ queue.enqueue(session(3), FocusPriority::Error);
+
+ // Verify internal ordering: NeedsInput(2), Error(3), Done(1)
+ assert_eq!(queue.entries[0].session_id, session(2));
+ assert_eq!(queue.entries[0].priority, FocusPriority::NeedsInput);
+ assert_eq!(queue.entries[1].session_id, session(3));
+ assert_eq!(queue.entries[1].priority, FocusPriority::Error);
+ assert_eq!(queue.entries[2].session_id, session(1));
+ assert_eq!(queue.entries[2].priority, FocusPriority::Done);
+
+ // Navigate to front (highest priority)
+ queue.set_cursor(0);
+ assert_eq!(queue.current().unwrap().session_id, session(2));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::NeedsInput);
+
+ // prev from NeedsInput should drop down to Error
+ queue.prev();
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::Error);
+
+ // prev from Error should drop down to Done
+ queue.prev();
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::Done);
+ }
+
+ #[test]
+ fn test_next_cycles_within_priority_then_wraps() {
+ let mut queue = FocusQueue::new();
+
+ // Add two NeedsInput items and one Done
+ queue.enqueue(session(1), FocusPriority::NeedsInput);
+ queue.enqueue(session(2), FocusPriority::NeedsInput);
+ queue.enqueue(session(3), FocusPriority::Done);
+
+ // Cursor starts at session 1 (first NeedsInput)
+ queue.set_cursor(0);
+ assert_eq!(queue.current().unwrap().session_id, session(1));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::NeedsInput);
+
+ // next should cycle to session 2 (also NeedsInput)
+ let result = queue.next();
+ assert_eq!(result, Some(session(2)));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::NeedsInput);
+
+ // next again should wrap back to session 1 (stays in NeedsInput, doesn't drop to Done)
+ let result = queue.next();
+ assert_eq!(result, Some(session(1)));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::NeedsInput);
+ }
+
+ #[test]
+ fn test_prev_drops_to_lower_priority() {
+ let mut queue = FocusQueue::new();
+
+ // Add two NeedsInput items and one Done
+ queue.enqueue(session(1), FocusPriority::NeedsInput);
+ queue.enqueue(session(2), FocusPriority::NeedsInput);
+ queue.enqueue(session(3), FocusPriority::Done);
+
+ // Start at first NeedsInput
+ queue.set_cursor(0);
+ assert_eq!(queue.current().unwrap().session_id, session(1));
+
+ // prev from first in group should drop to Done (lower priority)
+ let result = queue.prev();
+ assert_eq!(result, Some(session(3)));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::Done);
+ }
+
+ #[test]
+ fn test_prev_cycles_within_priority_before_dropping() {
+ let mut queue = FocusQueue::new();
+
+ // Add two NeedsInput items and one Done
+ queue.enqueue(session(1), FocusPriority::NeedsInput);
+ queue.enqueue(session(2), FocusPriority::NeedsInput);
+ queue.enqueue(session(3), FocusPriority::Done);
+
+ // Start at second NeedsInput
+ queue.set_cursor(1);
+ assert_eq!(queue.current().unwrap().session_id, session(2));
+
+ // prev should go to session 1 (earlier in same priority group)
+ let result = queue.prev();
+ assert_eq!(result, Some(session(1)));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::NeedsInput);
+
+ // prev again should now drop to Done
+ let result = queue.prev();
+ assert_eq!(result, Some(session(3)));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::Done);
+ }
+
+ #[test]
+ fn test_next_from_done_goes_to_needs_input() {
+ let mut queue = FocusQueue::new();
+
+ queue.enqueue(session(1), FocusPriority::Done);
+ queue.enqueue(session(2), FocusPriority::NeedsInput);
+ queue.enqueue(session(3), FocusPriority::Error);
+
+ // Navigate to Done (only one item with this priority)
+ queue.set_cursor(2); // Done is at index 2
+ assert_eq!(queue.current().unwrap().session_id, session(1));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::Done);
+
+ // next from Done should go up to NeedsInput (highest priority)
+ let result = queue.next();
+ assert_eq!(result, Some(session(2)));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::NeedsInput);
+ }
+
+ #[test]
+ fn test_prev_from_lowest_wraps_within_group() {
+ let mut queue = FocusQueue::new();
+
+ // Only Done items, no lower priority to drop to
+ queue.enqueue(session(1), FocusPriority::Done);
+ queue.enqueue(session(2), FocusPriority::Done);
+
+ queue.set_cursor(0);
+ assert_eq!(queue.current().unwrap().session_id, session(1));
+
+ // prev from first Done should wrap to last Done (no lower priority exists)
+ let result = queue.prev();
+ assert_eq!(result, Some(session(2)));
+ }
+
+ #[test]
+ fn test_cursor_adjustment_on_higher_priority_insert() {
+ let mut queue = FocusQueue::new();
+
+ // Start with a Done item
+ queue.enqueue(session(1), FocusPriority::Done);
+ assert_eq!(queue.current().unwrap().session_id, session(1));
+
+ // Insert a higher priority item - cursor should shift to keep pointing at same item
+ queue.enqueue(session(2), FocusPriority::NeedsInput);
+
+ // Cursor should still point to session 1 (now at index 1)
+ assert_eq!(queue.current().unwrap().session_id, session(1));
+
+ // next should go up to the new higher priority item
+ queue.next();
+ assert_eq!(queue.current().unwrap().session_id, session(2));
+ assert_eq!(queue.current().unwrap().priority, FocusPriority::NeedsInput);
+ }
+
+ #[test]
+ fn test_priority_upgrade() {
+ let mut queue = FocusQueue::new();
+
+ queue.enqueue(session(1), FocusPriority::Done);
+ queue.enqueue(session(2), FocusPriority::Done);
+
+ // Session 2 should be after session 1 (same priority, insertion order)
+ assert_eq!(queue.entries[0].session_id, session(1));
+ assert_eq!(queue.entries[1].session_id, session(2));
+
+ // Upgrade session 2 to NeedsInput
+ queue.enqueue(session(2), FocusPriority::NeedsInput);
+
+ // Session 2 should now be first
+ assert_eq!(queue.entries[0].session_id, session(2));
+ assert_eq!(queue.entries[0].priority, FocusPriority::NeedsInput);
+ }
+
+ #[test]
+ fn test_dequeue_adjusts_cursor() {
+ let mut queue = FocusQueue::new();
+
+ queue.enqueue(session(1), FocusPriority::NeedsInput);
+ queue.enqueue(session(2), FocusPriority::Error);
+ queue.enqueue(session(3), FocusPriority::Done);
+
+ // Move cursor to session 2 (Error) using set_cursor
+ queue.set_cursor(1);
+ assert_eq!(queue.current().unwrap().session_id, session(2));
+
+ // Remove session 1 (before cursor)
+ queue.dequeue(session(1));
+
+ // Cursor should adjust and still point to session 2
+ assert_eq!(queue.current().unwrap().session_id, session(2));
+ }
+
+ #[test]
+ fn test_single_item_navigation() {
+ let mut queue = FocusQueue::new();
+
+ queue.enqueue(session(1), FocusPriority::NeedsInput);
+
+ // With single item, next and prev should both return that item
+ assert_eq!(queue.next(), Some(session(1)));
+ assert_eq!(queue.prev(), Some(session(1)));
+ assert_eq!(queue.current().unwrap().session_id, session(1));
+ }
+
+ #[test]
+ fn test_update_from_statuses() {
+ let mut queue = FocusQueue::new();
+
+ // Initial statuses - order matters for cursor position
+ // First item added gets cursor, subsequent inserts shift it
+ let statuses = vec![
+ (session(1), AgentStatus::Done),
+ (session(2), AgentStatus::NeedsInput),
+ (session(3), AgentStatus::Working), // Should not be added (Idle/Working excluded)
+ ];
+ queue.update_from_statuses(statuses.into_iter());
+
+ assert_eq!(queue.len(), 2);
+ // Verify NeedsInput is first in priority order
+ assert_eq!(queue.entries[0].session_id, session(2));
+ assert_eq!(queue.entries[0].priority, FocusPriority::NeedsInput);
+
+ // Update: session 2 becomes Idle (should be removed from queue)
+ let statuses = vec![(session(2), AgentStatus::Idle)];
+ queue.update_from_statuses(statuses.into_iter());
+
+ assert_eq!(queue.len(), 1);
+ assert_eq!(queue.current().unwrap().session_id, session(1));
+ }
+}
diff --git a/crates/notedeck_dave/src/ipc.rs b/crates/notedeck_dave/src/ipc.rs
@@ -0,0 +1,246 @@
+//! IPC module for external spawn-agent commands via Unix domain sockets.
+//!
+//! This allows external tools (like `notedeck-spawn`) to create new agent
+//! sessions in a running notedeck instance.
+
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+/// Request to spawn a new agent
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SpawnRequest {
+ #[serde(rename = "type")]
+ pub request_type: String,
+ pub cwd: PathBuf,
+}
+
+/// Response to a spawn request
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SpawnResponse {
+ pub status: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub session_id: Option<u32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub message: Option<String>,
+}
+
+impl SpawnResponse {
+ pub fn ok(session_id: u32) -> Self {
+ Self {
+ status: "ok".to_string(),
+ session_id: Some(session_id),
+ message: None,
+ }
+ }
+
+ pub fn error(message: impl Into<String>) -> Self {
+ Self {
+ status: "error".to_string(),
+ session_id: None,
+ message: Some(message.into()),
+ }
+ }
+}
+
+/// Returns the path to the IPC socket.
+///
+/// Uses XDG_RUNTIME_DIR on Linux (e.g., /run/user/1000/notedeck/spawn.sock)
+/// or falls back to a user-local directory.
+pub fn socket_path() -> PathBuf {
+ // Try XDG_RUNTIME_DIR first (Linux)
+ if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") {
+ return PathBuf::from(runtime_dir)
+ .join("notedeck")
+ .join("spawn.sock");
+ }
+
+ // macOS: use Application Support
+ #[cfg(target_os = "macos")]
+ if let Some(home) = dirs::home_dir() {
+ return home
+ .join("Library")
+ .join("Application Support")
+ .join("notedeck")
+ .join("spawn.sock");
+ }
+
+ // Fallback: ~/.local/share/notedeck
+ dirs::data_local_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join("notedeck")
+ .join("spawn.sock")
+}
+
+#[cfg(unix)]
+pub use unix::*;
+
+#[cfg(unix)]
+mod unix {
+ use super::*;
+ use std::io::{BufRead, BufReader, Write};
+ use std::os::unix::net::{UnixListener, UnixStream};
+ use std::sync::mpsc;
+ use std::thread;
+
+ /// A pending IPC connection that needs to be processed
+ pub struct PendingConnection {
+ pub stream: UnixStream,
+ pub cwd: PathBuf,
+ }
+
+ /// Handle to the IPC listener background thread
+ pub struct IpcListener {
+ receiver: mpsc::Receiver<PendingConnection>,
+ }
+
+ impl IpcListener {
+ /// Poll for pending connections (non-blocking)
+ pub fn try_recv(&self) -> Option<PendingConnection> {
+ self.receiver.try_recv().ok()
+ }
+ }
+
+ /// Creates an IPC listener that runs in a background thread.
+ ///
+ /// The background thread blocks on accept() and calls request_repaint()
+ /// when a connection arrives, ensuring the UI wakes up immediately.
+ ///
+ /// Returns None if the socket cannot be created (e.g., permission issues).
+ pub fn create_listener(ctx: egui::Context) -> Option<IpcListener> {
+ let path = socket_path();
+
+ // Ensure parent directory exists
+ if let Some(parent) = path.parent() {
+ if let Err(e) = std::fs::create_dir_all(parent) {
+ tracing::warn!("Failed to create IPC socket directory: {}", e);
+ return None;
+ }
+ }
+
+ // Remove stale socket if it exists
+ if path.exists() {
+ if let Err(e) = std::fs::remove_file(&path) {
+ tracing::warn!("Failed to remove stale socket: {}", e);
+ return None;
+ }
+ }
+
+ // Create and bind the listener (blocking mode for the background thread)
+ let listener = match UnixListener::bind(&path) {
+ Ok(listener) => {
+ tracing::info!("IPC listener started at {}", path.display());
+ listener
+ }
+ Err(e) => {
+ tracing::warn!("Failed to create IPC listener: {}", e);
+ return None;
+ }
+ };
+
+ // Channel for sending connections to the main thread
+ let (sender, receiver) = mpsc::channel();
+
+ // Spawn background thread to handle incoming connections
+ thread::Builder::new()
+ .name("ipc-listener".to_string())
+ .spawn(move || {
+ for stream in listener.incoming() {
+ match stream {
+ Ok(mut stream) => {
+ // Parse the request in the background thread
+ match handle_connection(&mut stream) {
+ Ok(cwd) => {
+ let pending = PendingConnection { stream, cwd };
+ if sender.send(pending).is_err() {
+ // Main thread dropped the receiver, exit
+ tracing::debug!("IPC listener: main thread gone, exiting");
+ break;
+ }
+ // Wake up the UI to process the connection
+ ctx.request_repaint();
+ }
+ Err(e) => {
+ // Send error response directly
+ let response = SpawnResponse::error(&e);
+ let _ = send_response(&mut stream, &response);
+ tracing::warn!("IPC spawn-agent failed: {}", e);
+ }
+ }
+ }
+ Err(e) => {
+ tracing::warn!("IPC accept error: {}", e);
+ }
+ }
+ }
+ tracing::debug!("IPC listener thread exiting");
+ })
+ .ok()?;
+
+ Some(IpcListener { receiver })
+ }
+
+ /// Handles a single IPC connection, returning the cwd if valid spawn request.
+ pub fn handle_connection(stream: &mut UnixStream) -> Result<PathBuf, String> {
+ // Read the request line
+ let mut reader = BufReader::new(stream.try_clone().map_err(|e| e.to_string())?);
+ let mut line = String::new();
+ reader.read_line(&mut line).map_err(|e| e.to_string())?;
+
+ // Parse JSON request
+ let request: SpawnRequest =
+ serde_json::from_str(&line).map_err(|e| format!("Invalid JSON: {}", e))?;
+
+ // Validate request type
+ if request.request_type != "spawn_agent" {
+ return Err(format!("Unknown request type: {}", request.request_type));
+ }
+
+ // Validate path exists and is a directory
+ if !request.cwd.exists() {
+ return Err(format!("Path does not exist: {}", request.cwd.display()));
+ }
+ if !request.cwd.is_dir() {
+ return Err(format!(
+ "Path is not a directory: {}",
+ request.cwd.display()
+ ));
+ }
+
+ Ok(request.cwd)
+ }
+
+ /// Sends a response back to the client
+ pub fn send_response(stream: &mut UnixStream, response: &SpawnResponse) -> std::io::Result<()> {
+ let json = serde_json::to_string(response)?;
+ writeln!(stream, "{}", json)?;
+ stream.flush()
+ }
+}
+
+// Stub for non-Unix platforms (Windows)
+#[cfg(not(unix))]
+pub mod non_unix {
+ use std::path::PathBuf;
+
+ /// Stub for PendingConnection on non-Unix platforms
+ pub struct PendingConnection {
+ pub cwd: PathBuf,
+ }
+
+ /// Stub for IpcListener on non-Unix platforms
+ pub struct IpcListener;
+
+ impl IpcListener {
+ pub fn try_recv(&self) -> Option<PendingConnection> {
+ None
+ }
+ }
+
+ pub fn create_listener(_ctx: egui::Context) -> Option<IpcListener> {
+ tracing::info!("IPC spawn-agent not supported on this platform");
+ None
+ }
+}
+
+#[cfg(not(unix))]
+pub use non_unix::*;
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -1,54 +1,104 @@
+mod agent_status;
+mod auto_accept;
mod avatar;
+mod backend;
mod config;
+pub mod file_update;
+mod focus_queue;
+pub mod ipc;
pub(crate) mod mesh;
mod messages;
mod quaternion;
pub mod session;
+pub mod session_discovery;
mod tools;
mod ui;
+mod update;
mod vec3;
-use async_openai::{
- config::OpenAIConfig,
- types::{ChatCompletionRequestMessage, CreateChatCompletionRequest},
- Client,
-};
+use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend};
use chrono::{Duration, Local};
use egui_wgpu::RenderState;
use enostr::KeypairUnowned;
-use futures::StreamExt;
+use focus_queue::FocusQueue;
use nostrdb::Transaction;
use notedeck::{ui::is_narrow, AppAction, AppContext, AppResponse};
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
+use std::path::PathBuf;
use std::string::ToString;
-use std::sync::mpsc;
use std::sync::Arc;
+use std::time::Instant;
pub use avatar::DaveAvatar;
-pub use config::ModelConfig;
-pub use messages::{DaveApiResponse, Message};
+pub use config::{AiMode, AiProvider, DaveSettings, ModelConfig};
+pub use messages::{
+ AskUserQuestionInput, DaveApiResponse, Message, PermissionResponse, PermissionResponseType,
+ QuestionAnswer, SessionInfo, SubagentInfo, SubagentStatus, ToolResult,
+};
pub use quaternion::Quaternion;
pub use session::{ChatSession, SessionId, SessionManager};
+pub use session_discovery::{discover_sessions, format_relative_time, ResumableSession};
pub use tools::{
PartialToolCall, QueryCall, QueryResponse, Tool, ToolCall, ToolCalls, ToolResponse,
ToolResponses,
};
-pub use ui::{DaveAction, DaveResponse, DaveUi, SessionListAction, SessionListUi};
+pub use ui::{
+ check_keybindings, AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi,
+ DirectoryPicker, DirectoryPickerAction, KeyAction, KeyActionResult, OverlayResult, SceneAction,
+ SceneResponse, SceneViewAction, SendActionResult, SessionListAction, SessionListUi,
+ SessionPicker, SessionPickerAction, SettingsPanelAction, UiActionResult,
+};
pub use vec3::Vec3;
+/// Represents which full-screen overlay (if any) is currently active
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum DaveOverlay {
+ #[default]
+ None,
+ Settings,
+ DirectoryPicker,
+ SessionPicker,
+}
+
pub struct Dave {
+ /// AI interaction mode (Chat vs Agentic)
+ ai_mode: AiMode,
/// Manages multiple chat sessions
session_manager: SessionManager,
/// A 3d representation of dave.
avatar: Option<DaveAvatar>,
/// Shared tools available to all sessions
tools: Arc<HashMap<String, Tool>>,
- /// Shared API client
- client: async_openai::Client<OpenAIConfig>,
+ /// AI backend (OpenAI, Claude, etc.)
+ backend: Box<dyn AiBackend>,
/// Model configuration
model_config: ModelConfig,
/// Whether to show session list on mobile
show_session_list: bool,
+ /// User settings
+ settings: DaveSettings,
+ /// Settings panel UI state
+ settings_panel: DaveSettingsPanel,
+ /// RTS-style scene view
+ scene: AgentScene,
+ /// Whether to show scene view (vs classic chat view)
+ show_scene: bool,
+ /// Tracks when first Escape was pressed for interrupt confirmation
+ interrupt_pending_since: Option<Instant>,
+ /// Focus queue for agents needing attention
+ focus_queue: FocusQueue,
+ /// Auto-steal focus mode: automatically cycle through focus queue items
+ auto_steal_focus: bool,
+ /// The session ID to return to after processing all NeedsInput items
+ home_session: Option<SessionId>,
+ /// Directory picker for selecting working directory when creating sessions
+ directory_picker: DirectoryPicker,
+ /// Session picker for resuming existing Claude sessions
+ session_picker: SessionPicker,
+ /// Current overlay taking over the UI (if any)
+ active_overlay: DaveOverlay,
+ /// IPC listener for external spawn-agent commands
+ ipc_listener: Option<ipc::IpcListener>,
}
/// Calculate an anonymous user_id from a keypair
@@ -69,7 +119,7 @@ impl Dave {
self.avatar.as_mut()
}
- fn system_prompt() -> Message {
+ fn _system_prompt() -> Message {
let now = Local::now();
let yesterday = now - Duration::hours(24);
let date = now.format("%Y-%m-%d %H:%M:%S");
@@ -93,10 +143,28 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
))
}
- pub fn new(render_state: Option<&RenderState>) -> Self {
+ pub fn new(render_state: Option<&RenderState>, ndb: nostrdb::Ndb, ctx: egui::Context) -> Self {
let model_config = ModelConfig::default();
//let model_config = ModelConfig::ollama();
- let client = Client::with_config(model_config.to_api());
+
+ // Determine AI mode from backend type
+ let ai_mode = model_config.ai_mode();
+
+ // Create backend based on configuration
+ let backend: Box<dyn AiBackend> = match model_config.backend {
+ BackendType::OpenAI => {
+ use async_openai::Client;
+ let client = Client::with_config(model_config.to_api());
+ Box::new(OpenAiBackend::new(client, ndb.clone()))
+ }
+ BackendType::Claude => {
+ let api_key = model_config
+ .anthropic_api_key
+ .as_ref()
+ .expect("Claude backend requires ANTHROPIC_API_KEY or CLAUDE_API_KEY");
+ Box::new(ClaudeBackend::new(api_key.clone()))
+ }
+ };
let avatar = render_state.map(DaveAvatar::new);
let mut tools: HashMap<String, Tool> = HashMap::new();
@@ -104,145 +172,375 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
tools.insert(tool.name().to_string(), tool);
}
+ let settings = DaveSettings::from_model_config(&model_config);
+
+ let directory_picker = DirectoryPicker::new();
+
+ // Create IPC listener for external spawn-agent commands
+ let ipc_listener = ipc::create_listener(ctx);
+
+ // In Chat mode, create a default session immediately and skip directory picker
+ // In Agentic mode, show directory picker on startup
+ let (session_manager, active_overlay) = match ai_mode {
+ AiMode::Chat => {
+ let mut manager = SessionManager::new();
+ // Create a default session with current directory
+ manager.new_session(std::env::current_dir().unwrap_or_default(), ai_mode);
+ (manager, DaveOverlay::None)
+ }
+ AiMode::Agentic => (SessionManager::new(), DaveOverlay::DirectoryPicker),
+ };
+
Dave {
- client,
+ ai_mode,
+ backend,
avatar,
- session_manager: SessionManager::new(),
+ session_manager,
tools: Arc::new(tools),
model_config,
show_session_list: false,
+ settings,
+ settings_panel: DaveSettingsPanel::new(),
+ scene: AgentScene::new(),
+ show_scene: false, // Default to list view
+ interrupt_pending_since: None,
+ focus_queue: FocusQueue::new(),
+ auto_steal_focus: false,
+ home_session: None,
+ directory_picker,
+ session_picker: SessionPicker::new(),
+ active_overlay,
+ ipc_listener,
}
}
- /// Process incoming tokens from the ai backend
- fn process_events(&mut self, app_ctx: &AppContext) -> bool {
- // Should we continue sending requests? Set this to true if
- // we have tool responses to send back to the ai
- let mut should_send = false;
+ /// Get current settings for persistence
+ pub fn settings(&self) -> &DaveSettings {
+ &self.settings
+ }
- // Take the receiver out to avoid borrow conflicts
- let recvr = {
- let Some(session) = self.session_manager.get_active_mut() else {
- return should_send;
- };
- session.incoming_tokens.take()
- };
+ /// Apply new settings. Note: Provider changes require app restart to take effect.
+ pub fn apply_settings(&mut self, settings: DaveSettings) {
+ self.model_config = ModelConfig::from_settings(&settings);
+ self.settings = settings;
+ }
- let Some(recvr) = recvr else {
- return should_send;
- };
+ /// Process incoming tokens from the ai backend for ALL sessions
+ /// Returns a set of session IDs that need to send tool responses
+ fn process_events(&mut self, app_ctx: &AppContext) -> HashSet<SessionId> {
+ // Track which sessions need to send tool responses
+ let mut needs_send: HashSet<SessionId> = HashSet::new();
+ let active_id = self.session_manager.active_id();
- while let Ok(res) = recvr.try_recv() {
- if let Some(avatar) = &mut self.avatar {
- avatar.random_nudge();
- }
+ // Get all session IDs to process
+ let session_ids = self.session_manager.session_ids();
- let Some(session) = self.session_manager.get_active_mut() else {
- break;
+ for session_id in session_ids {
+ // Take the receiver out to avoid borrow conflicts
+ let recvr = {
+ let Some(session) = self.session_manager.get_mut(session_id) else {
+ continue;
+ };
+ session.incoming_tokens.take()
};
- match res {
- DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)),
-
- DaveApiResponse::Token(token) => match session.chat.last_mut() {
- Some(Message::Assistant(msg)) => *msg = msg.clone() + &token,
- Some(_) => session.chat.push(Message::Assistant(token)),
- None => {}
- },
-
- DaveApiResponse::ToolCalls(toolcalls) => {
- tracing::info!("got tool calls: {:?}", toolcalls);
- session.chat.push(Message::ToolCalls(toolcalls.clone()));
-
- let txn = Transaction::new(app_ctx.ndb).unwrap();
- for call in &toolcalls {
- // execute toolcall
- match call.calls() {
- ToolCalls::PresentNotes(present) => {
- session.chat.push(Message::ToolResponse(ToolResponse::new(
- call.id().to_owned(),
- ToolResponses::PresentNotes(present.note_ids.len() as i32),
- )));
-
- should_send = true;
- }
+ let Some(recvr) = recvr else {
+ continue;
+ };
+
+ while let Ok(res) = recvr.try_recv() {
+ // Nudge avatar only for active session
+ if active_id == Some(session_id) {
+ if let Some(avatar) = &mut self.avatar {
+ avatar.random_nudge();
+ }
+ }
- ToolCalls::Invalid(invalid) => {
- should_send = true;
+ let Some(session) = self.session_manager.get_mut(session_id) else {
+ break;
+ };
- session.chat.push(Message::tool_error(
- call.id().to_string(),
- invalid.error.clone(),
- ));
+ match res {
+ DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)),
+
+ DaveApiResponse::Token(token) => match session.chat.last_mut() {
+ Some(Message::Assistant(msg)) => msg.push_str(&token),
+ Some(_) => session.chat.push(Message::Assistant(token)),
+ None => {}
+ },
+
+ DaveApiResponse::ToolCalls(toolcalls) => {
+ tracing::info!("got tool calls: {:?}", toolcalls);
+ session.chat.push(Message::ToolCalls(toolcalls.clone()));
+
+ let txn = Transaction::new(app_ctx.ndb).unwrap();
+ for call in &toolcalls {
+ // execute toolcall
+ match call.calls() {
+ ToolCalls::PresentNotes(present) => {
+ session.chat.push(Message::ToolResponse(ToolResponse::new(
+ call.id().to_owned(),
+ ToolResponses::PresentNotes(present.note_ids.len() as i32),
+ )));
+
+ needs_send.insert(session_id);
+ }
+
+ ToolCalls::Invalid(invalid) => {
+ session.chat.push(Message::tool_error(
+ call.id().to_string(),
+ invalid.error.clone(),
+ ));
+
+ needs_send.insert(session_id);
+ }
+
+ ToolCalls::Query(search_call) => {
+ let resp = search_call.execute(&txn, app_ctx.ndb);
+ session.chat.push(Message::ToolResponse(ToolResponse::new(
+ call.id().to_owned(),
+ ToolResponses::Query(resp),
+ )));
+
+ needs_send.insert(session_id);
+ }
}
+ }
+ }
- ToolCalls::Query(search_call) => {
- should_send = true;
+ DaveApiResponse::PermissionRequest(pending) => {
+ tracing::info!(
+ "Permission request for tool '{}': {:?}",
+ pending.request.tool_name,
+ pending.request.tool_input
+ );
- let resp = search_call.execute(&txn, app_ctx.ndb);
- session.chat.push(Message::ToolResponse(ToolResponse::new(
- call.id().to_owned(),
- ToolResponses::Query(resp),
- )))
- }
+ // Store the response sender for later (agentic only)
+ if let Some(agentic) = &mut session.agentic {
+ agentic
+ .pending_permissions
+ .insert(pending.request.id, pending.response_tx);
+ }
+
+ // Add the request to chat for UI display
+ session
+ .chat
+ .push(Message::PermissionRequest(pending.request));
+ }
+
+ DaveApiResponse::ToolResult(result) => {
+ tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary);
+ session.chat.push(Message::ToolResult(result));
+ }
+
+ DaveApiResponse::SessionInfo(info) => {
+ tracing::debug!(
+ "Session info: model={:?}, tools={}, agents={}",
+ info.model,
+ info.tools.len(),
+ info.agents.len()
+ );
+ if let Some(agentic) = &mut session.agentic {
+ agentic.session_info = Some(info);
+ }
+ }
+
+ DaveApiResponse::SubagentSpawned(subagent) => {
+ tracing::debug!(
+ "Subagent spawned: {} ({}) - {}",
+ subagent.task_id,
+ subagent.subagent_type,
+ subagent.description
+ );
+ let task_id = subagent.task_id.clone();
+ let idx = session.chat.len();
+ session.chat.push(Message::Subagent(subagent));
+ if let Some(agentic) = &mut session.agentic {
+ agentic.subagent_indices.insert(task_id, idx);
+ }
+ }
+
+ DaveApiResponse::SubagentOutput { task_id, output } => {
+ session.update_subagent_output(&task_id, &output);
+ }
+
+ DaveApiResponse::SubagentCompleted { task_id, result } => {
+ tracing::debug!("Subagent completed: {}", task_id);
+ session.complete_subagent(&task_id, &result);
+ }
+
+ DaveApiResponse::CompactionStarted => {
+ tracing::debug!("Compaction started for session {}", session_id);
+ if let Some(agentic) = &mut session.agentic {
+ agentic.is_compacting = true;
+ }
+ }
+
+ DaveApiResponse::CompactionComplete(info) => {
+ tracing::debug!(
+ "Compaction completed for session {}: pre_tokens={}",
+ session_id,
+ info.pre_tokens
+ );
+ if let Some(agentic) = &mut session.agentic {
+ agentic.is_compacting = false;
+ agentic.last_compaction = Some(info.clone());
}
+ session.chat.push(Message::CompactionComplete(info));
}
}
}
- }
- // Put the receiver back
- if let Some(session) = self.session_manager.get_active_mut() {
- session.incoming_tokens = Some(recvr);
+ // Check if channel is disconnected (stream ended)
+ match recvr.try_recv() {
+ Err(std::sync::mpsc::TryRecvError::Disconnected) => {
+ // Stream ended, clear task state
+ if let Some(session) = self.session_manager.get_mut(session_id) {
+ session.task_handle = None;
+ // Don't restore incoming_tokens - leave it None
+ }
+ }
+ _ => {
+ // Channel still open, put receiver back
+ if let Some(session) = self.session_manager.get_mut(session_id) {
+ session.incoming_tokens = Some(recvr);
+ }
+ }
+ }
}
- should_send
+ needs_send
}
fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
+ // Check overlays first - they take over the entire UI
+ match self.active_overlay {
+ DaveOverlay::Settings => {
+ match ui::settings_overlay_ui(&mut self.settings_panel, &self.settings, ui) {
+ OverlayResult::ApplySettings(new_settings) => {
+ self.apply_settings(new_settings.clone());
+ self.active_overlay = DaveOverlay::None;
+ return DaveResponse::new(DaveAction::UpdateSettings(new_settings));
+ }
+ OverlayResult::Close => {
+ self.active_overlay = DaveOverlay::None;
+ }
+ _ => {}
+ }
+ return DaveResponse::default();
+ }
+ DaveOverlay::DirectoryPicker => {
+ let has_sessions = !self.session_manager.is_empty();
+ match ui::directory_picker_overlay_ui(&mut self.directory_picker, has_sessions, ui)
+ {
+ OverlayResult::DirectorySelected(path) => {
+ self.create_session_with_cwd(path);
+ self.active_overlay = DaveOverlay::None;
+ }
+ OverlayResult::ShowSessionPicker(path) => {
+ self.session_picker.open(path);
+ self.active_overlay = DaveOverlay::SessionPicker;
+ }
+ OverlayResult::Close => {
+ self.active_overlay = DaveOverlay::None;
+ }
+ _ => {}
+ }
+ return DaveResponse::default();
+ }
+ DaveOverlay::SessionPicker => {
+ match ui::session_picker_overlay_ui(&mut self.session_picker, ui) {
+ OverlayResult::ResumeSession {
+ cwd,
+ session_id,
+ title,
+ } => {
+ self.create_resumed_session_with_cwd(cwd, session_id, title);
+ self.session_picker.close();
+ self.active_overlay = DaveOverlay::None;
+ }
+ OverlayResult::NewSession { cwd } => {
+ self.create_session_with_cwd(cwd);
+ self.session_picker.close();
+ self.active_overlay = DaveOverlay::None;
+ }
+ OverlayResult::BackToDirectoryPicker => {
+ self.session_picker.close();
+ self.active_overlay = DaveOverlay::DirectoryPicker;
+ }
+ _ => {}
+ }
+ return DaveResponse::default();
+ }
+ DaveOverlay::None => {}
+ }
+
+ // Normal routing
if is_narrow(ui.ctx()) {
self.narrow_ui(app_ctx, ui)
+ } else if self.show_scene {
+ self.scene_ui(app_ctx, ui)
} else {
self.desktop_ui(app_ctx, ui)
}
}
- /// Desktop layout with sidebar for session list
- fn desktop_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
- let available = ui.available_rect_before_wrap();
- let sidebar_width = 280.0;
-
- let sidebar_rect =
- egui::Rect::from_min_size(available.min, egui::vec2(sidebar_width, available.height()));
- let chat_rect = egui::Rect::from_min_size(
- egui::pos2(available.min.x + sidebar_width, available.min.y),
- egui::vec2(available.width() - sidebar_width, available.height()),
+ /// Scene view with RTS-style agent visualization and chat side panel
+ fn scene_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
+ let is_interrupt_pending = self.is_interrupt_pending();
+ let (dave_response, view_action) = ui::scene_ui(
+ &mut self.session_manager,
+ &mut self.scene,
+ &self.focus_queue,
+ &self.model_config,
+ is_interrupt_pending,
+ self.auto_steal_focus,
+ app_ctx,
+ ui,
);
- // Render sidebar first - borrow released after this
- let session_action = ui
- .allocate_new_ui(egui::UiBuilder::new().max_rect(sidebar_rect), |ui| {
- egui::Frame::new()
- .fill(ui.visuals().faint_bg_color)
- .inner_margin(egui::Margin::symmetric(8, 12))
- .show(ui, |ui| SessionListUi::new(&self.session_manager).ui(ui))
- .inner
- })
- .inner;
-
- // Now we can mutably borrow for chat
- let chat_response = ui
- .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| {
- if let Some(session) = self.session_manager.get_active_mut() {
- DaveUi::new(self.model_config.trial, &session.chat, &mut session.input)
- .ui(app_ctx, ui)
+ // Handle view actions
+ match view_action {
+ SceneViewAction::ToggleToListView => {
+ self.show_scene = false;
+ }
+ SceneViewAction::SpawnAgent => {
+ return DaveResponse::new(DaveAction::NewChat);
+ }
+ SceneViewAction::DeleteSelected(ids) => {
+ for id in ids {
+ self.delete_session(id);
+ }
+ if let Some(session) = self.session_manager.sessions_ordered().first() {
+ self.scene.select(session.id);
} else {
- DaveResponse::default()
+ self.scene.clear_selection();
}
- })
- .inner;
+ }
+ SceneViewAction::None => {}
+ }
+
+ dave_response
+ }
+
+ /// Desktop layout with sidebar for session list
+ fn desktop_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
+ let is_interrupt_pending = self.is_interrupt_pending();
+ let (chat_response, session_action, toggle_scene) = ui::desktop_ui(
+ &mut self.session_manager,
+ &self.focus_queue,
+ &self.model_config,
+ is_interrupt_pending,
+ self.auto_steal_focus,
+ self.ai_mode,
+ app_ctx,
+ ui,
+ );
+
+ if toggle_scene {
+ self.show_scene = true;
+ }
- // Handle actions after rendering
if let Some(action) = session_action {
match action {
SessionListAction::NewSession => return DaveResponse::new(DaveAction::NewChat),
@@ -250,7 +548,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
self.session_manager.switch_to(id);
}
SessionListAction::Delete(id) => {
- self.session_manager.delete_session(id);
+ self.delete_session(id);
}
}
}
@@ -260,190 +558,299 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
/// Narrow/mobile layout - shows either session list or chat
fn narrow_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
- if self.show_session_list {
- // Show session list
- let session_action = egui::Frame::new()
- .fill(ui.visuals().faint_bg_color)
- .inner_margin(egui::Margin::symmetric(8, 12))
- .show(ui, |ui| SessionListUi::new(&self.session_manager).ui(ui))
- .inner;
- if let Some(action) = session_action {
- match action {
- SessionListAction::NewSession => {
- self.session_manager.new_session();
- self.show_session_list = false;
- }
- SessionListAction::SwitchTo(id) => {
- self.session_manager.switch_to(id);
- self.show_session_list = false;
- }
- SessionListAction::Delete(id) => {
- self.session_manager.delete_session(id);
- }
+ let is_interrupt_pending = self.is_interrupt_pending();
+ let (dave_response, session_action) = ui::narrow_ui(
+ &mut self.session_manager,
+ &self.focus_queue,
+ &self.model_config,
+ is_interrupt_pending,
+ self.auto_steal_focus,
+ self.ai_mode,
+ self.show_session_list,
+ app_ctx,
+ ui,
+ );
+
+ if let Some(action) = session_action {
+ match action {
+ SessionListAction::NewSession => {
+ self.handle_new_chat();
+ self.show_session_list = false;
+ }
+ SessionListAction::SwitchTo(id) => {
+ self.session_manager.switch_to(id);
+ self.show_session_list = false;
+ }
+ SessionListAction::Delete(id) => {
+ self.delete_session(id);
}
- }
- DaveResponse::default()
- } else {
- // Show chat
- if let Some(session) = self.session_manager.get_active_mut() {
- DaveUi::new(self.model_config.trial, &session.chat, &mut session.input)
- .ui(app_ctx, ui)
- } else {
- DaveResponse::default()
}
}
+
+ dave_response
}
fn handle_new_chat(&mut self) {
- self.session_manager.new_session();
+ // Show the directory picker overlay
+ self.active_overlay = DaveOverlay::DirectoryPicker;
}
- /// Handle a user send action triggered by the ui
- fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) {
- if let Some(session) = self.session_manager.get_active_mut() {
- session.chat.push(Message::User(session.input.clone()));
- session.input.clear();
- session.update_title_from_first_message();
- }
- self.send_user_message(app_ctx, ui.ctx());
+ /// Create a new session with the given cwd (called after directory picker selection)
+ fn create_session_with_cwd(&mut self, cwd: PathBuf) {
+ update::create_session_with_cwd(
+ &mut self.session_manager,
+ &mut self.directory_picker,
+ &mut self.scene,
+ self.show_scene,
+ self.ai_mode,
+ cwd,
+ );
}
- fn send_user_message(&mut self, app_ctx: &AppContext, ctx: &egui::Context) {
- let Some(session) = self.session_manager.get_active_mut() else {
- return;
- };
+ /// Create a new session that resumes an existing Claude conversation
+ fn create_resumed_session_with_cwd(
+ &mut self,
+ cwd: PathBuf,
+ resume_session_id: String,
+ title: String,
+ ) {
+ update::create_resumed_session_with_cwd(
+ &mut self.session_manager,
+ &mut self.directory_picker,
+ &mut self.scene,
+ self.show_scene,
+ self.ai_mode,
+ cwd,
+ resume_session_id,
+ title,
+ );
+ }
- let messages: Vec<ChatCompletionRequestMessage> = {
- let txn = Transaction::new(app_ctx.ndb).expect("txn");
- session
- .chat
- .iter()
- .filter_map(|c| c.to_api_msg(&txn, app_ctx.ndb))
- .collect()
- };
- tracing::debug!("sending messages, latest: {:?}", messages.last().unwrap());
+ /// Clone the active agent, creating a new session with the same working directory
+ fn clone_active_agent(&mut self) {
+ update::clone_active_agent(
+ &mut self.session_manager,
+ &mut self.directory_picker,
+ &mut self.scene,
+ self.show_scene,
+ self.ai_mode,
+ );
+ }
- let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair());
+ /// Poll for IPC spawn-agent commands from external tools
+ fn poll_ipc_commands(&mut self) {
+ let Some(listener) = self.ipc_listener.as_ref() else {
+ return;
+ };
- let ctx = ctx.clone();
- let client = self.client.clone();
- let tools = self.tools.clone();
- let model_name = self.model_config.model().to_owned();
+ // Drain all pending connections (non-blocking)
+ while let Some(mut pending) = listener.try_recv() {
+ // Create the session and get its ID
+ let id = self
+ .session_manager
+ .new_session(pending.cwd.clone(), self.ai_mode);
+ self.directory_picker.add_recent(pending.cwd);
+
+ // Focus on new session
+ if let Some(session) = self.session_manager.get_mut(id) {
+ session.focus_requested = true;
+ if self.show_scene {
+ self.scene.select(id);
+ if let Some(agentic) = &session.agentic {
+ self.scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
- let (tx, rx) = mpsc::channel();
- session.incoming_tokens = Some(rx);
+ // Close directory picker if open
+ if self.active_overlay == DaveOverlay::DirectoryPicker {
+ self.active_overlay = DaveOverlay::None;
+ }
- tokio::spawn(async move {
- let mut token_stream = match client
- .chat()
- .create_stream(CreateChatCompletionRequest {
- model: model_name,
- stream: Some(true),
- messages,
- tools: Some(tools::dave_tools().iter().map(|t| t.to_api()).collect()),
- user: Some(user_id),
- ..Default::default()
- })
- .await
+ // Send success response back to the client
+ #[cfg(unix)]
{
- Err(err) => {
- tracing::error!("openai chat error: {err}");
- return;
- }
+ let response = ipc::SpawnResponse::ok(id);
+ let _ = ipc::send_response(&mut pending.stream, &response);
+ }
- Ok(stream) => stream,
- };
+ tracing::info!("Spawned agent via IPC (session {})", id);
+ }
+ }
- let mut all_tool_calls: HashMap<u32, PartialToolCall> = HashMap::new();
+ /// Delete a session and clean up backend resources
+ fn delete_session(&mut self, id: SessionId) {
+ update::delete_session(
+ &mut self.session_manager,
+ &mut self.focus_queue,
+ self.backend.as_ref(),
+ &mut self.directory_picker,
+ id,
+ );
+ }
- while let Some(token) = token_stream.next().await {
- let token = match token {
- Ok(token) => token,
- Err(err) => {
- tracing::error!("failed to get token: {err}");
- let _ = tx.send(DaveApiResponse::Failed(err.to_string()));
- return;
- }
- };
+ /// Handle an interrupt request - requires double-Escape to confirm
+ fn handle_interrupt_request(&mut self, ctx: &egui::Context) {
+ self.interrupt_pending_since = update::handle_interrupt_request(
+ &self.session_manager,
+ self.backend.as_ref(),
+ self.interrupt_pending_since,
+ ctx,
+ );
+ }
- for choice in &token.choices {
- let resp = &choice.delta;
+ /// Check if interrupt confirmation has timed out and clear it
+ fn check_interrupt_timeout(&mut self) {
+ self.interrupt_pending_since =
+ update::check_interrupt_timeout(self.interrupt_pending_since);
+ }
- // if we have tool call arg chunks, collect them here
- if let Some(tool_calls) = &resp.tool_calls {
- for tool in tool_calls {
- let entry = all_tool_calls.entry(tool.index).or_default();
+ /// Returns true if an interrupt is pending confirmation
+ pub fn is_interrupt_pending(&self) -> bool {
+ self.interrupt_pending_since.is_some()
+ }
- if let Some(id) = &tool.id {
- entry.id_mut().get_or_insert(id.clone());
- }
+ /// Get the first pending permission request ID for the active session
+ fn first_pending_permission(&self) -> Option<uuid::Uuid> {
+ update::first_pending_permission(&self.session_manager)
+ }
- if let Some(name) = tool.function.as_ref().and_then(|f| f.name.as_ref())
- {
- entry.name_mut().get_or_insert(name.to_string());
- }
+ /// Check if the first pending permission is an AskUserQuestion tool call
+ fn has_pending_question(&self) -> bool {
+ update::has_pending_question(&self.session_manager)
+ }
- if let Some(argchunk) =
- tool.function.as_ref().and_then(|f| f.arguments.as_ref())
- {
- entry
- .arguments_mut()
- .get_or_insert_with(String::new)
- .push_str(argchunk);
- }
- }
- }
+ /// Handle a keybinding action
+ fn handle_key_action(&mut self, key_action: KeyAction, ui: &egui::Ui) {
+ match ui::handle_key_action(
+ key_action,
+ &mut self.session_manager,
+ &mut self.scene,
+ &mut self.focus_queue,
+ self.backend.as_ref(),
+ self.show_scene,
+ self.auto_steal_focus,
+ &mut self.home_session,
+ &mut self.active_overlay,
+ ui.ctx(),
+ ) {
+ KeyActionResult::ToggleView => {
+ self.show_scene = !self.show_scene;
+ }
+ KeyActionResult::HandleInterrupt => {
+ self.handle_interrupt_request(ui.ctx());
+ }
+ KeyActionResult::CloneAgent => {
+ self.clone_active_agent();
+ }
+ KeyActionResult::DeleteSession(id) => {
+ self.delete_session(id);
+ }
+ KeyActionResult::SetAutoSteal(new_state) => {
+ self.auto_steal_focus = new_state;
+ }
+ KeyActionResult::None => {}
+ }
+ }
- if let Some(content) = &resp.content {
- if let Err(err) = tx.send(DaveApiResponse::Token(content.to_owned())) {
- tracing::error!("failed to send dave response token to ui: {err}");
- }
- ctx.request_repaint();
- }
- }
+ /// Handle the Send action, including tentative permission states
+ fn handle_send_action(&mut self, ctx: &AppContext, ui: &egui::Ui) {
+ match ui::handle_send_action(&mut self.session_manager, self.backend.as_ref(), ui.ctx()) {
+ SendActionResult::SendMessage => {
+ self.handle_user_send(ctx, ui);
}
+ SendActionResult::Handled => {}
+ }
+ }
- let mut parsed_tool_calls = vec![];
- for (_index, partial) in all_tool_calls {
- let Some(unknown_tool_call) = partial.complete() else {
- tracing::error!("could not complete partial tool call: {:?}", partial);
- continue;
- };
+ /// Handle a UI action from DaveUi
+ fn handle_ui_action(
+ &mut self,
+ action: DaveAction,
+ ctx: &AppContext,
+ ui: &egui::Ui,
+ ) -> Option<AppAction> {
+ match ui::handle_ui_action(
+ action,
+ &mut self.session_manager,
+ self.backend.as_ref(),
+ &mut self.active_overlay,
+ &mut self.show_session_list,
+ ui.ctx(),
+ ) {
+ UiActionResult::AppAction(app_action) => Some(app_action),
+ UiActionResult::SendAction => {
+ self.handle_send_action(ctx, ui);
+ None
+ }
+ UiActionResult::Handled => None,
+ }
+ }
- match unknown_tool_call.parse(&tools) {
- Ok(tool_call) => {
- parsed_tool_calls.push(tool_call);
- }
- Err(err) => {
- // TODO: we should be
- tracing::error!(
- "failed to parse tool call {:?}: {}",
- unknown_tool_call,
- err,
- );
+ /// Handle a user send action triggered by the ui
+ fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) {
+ // Check for /cd command first (agentic only)
+ let cd_result = self
+ .session_manager
+ .get_active_mut()
+ .and_then(update::handle_cd_command);
+
+ // If /cd command was processed, add to recent directories
+ if let Some(Ok(path)) = cd_result {
+ self.directory_picker.add_recent(path);
+ return;
+ } else if cd_result.is_some() {
+ // Error case - already handled above
+ return;
+ }
- if let Some(id) = partial.id() {
- // we have an id, so we can communicate the error
- // back to the ai
- parsed_tool_calls.push(ToolCall::invalid(
- id.to_string(),
- partial.name,
- partial.arguments,
- err.to_string(),
- ));
- }
- }
- };
- }
+ // Normal message handling
+ if let Some(session) = self.session_manager.get_active_mut() {
+ session.chat.push(Message::User(session.input.clone()));
+ session.input.clear();
+ session.update_title_from_last_message();
+ }
+ self.send_user_message(app_ctx, ui.ctx());
+ }
- if !parsed_tool_calls.is_empty() {
- tx.send(DaveApiResponse::ToolCalls(parsed_tool_calls))
- .unwrap();
- ctx.request_repaint();
- }
+ fn send_user_message(&mut self, app_ctx: &AppContext, ctx: &egui::Context) {
+ let Some(active_id) = self.session_manager.active_id() else {
+ return;
+ };
+ self.send_user_message_for(active_id, app_ctx, ctx);
+ }
+
+ /// Send a message for a specific session by ID
+ fn send_user_message_for(&mut self, sid: SessionId, app_ctx: &AppContext, ctx: &egui::Context) {
+ let Some(session) = self.session_manager.get_mut(sid) else {
+ return;
+ };
+
+ let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair());
+ let session_id = format!("dave-session-{}", session.id);
+ let messages = session.chat.clone();
+ let cwd = session.agentic.as_ref().map(|a| a.cwd.clone());
+ let resume_session_id = session
+ .agentic
+ .as_ref()
+ .and_then(|a| a.resume_session_id.clone());
+ let tools = self.tools.clone();
+ let model_name = self.model_config.model().to_owned();
+ let ctx = ctx.clone();
- tracing::debug!("stream closed");
- });
+ // Use backend to stream request
+ let (rx, task_handle) = self.backend.stream_request(
+ messages,
+ tools,
+ model_name,
+ user_id,
+ session_id,
+ cwd,
+ resume_session_id,
+ ctx,
+ );
+ session.incoming_tokens = Some(rx);
+ session.task_handle = task_handle;
}
}
@@ -451,37 +858,64 @@ impl notedeck::App for Dave {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
let mut app_action: Option<AppAction> = None;
- // always insert system prompt if we have no context in active session
- if let Some(session) = self.session_manager.get_active_mut() {
- if session.chat.is_empty() {
- session.chat.push(Dave::system_prompt());
- }
+ // Poll for external spawn-agent commands via IPC
+ self.poll_ipc_commands();
+
+ // Poll for external editor completion
+ update::poll_editor_job(&mut self.session_manager);
+
+ // Handle global keybindings (when no text input has focus)
+ let has_pending_permission = self.first_pending_permission().is_some();
+ let has_pending_question = self.has_pending_question();
+ let in_tentative_state = self
+ .session_manager
+ .get_active()
+ .and_then(|s| s.agentic.as_ref())
+ .map(|a| a.permission_message_state != crate::session::PermissionMessageState::None)
+ .unwrap_or(false);
+ if let Some(key_action) = check_keybindings(
+ ui.ctx(),
+ has_pending_permission,
+ has_pending_question,
+ in_tentative_state,
+ self.ai_mode,
+ ) {
+ self.handle_key_action(key_action, ui);
}
- //update_dave(self, ctx, ui.ctx());
- let should_send = self.process_events(ctx);
+ // Check if interrupt confirmation has timed out
+ self.check_interrupt_timeout();
+
+ // Process incoming AI responses for all sessions
+ let sessions_needing_send = self.process_events(ctx);
+
+ // Update all session statuses after processing events
+ self.session_manager.update_all_statuses();
+
+ // Update focus queue based on status changes
+ let status_iter = self.session_manager.iter().map(|s| (s.id, s.status()));
+ self.focus_queue.update_from_statuses(status_iter);
+
+ // Process auto-steal focus mode
+ update::process_auto_steal_focus(
+ &mut self.session_manager,
+ &mut self.focus_queue,
+ &mut self.scene,
+ self.show_scene,
+ self.auto_steal_focus,
+ &mut self.home_session,
+ );
+
+ // Render UI and handle actions
if let Some(action) = self.ui(ctx, ui).action {
- match action {
- DaveAction::ToggleChrome => {
- app_action = Some(AppAction::ToggleChrome);
- }
- DaveAction::Note(n) => {
- app_action = Some(AppAction::Note(n));
- }
- DaveAction::NewChat => {
- self.handle_new_chat();
- }
- DaveAction::Send => {
- self.handle_user_send(ctx, ui);
- }
- DaveAction::ShowSessionList => {
- self.show_session_list = !self.show_session_list;
- }
+ if let Some(returned_action) = self.handle_ui_action(action, ctx, ui) {
+ app_action = Some(returned_action);
}
}
- if should_send {
- self.send_user_message(ctx, ui.ctx());
+ // Send continuation messages for all sessions that have tool responses
+ for session_id in sessions_needing_send {
+ self.send_user_message_for(session_id, ctx, ui.ctx());
}
AppResponse::action(app_action)
diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs
@@ -1,6 +1,152 @@
use crate::tools::{ToolCall, ToolResponse};
use async_openai::types::*;
use nostrdb::{Ndb, Transaction};
+use serde::{Deserialize, Serialize};
+use tokio::sync::oneshot;
+use uuid::Uuid;
+
+/// A question option from AskUserQuestion
+#[derive(Debug, Clone, Deserialize)]
+pub struct QuestionOption {
+ pub label: String,
+ pub description: String,
+}
+
+/// A single question from AskUserQuestion
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UserQuestion {
+ pub question: String,
+ pub header: String,
+ #[serde(default)]
+ pub multi_select: bool,
+ pub options: Vec<QuestionOption>,
+}
+
+/// Parsed AskUserQuestion tool input
+#[derive(Debug, Clone, Deserialize)]
+pub struct AskUserQuestionInput {
+ pub questions: Vec<UserQuestion>,
+}
+
+/// User's answer to a question
+#[derive(Debug, Clone, Default, Serialize)]
+pub struct QuestionAnswer {
+ /// Selected option indices
+ pub selected: Vec<usize>,
+ /// Custom "Other" text if provided
+ pub other_text: Option<String>,
+}
+
+/// A request for user permission to use a tool (displayable data only)
+#[derive(Debug, Clone)]
+pub struct PermissionRequest {
+ /// Unique identifier for this permission request
+ pub id: Uuid,
+ /// The tool that wants to be used
+ pub tool_name: String,
+ /// The arguments the tool will be called with
+ pub tool_input: serde_json::Value,
+ /// The user's response (None if still pending)
+ pub response: Option<PermissionResponseType>,
+ /// For AskUserQuestion: pre-computed summary of answers for display
+ pub answer_summary: Option<AnswerSummary>,
+}
+
+/// A single entry in an answer summary
+#[derive(Debug, Clone)]
+pub struct AnswerSummaryEntry {
+ /// The question header (e.g., "Library", "Approach")
+ pub header: String,
+ /// The selected answer text, comma-separated if multiple
+ pub answer: String,
+}
+
+/// Pre-computed summary of an AskUserQuestion response for display
+#[derive(Debug, Clone)]
+pub struct AnswerSummary {
+ pub entries: Vec<AnswerSummaryEntry>,
+}
+
+/// A permission request with the response channel (for channel communication)
+pub struct PendingPermission {
+ /// The displayable request data
+ pub request: PermissionRequest,
+ /// Channel to send the user's response back
+ pub response_tx: oneshot::Sender<PermissionResponse>,
+}
+
+/// The user's response to a permission request
+#[derive(Debug, Clone)]
+pub enum PermissionResponse {
+ /// Allow the tool to execute, with an optional message for the AI
+ Allow { message: Option<String> },
+ /// Deny the tool execution with a reason
+ Deny { reason: String },
+}
+
+/// The recorded response type for display purposes (without channel details)
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PermissionResponseType {
+ Allowed,
+ Denied,
+}
+
+/// Metadata about a completed tool execution
+#[derive(Debug, Clone)]
+pub struct ToolResult {
+ pub tool_name: String,
+ pub summary: String, // e.g., "154 lines", "exit 0", "3 matches"
+}
+
+/// Session initialization info from Claude Code CLI
+#[derive(Debug, Clone, Default)]
+pub struct SessionInfo {
+ /// Available tools in this session
+ pub tools: Vec<String>,
+ /// Model being used (e.g., "claude-opus-4-5-20251101")
+ pub model: Option<String>,
+ /// Permission mode (e.g., "default", "plan")
+ pub permission_mode: Option<String>,
+ /// Available slash commands
+ pub slash_commands: Vec<String>,
+ /// Available agent types for Task tool
+ pub agents: Vec<String>,
+ /// Claude Code CLI version
+ pub cli_version: Option<String>,
+ /// Current working directory
+ pub cwd: Option<String>,
+ /// Session ID from Claude Code
+ pub claude_session_id: Option<String>,
+}
+
+/// Status of a subagent spawned by the Task tool
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SubagentStatus {
+ /// Subagent is running
+ Running,
+ /// Subagent completed successfully
+ Completed,
+ /// Subagent failed with an error
+ Failed,
+}
+
+/// Information about a subagent spawned by the Task tool
+#[derive(Debug, Clone)]
+pub struct SubagentInfo {
+ /// Unique ID for this subagent task
+ pub task_id: String,
+ /// Description of what the subagent is doing
+ pub description: String,
+ /// Type of subagent (e.g., "Explore", "Plan", "Bash")
+ pub subagent_type: String,
+ /// Current status
+ pub status: SubagentStatus,
+ /// Output content (truncated for display)
+ pub output: String,
+ /// Maximum output size to keep (for size-restricted window)
+ pub max_output_size: usize,
+}
#[derive(Debug, Clone)]
pub enum Message {
@@ -10,6 +156,21 @@ pub enum Message {
Assistant(String),
ToolCalls(Vec<ToolCall>),
ToolResponse(ToolResponse),
+ /// A permission request from the AI that needs user response
+ PermissionRequest(PermissionRequest),
+ /// Result metadata from a completed tool execution
+ ToolResult(ToolResult),
+ /// Conversation was compacted
+ CompactionComplete(CompactionInfo),
+ /// A subagent spawned by Task tool
+ Subagent(SubagentInfo),
+}
+
+/// Compaction info from compact_boundary system message
+#[derive(Debug, Clone)]
+pub struct CompactionInfo {
+ /// Number of tokens before compaction
+ pub pre_tokens: u64,
}
/// The ai backends response. Since we are using streaming APIs these are
@@ -18,6 +179,28 @@ pub enum DaveApiResponse {
ToolCalls(Vec<ToolCall>),
Token(String),
Failed(String),
+ /// A permission request that needs to be displayed to the user
+ PermissionRequest(PendingPermission),
+ /// Metadata from a completed tool execution
+ ToolResult(ToolResult),
+ /// Session initialization info from Claude Code CLI
+ SessionInfo(SessionInfo),
+ /// Subagent spawned by Task tool
+ SubagentSpawned(SubagentInfo),
+ /// Subagent output update
+ SubagentOutput {
+ task_id: String,
+ output: String,
+ },
+ /// Subagent completed
+ SubagentCompleted {
+ task_id: String,
+ result: String,
+ },
+ /// Conversation compaction started
+ CompactionStarted,
+ /// Conversation compaction completed with token info
+ CompactionComplete(CompactionInfo),
}
impl Message {
@@ -69,6 +252,18 @@ impl Message {
},
))
}
+
+ // Permission requests are UI-only, not sent to the API
+ Message::PermissionRequest(_) => None,
+
+ // Tool results are UI-only, not sent to the API
+ Message::ToolResult(_) => None,
+
+ // Compaction complete is UI-only, not sent to the API
+ Message::CompactionComplete(_) => None,
+
+ // Subagent info is UI-only, not sent to the API
+ Message::Subagent(_) => None,
}
}
}
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -1,10 +1,113 @@
use std::collections::HashMap;
+use std::path::PathBuf;
use std::sync::mpsc::Receiver;
+use crate::agent_status::AgentStatus;
+use crate::config::AiMode;
+use crate::messages::{
+ CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentStatus,
+};
use crate::{DaveApiResponse, Message};
+use claude_agent_sdk_rs::PermissionMode;
+use tokio::sync::oneshot;
+use uuid::Uuid;
pub type SessionId = u32;
+/// State for permission response with message
+#[derive(Default, Clone, Copy, PartialEq)]
+pub enum PermissionMessageState {
+ #[default]
+ None,
+ /// User pressed Shift+1, waiting for message then will Allow
+ TentativeAccept,
+ /// User pressed Shift+2, waiting for message then will Deny
+ TentativeDeny,
+}
+
+/// Agentic-mode specific session data (Claude backend only)
+pub struct AgenticSessionData {
+ /// Pending permission requests waiting for user response
+ pub pending_permissions: HashMap<Uuid, oneshot::Sender<PermissionResponse>>,
+ /// Position in the RTS scene (in scene coordinates)
+ pub scene_position: egui::Vec2,
+ /// Permission mode for Claude (Default or Plan)
+ pub permission_mode: PermissionMode,
+ /// State for permission response message (tentative accept/deny)
+ pub permission_message_state: PermissionMessageState,
+ /// State for pending AskUserQuestion responses (keyed by request UUID)
+ pub question_answers: HashMap<Uuid, Vec<QuestionAnswer>>,
+ /// Current question index for multi-question AskUserQuestion (keyed by request UUID)
+ pub question_index: HashMap<Uuid, usize>,
+ /// Working directory for claude-code subprocess
+ pub cwd: PathBuf,
+ /// Session info from Claude Code CLI (tools, model, agents, etc.)
+ pub session_info: Option<SessionInfo>,
+ /// Indices of subagent messages in chat (keyed by task_id)
+ pub subagent_indices: HashMap<String, usize>,
+ /// Whether conversation compaction is in progress
+ pub is_compacting: bool,
+ /// Info from the last completed compaction (for display)
+ pub last_compaction: Option<CompactionInfo>,
+ /// Claude session ID to resume (UUID from Claude CLI's session storage)
+ /// When set, the backend will use --resume to continue this session
+ pub resume_session_id: Option<String>,
+}
+
+impl AgenticSessionData {
+ pub fn new(id: SessionId, cwd: PathBuf) -> Self {
+ // Arrange sessions in a grid pattern
+ let col = (id as i32 - 1) % 4;
+ let row = (id as i32 - 1) / 4;
+ let x = col as f32 * 150.0 - 225.0; // Center around origin
+ let y = row as f32 * 150.0 - 75.0;
+
+ AgenticSessionData {
+ pending_permissions: HashMap::new(),
+ scene_position: egui::Vec2::new(x, y),
+ permission_mode: PermissionMode::Default,
+ permission_message_state: PermissionMessageState::None,
+ question_answers: HashMap::new(),
+ question_index: HashMap::new(),
+ cwd,
+ session_info: None,
+ subagent_indices: HashMap::new(),
+ is_compacting: false,
+ last_compaction: None,
+ resume_session_id: None,
+ }
+ }
+
+ /// Update a subagent's output (appending new content, keeping only the tail)
+ pub fn update_subagent_output(
+ &mut self,
+ chat: &mut [Message],
+ task_id: &str,
+ new_output: &str,
+ ) {
+ if let Some(&idx) = self.subagent_indices.get(task_id) {
+ if let Some(Message::Subagent(subagent)) = chat.get_mut(idx) {
+ subagent.output.push_str(new_output);
+ // Keep only the most recent content up to max_output_size
+ if subagent.output.len() > subagent.max_output_size {
+ let keep_from = subagent.output.len() - subagent.max_output_size;
+ subagent.output = subagent.output[keep_from..].to_string();
+ }
+ }
+ }
+ }
+
+ /// Mark a subagent as completed
+ pub fn complete_subagent(&mut self, chat: &mut [Message], task_id: &str, result: &str) {
+ if let Some(&idx) = self.subagent_indices.get(task_id) {
+ if let Some(Message::Subagent(subagent)) = chat.get_mut(idx) {
+ subagent.status = SubagentStatus::Completed;
+ subagent.output = result.to_string();
+ }
+ }
+ }
+}
+
/// A single chat session with Dave
pub struct ChatSession {
pub id: SessionId,
@@ -12,42 +115,198 @@ pub struct ChatSession {
pub chat: Vec<Message>,
pub input: String,
pub incoming_tokens: Option<Receiver<DaveApiResponse>>,
+ /// Handle to the background task processing this session's AI requests.
+ /// Aborted on drop to clean up the subprocess.
+ pub task_handle: Option<tokio::task::JoinHandle<()>>,
+ /// Cached status for the agent (derived from session state)
+ cached_status: AgentStatus,
+ /// Whether this session's input should be focused on the next frame
+ pub focus_requested: bool,
+ /// AI interaction mode for this session (Chat vs Agentic)
+ pub ai_mode: AiMode,
+ /// Agentic-mode specific data (None in Chat mode)
+ pub agentic: Option<AgenticSessionData>,
+}
+
+impl Drop for ChatSession {
+ fn drop(&mut self) {
+ if let Some(handle) = self.task_handle.take() {
+ handle.abort();
+ }
+ }
}
impl ChatSession {
- pub fn new(id: SessionId) -> Self {
+ pub fn new(id: SessionId, cwd: PathBuf, ai_mode: AiMode) -> Self {
+ let agentic = match ai_mode {
+ AiMode::Agentic => Some(AgenticSessionData::new(id, cwd)),
+ AiMode::Chat => None,
+ };
+
ChatSession {
id,
title: "New Chat".to_string(),
chat: vec![],
input: String::new(),
incoming_tokens: None,
+ task_handle: None,
+ cached_status: AgentStatus::Idle,
+ focus_requested: false,
+ ai_mode,
+ agentic,
+ }
+ }
+
+ /// Create a new session that resumes an existing Claude conversation
+ pub fn new_resumed(
+ id: SessionId,
+ cwd: PathBuf,
+ resume_session_id: String,
+ title: String,
+ ai_mode: AiMode,
+ ) -> Self {
+ let mut session = Self::new(id, cwd, ai_mode);
+ if let Some(ref mut agentic) = session.agentic {
+ agentic.resume_session_id = Some(resume_session_id);
+ }
+ session.title = title;
+ session
+ }
+
+ // === Helper methods for accessing agentic data ===
+
+ /// Get agentic data, panics if not in agentic mode (use in agentic-only code paths)
+ pub fn agentic(&self) -> &AgenticSessionData {
+ self.agentic
+ .as_ref()
+ .expect("agentic data only available in Agentic mode")
+ }
+
+ /// Get mutable agentic data
+ pub fn agentic_mut(&mut self) -> &mut AgenticSessionData {
+ self.agentic
+ .as_mut()
+ .expect("agentic data only available in Agentic mode")
+ }
+
+ /// Check if session has agentic capabilities
+ pub fn is_agentic(&self) -> bool {
+ self.agentic.is_some()
+ }
+
+ /// Check if session has pending permission requests
+ pub fn has_pending_permissions(&self) -> bool {
+ self.agentic
+ .as_ref()
+ .is_some_and(|a| !a.pending_permissions.is_empty())
+ }
+
+ /// Check if session is in plan mode
+ pub fn is_plan_mode(&self) -> bool {
+ self.agentic
+ .as_ref()
+ .is_some_and(|a| a.permission_mode == PermissionMode::Plan)
+ }
+
+ /// Get the working directory (agentic only)
+ pub fn cwd(&self) -> Option<&PathBuf> {
+ self.agentic.as_ref().map(|a| &a.cwd)
+ }
+
+ /// Update a subagent's output (appending new content, keeping only the tail)
+ pub fn update_subagent_output(&mut self, task_id: &str, new_output: &str) {
+ if let Some(ref mut agentic) = self.agentic {
+ agentic.update_subagent_output(&mut self.chat, task_id, new_output);
+ }
+ }
+
+ /// Mark a subagent as completed
+ pub fn complete_subagent(&mut self, task_id: &str, result: &str) {
+ if let Some(ref mut agentic) = self.agentic {
+ agentic.complete_subagent(&mut self.chat, task_id, result);
+ }
+ }
+
+ /// Update the session title from the last message (user or assistant)
+ pub fn update_title_from_last_message(&mut self) {
+ for msg in self.chat.iter().rev() {
+ let text = match msg {
+ Message::User(text) | Message::Assistant(text) => text,
+ _ => continue,
+ };
+ // Use first ~30 chars of last message as title
+ let title: String = text.chars().take(30).collect();
+ self.title = if text.len() > 30 {
+ format!("{}...", title)
+ } else {
+ title
+ };
+ break;
}
}
- /// Update the session title from the first user message
- pub fn update_title_from_first_message(&mut self) {
- for msg in &self.chat {
- if let Message::User(text) = msg {
- // Use first ~30 chars of first user message as title
- let title: String = text.chars().take(30).collect();
- self.title = if text.len() > 30 {
- format!("{}...", title)
- } else {
- title
- };
- break;
+ /// Get the current status of this session/agent
+ pub fn status(&self) -> AgentStatus {
+ self.cached_status
+ }
+
+ /// Update the cached status based on current session state
+ pub fn update_status(&mut self) {
+ self.cached_status = self.derive_status();
+ }
+
+ /// Derive status from the current session state
+ fn derive_status(&self) -> AgentStatus {
+ // Check for pending permission requests (needs input) - agentic only
+ if self.has_pending_permissions() {
+ return AgentStatus::NeedsInput;
+ }
+
+ // Check for error in last message
+ if let Some(Message::Error(_)) = self.chat.last() {
+ return AgentStatus::Error;
+ }
+
+ // Check if actively working (has task handle and receiving tokens)
+ if self.task_handle.is_some() && self.incoming_tokens.is_some() {
+ return AgentStatus::Working;
+ }
+
+ // Check if done (has messages and no active task)
+ if !self.chat.is_empty() && self.task_handle.is_none() {
+ // Check if the last meaningful message was from assistant
+ for msg in self.chat.iter().rev() {
+ match msg {
+ Message::Assistant(_) => return AgentStatus::Done,
+ Message::User(_) => return AgentStatus::Idle, // Waiting for response
+ Message::Error(_) => return AgentStatus::Error,
+ _ => continue,
+ }
}
}
+
+ AgentStatus::Idle
}
}
+/// Tracks a pending external editor process
+pub struct EditorJob {
+ /// The spawned editor process
+ pub child: std::process::Child,
+ /// Path to the temp file being edited
+ pub temp_path: PathBuf,
+ /// Session ID that initiated the editor
+ pub session_id: SessionId,
+}
+
/// Manages multiple chat sessions
pub struct SessionManager {
sessions: HashMap<SessionId, ChatSession>,
order: Vec<SessionId>, // Sorted by recency (most recent first)
active: Option<SessionId>,
next_id: SessionId,
+ /// Pending external editor job (only one at a time)
+ pub pending_editor: Option<EditorJob>,
}
impl Default for SessionManager {
@@ -58,23 +317,40 @@ impl Default for SessionManager {
impl SessionManager {
pub fn new() -> Self {
- let mut manager = SessionManager {
+ SessionManager {
sessions: HashMap::new(),
order: Vec::new(),
active: None,
next_id: 1,
- };
- // Start with one session
- manager.new_session();
- manager
+ pending_editor: None,
+ }
}
- /// Create a new session and make it active
- pub fn new_session(&mut self) -> SessionId {
+ /// Create a new session with the given cwd and make it active
+ pub fn new_session(&mut self, cwd: PathBuf, ai_mode: AiMode) -> SessionId {
let id = self.next_id;
self.next_id += 1;
- let session = ChatSession::new(id);
+ let session = ChatSession::new(id, cwd, ai_mode);
+ self.sessions.insert(id, session);
+ self.order.insert(0, id); // Most recent first
+ self.active = Some(id);
+
+ id
+ }
+
+ /// Create a new session that resumes an existing Claude conversation
+ pub fn new_resumed_session(
+ &mut self,
+ cwd: PathBuf,
+ resume_session_id: String,
+ title: String,
+ ai_mode: AiMode,
+ ) -> SessionId {
+ let id = self.next_id;
+ self.next_id += 1;
+
+ let session = ChatSession::new_resumed(id, cwd, resume_session_id, title, ai_mode);
self.sessions.insert(id, session);
self.order.insert(0, id); // Most recent first
self.active = Some(id);
@@ -108,6 +384,9 @@ impl SessionManager {
}
/// Delete a session
+ /// Returns true if the session was deleted, false if it didn't exist.
+ /// If the last session is deleted, active will be None and the caller
+ /// should open the directory picker to create a new session.
pub fn delete_session(&mut self, id: SessionId) -> bool {
if self.sessions.remove(&id).is_some() {
self.order.retain(|&x| x != id);
@@ -115,11 +394,6 @@ impl SessionManager {
// If we deleted the active session, switch to another
if self.active == Some(id) {
self.active = self.order.first().copied();
-
- // If no sessions left, create a new one
- if self.active.is_none() {
- self.new_session();
- }
}
true
} else {
@@ -152,4 +426,46 @@ impl SessionManager {
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
+
+ /// Get a reference to a session by ID
+ pub fn get(&self, id: SessionId) -> Option<&ChatSession> {
+ self.sessions.get(&id)
+ }
+
+ /// Get a mutable reference to a session by ID
+ pub fn get_mut(&mut self, id: SessionId) -> Option<&mut ChatSession> {
+ self.sessions.get_mut(&id)
+ }
+
+ /// Iterate over all sessions
+ pub fn iter(&self) -> impl Iterator<Item = &ChatSession> {
+ self.sessions.values()
+ }
+
+ /// Iterate over all sessions mutably
+ pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut ChatSession> {
+ self.sessions.values_mut()
+ }
+
+ /// Update status for all sessions
+ pub fn update_all_statuses(&mut self) {
+ for session in self.sessions.values_mut() {
+ session.update_status();
+ }
+ }
+
+ /// Get the first session that needs attention (NeedsInput status)
+ pub fn find_needs_attention(&self) -> Option<SessionId> {
+ for session in self.sessions.values() {
+ if session.status() == AgentStatus::NeedsInput {
+ return Some(session.id);
+ }
+ }
+ None
+ }
+
+ /// Get all session IDs
+ pub fn session_ids(&self) -> Vec<SessionId> {
+ self.order.clone()
+ }
}
diff --git a/crates/notedeck_dave/src/session_discovery.rs b/crates/notedeck_dave/src/session_discovery.rs
@@ -0,0 +1,243 @@
+//! Discovers resumable Claude Code sessions from the filesystem.
+//!
+//! Claude Code stores session data in ~/.claude/projects/<project-path>/
+//! where <project-path> is the cwd with slashes replaced by dashes and leading slash removed.
+
+use serde::Deserialize;
+use std::fs::{self, File};
+use std::io::{BufRead, BufReader};
+use std::path::{Path, PathBuf};
+
+/// Information about a resumable Claude session
+#[derive(Debug, Clone)]
+pub struct ResumableSession {
+ /// The UUID session identifier used by Claude CLI
+ pub session_id: String,
+ /// Path to the session JSONL file
+ pub file_path: PathBuf,
+ /// Timestamp of the most recent message
+ pub last_timestamp: chrono::DateTime<chrono::Utc>,
+ /// Summary/title derived from first user message
+ pub summary: String,
+ /// Number of messages in the session
+ pub message_count: usize,
+}
+
+/// A message entry from the JSONL file
+#[derive(Deserialize)]
+struct SessionEntry {
+ #[serde(rename = "sessionId")]
+ session_id: Option<String>,
+ timestamp: Option<String>,
+ #[serde(rename = "type")]
+ entry_type: Option<String>,
+ message: Option<MessageContent>,
+}
+
+#[derive(Deserialize)]
+struct MessageContent {
+ role: Option<String>,
+ content: Option<serde_json::Value>,
+}
+
+/// Converts a working directory to its Claude project path
+/// e.g., /home/jb55/dev/notedeck-dave -> -home-jb55-dev-notedeck-dave
+fn cwd_to_project_path(cwd: &Path) -> String {
+ let path_str = cwd.to_string_lossy();
+ // Replace path separators with dashes, keep the leading dash
+ path_str.replace('/', "-")
+}
+
+/// Get the Claude projects directory
+fn get_claude_projects_dir() -> Option<PathBuf> {
+ dirs::home_dir().map(|home| home.join(".claude").join("projects"))
+}
+
+/// Extract the first user message content as a summary
+fn extract_first_user_message(content: &serde_json::Value) -> Option<String> {
+ match content {
+ serde_json::Value::String(s) => {
+ // Clean up the message - remove "Human: " prefix if present
+ let cleaned = s.trim().strip_prefix("Human:").unwrap_or(s).trim();
+ // Take first 60 chars
+ let summary: String = cleaned.chars().take(60).collect();
+ if cleaned.len() > 60 {
+ Some(format!("{}...", summary))
+ } else {
+ Some(summary.to_string())
+ }
+ }
+ serde_json::Value::Array(arr) => {
+ // Content might be an array of content blocks
+ for item in arr {
+ if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
+ let summary: String = text.chars().take(60).collect();
+ if text.len() > 60 {
+ return Some(format!("{}...", summary));
+ } else {
+ return Some(summary.to_string());
+ }
+ }
+ }
+ None
+ }
+ _ => None,
+ }
+}
+
+/// Parse a session JSONL file to extract session info
+fn parse_session_file(path: &Path) -> Option<ResumableSession> {
+ let file = File::open(path).ok()?;
+ let reader = BufReader::new(file);
+
+ let mut session_id: Option<String> = None;
+ let mut last_timestamp: Option<chrono::DateTime<chrono::Utc>> = None;
+ let mut first_user_message: Option<String> = None;
+ let mut message_count = 0;
+
+ for line in reader.lines() {
+ let line = line.ok()?;
+ if line.trim().is_empty() {
+ continue;
+ }
+
+ if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) {
+ // Get session ID from first entry that has it
+ if session_id.is_none() {
+ session_id = entry.session_id.clone();
+ }
+
+ // Track timestamp
+ if let Some(ts_str) = &entry.timestamp {
+ if let Ok(ts) = ts_str.parse::<chrono::DateTime<chrono::Utc>>() {
+ if last_timestamp.is_none() || ts > last_timestamp.unwrap() {
+ last_timestamp = Some(ts);
+ }
+ }
+ }
+
+ // Count user/assistant messages
+ if matches!(
+ entry.entry_type.as_deref(),
+ Some("user") | Some("assistant")
+ ) {
+ message_count += 1;
+
+ // Get first user message for summary
+ if entry.entry_type.as_deref() == Some("user") && first_user_message.is_none() {
+ if let Some(msg) = &entry.message {
+ if msg.role.as_deref() == Some("user") {
+ if let Some(content) = &msg.content {
+ first_user_message = extract_first_user_message(content);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Need at least a session_id and some messages
+ let session_id = session_id?;
+ if message_count == 0 {
+ return None;
+ }
+
+ Some(ResumableSession {
+ session_id,
+ file_path: path.to_path_buf(),
+ last_timestamp: last_timestamp.unwrap_or_else(chrono::Utc::now),
+ summary: first_user_message.unwrap_or_else(|| "(no summary)".to_string()),
+ message_count,
+ })
+}
+
+/// Discover all resumable sessions for a given working directory
+pub fn discover_sessions(cwd: &Path) -> Vec<ResumableSession> {
+ let projects_dir = match get_claude_projects_dir() {
+ Some(dir) => dir,
+ None => return Vec::new(),
+ };
+
+ let project_path = cwd_to_project_path(cwd);
+ let session_dir = projects_dir.join(&project_path);
+
+ if !session_dir.exists() || !session_dir.is_dir() {
+ return Vec::new();
+ }
+
+ let mut sessions = Vec::new();
+
+ // Read all .jsonl files in the session directory
+ if let Ok(entries) = fs::read_dir(&session_dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|ext| ext == "jsonl") {
+ if let Some(session) = parse_session_file(&path) {
+ sessions.push(session);
+ }
+ }
+ }
+ }
+
+ // Sort by most recent first
+ sessions.sort_by(|a, b| b.last_timestamp.cmp(&a.last_timestamp));
+
+ sessions
+}
+
+/// Format a timestamp for display (relative time like "2 hours ago")
+pub fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(*timestamp);
+
+ if duration.num_seconds() < 60 {
+ "just now".to_string()
+ } else if duration.num_minutes() < 60 {
+ let mins = duration.num_minutes();
+ format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
+ } else if duration.num_hours() < 24 {
+ let hours = duration.num_hours();
+ format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
+ } else if duration.num_days() < 7 {
+ let days = duration.num_days();
+ format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
+ } else {
+ timestamp.format("%Y-%m-%d").to_string()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_cwd_to_project_path() {
+ assert_eq!(
+ cwd_to_project_path(Path::new("/home/jb55/dev/notedeck-dave")),
+ "-home-jb55-dev-notedeck-dave"
+ );
+ assert_eq!(cwd_to_project_path(Path::new("/tmp/test")), "-tmp-test");
+ }
+
+ #[test]
+ fn test_extract_first_user_message_string() {
+ let content = serde_json::json!("Human: Hello, world!\n\n");
+ let result = extract_first_user_message(&content);
+ assert_eq!(result, Some("Hello, world!".to_string()));
+ }
+
+ #[test]
+ fn test_extract_first_user_message_array() {
+ let content = serde_json::json!([{"type": "text", "text": "Test message"}]);
+ let result = extract_first_user_message(&content);
+ assert_eq!(result, Some("Test message".to_string()));
+ }
+
+ #[test]
+ fn test_extract_first_user_message_truncation() {
+ let long_content = serde_json::json!("Human: This is a very long message that should be truncated because it exceeds sixty characters in length");
+ let result = extract_first_user_message(&long_content);
+ assert!(result.unwrap().ends_with("..."));
+ }
+}
diff --git a/crates/notedeck_dave/src/tools.rs b/crates/notedeck_dave/src/tools.rs
@@ -14,6 +14,10 @@ pub struct ToolCall {
}
impl ToolCall {
+ pub fn new(id: String, typ: ToolCalls) -> Self {
+ Self { id, typ }
+ }
+
pub fn id(&self) -> &str {
&self.id
}
@@ -86,7 +90,7 @@ impl PartialToolCall {
/// The query response from nostrdb for a given context
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResponse {
- notes: Vec<u64>,
+ pub notes: Vec<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -161,7 +165,16 @@ impl ToolCalls {
}
}
- fn arguments(&self) -> String {
+ /// Returns the tool name as defined in the tool registry (for prompt-based tool calls)
+ pub fn tool_name(&self) -> &'static str {
+ match self {
+ Self::Query(_) => "query",
+ Self::Invalid(_) => "invalid",
+ Self::PresentNotes(_) => "present_notes",
+ }
+ }
+
+ pub fn arguments(&self) -> String {
match self {
Self::Query(search) => serde_json::to_string(search).unwrap(),
Self::Invalid(partial) => serde_json::to_string(partial).unwrap(),
@@ -232,6 +245,14 @@ impl Tool {
self.name
}
+ pub fn description(&self) -> &'static str {
+ self.description
+ }
+
+ pub fn parse_call(&self) -> fn(&str) -> Result<ToolCalls, ToolCallError> {
+ self.parse_call
+ }
+
pub fn to_function_object(&self) -> FunctionObject {
let required_args = self
.arguments
diff --git a/crates/notedeck_dave/src/ui/ask_question.rs b/crates/notedeck_dave/src/ui/ask_question.rs
@@ -0,0 +1,276 @@
+//! UI for rendering AskUserQuestion tool calls from Claude Code
+
+use crate::messages::{AskUserQuestionInput, PermissionRequest, QuestionAnswer};
+use std::collections::HashMap;
+use uuid::Uuid;
+
+use super::badge;
+use super::keybind_hint;
+use super::DaveAction;
+
+/// Render an AskUserQuestion tool call with selectable options
+///
+/// Shows one question at a time with numbered options.
+/// Returns a `DaveAction::QuestionResponse` when the user submits all answers.
+pub fn ask_user_question_ui(
+ request: &PermissionRequest,
+ questions: &AskUserQuestionInput,
+ answers_map: &mut HashMap<Uuid, Vec<QuestionAnswer>>,
+ index_map: &mut HashMap<Uuid, usize>,
+ ui: &mut egui::Ui,
+) -> Option<DaveAction> {
+ let mut action = None;
+ let inner_margin = 12.0;
+ let corner_radius = 8.0;
+
+ let num_questions = questions.questions.len();
+
+ // Get or initialize answer state for this request
+ let answers = answers_map
+ .entry(request.id)
+ .or_insert_with(|| vec![QuestionAnswer::default(); num_questions]);
+
+ // Get current question index
+ let current_idx = *index_map.entry(request.id).or_insert(0);
+
+ // Ensure we have a valid index
+ if current_idx >= num_questions {
+ // All questions answered, shouldn't happen but handle gracefully
+ return None;
+ }
+
+ let question = &questions.questions[current_idx];
+
+ // Ensure we have an answer entry for this question
+ while answers.len() <= current_idx {
+ answers.push(QuestionAnswer::default());
+ }
+
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .stroke(egui::Stroke::new(1.0, ui.visuals().selection.stroke.color))
+ .show(ui, |ui| {
+ ui.vertical(|ui| {
+ // Progress indicator if multiple questions
+ if num_questions > 1 {
+ ui.horizontal(|ui| {
+ ui.label(
+ egui::RichText::new(format!(
+ "Question {} of {}",
+ current_idx + 1,
+ num_questions
+ ))
+ .weak()
+ .size(11.0),
+ );
+ });
+ ui.add_space(4.0);
+ }
+
+ // Header badge and question text
+ ui.horizontal(|ui| {
+ badge::StatusBadge::new(&question.header)
+ .variant(badge::BadgeVariant::Info)
+ .show(ui);
+ ui.add_space(8.0);
+ ui.label(egui::RichText::new(&question.question).strong());
+ });
+
+ ui.add_space(8.0);
+
+ // Check for number key presses
+ let pressed_number = ui.input(|i| {
+ for n in 1..=9 {
+ let key = match n {
+ 1 => egui::Key::Num1,
+ 2 => egui::Key::Num2,
+ 3 => egui::Key::Num3,
+ 4 => egui::Key::Num4,
+ 5 => egui::Key::Num5,
+ 6 => egui::Key::Num6,
+ 7 => egui::Key::Num7,
+ 8 => egui::Key::Num8,
+ 9 => egui::Key::Num9,
+ _ => unreachable!(),
+ };
+ if i.key_pressed(key) && !i.modifiers.shift && !i.modifiers.ctrl {
+ return Some(n);
+ }
+ }
+ None
+ });
+
+ // Options (numbered 1-N)
+ let num_options = question.options.len();
+ for (opt_idx, option) in question.options.iter().enumerate() {
+ let option_num = opt_idx + 1;
+ let is_selected = answers[current_idx].selected.contains(&opt_idx);
+ let other_is_selected = answers[current_idx].other_text.is_some();
+
+ // Handle keyboard selection
+ if pressed_number == Some(option_num) {
+ if question.multi_select {
+ if is_selected {
+ answers[current_idx].selected.retain(|&i| i != opt_idx);
+ } else {
+ answers[current_idx].selected.push(opt_idx);
+ }
+ } else {
+ answers[current_idx].selected = vec![opt_idx];
+ answers[current_idx].other_text = None;
+ }
+ }
+
+ ui.horizontal(|ui| {
+ // Number hint
+ keybind_hint(ui, &option_num.to_string());
+
+ if question.multi_select {
+ // Checkbox for multi-select
+ let mut checked = is_selected;
+ if ui.checkbox(&mut checked, "").changed() {
+ if checked {
+ answers[current_idx].selected.push(opt_idx);
+ } else {
+ answers[current_idx].selected.retain(|&i| i != opt_idx);
+ }
+ }
+ } else {
+ // Radio button for single-select
+ let selected = is_selected && !other_is_selected;
+ if ui.radio(selected, "").clicked() {
+ answers[current_idx].selected = vec![opt_idx];
+ answers[current_idx].other_text = None;
+ }
+ }
+
+ ui.vertical(|ui| {
+ ui.label(egui::RichText::new(&option.label));
+ ui.label(egui::RichText::new(&option.description).weak().size(11.0));
+ });
+ });
+
+ ui.add_space(4.0);
+ }
+
+ // "Other" option (numbered as last option + 1)
+ let other_num = num_options + 1;
+ let other_selected = answers[current_idx].other_text.is_some();
+
+ // Handle keyboard selection for "Other"
+ if pressed_number == Some(other_num) {
+ if question.multi_select {
+ if other_selected {
+ answers[current_idx].other_text = None;
+ } else {
+ answers[current_idx].other_text = Some(String::new());
+ }
+ } else {
+ answers[current_idx].selected.clear();
+ answers[current_idx].other_text = Some(String::new());
+ }
+ }
+
+ ui.horizontal(|ui| {
+ // Number hint for "Other"
+ keybind_hint(ui, &other_num.to_string());
+
+ if question.multi_select {
+ let mut checked = other_selected;
+ if ui.checkbox(&mut checked, "").changed() {
+ if checked {
+ answers[current_idx].other_text = Some(String::new());
+ } else {
+ answers[current_idx].other_text = None;
+ }
+ }
+ } else if ui.radio(other_selected, "").clicked() {
+ answers[current_idx].selected.clear();
+ answers[current_idx].other_text = Some(String::new());
+ }
+
+ ui.label("Other:");
+
+ // Text input for "Other"
+ if let Some(text) = &mut answers[current_idx].other_text {
+ ui.add(
+ egui::TextEdit::singleline(text)
+ .desired_width(200.0)
+ .hint_text("Type your answer..."),
+ );
+ }
+ });
+
+ // Submit button
+ ui.add_space(8.0);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
+
+ let is_last_question = current_idx == num_questions - 1;
+ let button_label = if is_last_question { "Submit" } else { "Next" };
+
+ let submit_response = badge::ActionButton::new(
+ button_label,
+ egui::Color32::from_rgb(34, 139, 34),
+ button_text_color,
+ )
+ .keybind("\u{21B5}") // ↵ enter symbol
+ .show(ui);
+
+ if submit_response.clicked()
+ || ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift)
+ {
+ if is_last_question {
+ // All questions answered, submit
+ action = Some(DaveAction::QuestionResponse {
+ request_id: request.id,
+ answers: answers.clone(),
+ });
+ } else {
+ // Move to next question
+ index_map.insert(request.id, current_idx + 1);
+ }
+ }
+ });
+ });
+ });
+
+ action
+}
+
+/// Render a compact summary of an answered AskUserQuestion
+///
+/// Shows the question header(s) and selected answer(s) in a single line.
+/// Uses pre-computed AnswerSummary to avoid per-frame allocations.
+pub fn ask_user_question_summary_ui(summary: &crate::messages::AnswerSummary, ui: &mut egui::Ui) {
+ let inner_margin = 8.0;
+ let corner_radius = 6.0;
+
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .show(ui, |ui| {
+ ui.horizontal_wrapped(|ui| {
+ for (idx, entry) in summary.entries.iter().enumerate() {
+ // Add separator between questions
+ if idx > 0 {
+ ui.separator();
+ }
+
+ // Header badge
+ badge::StatusBadge::new(&entry.header)
+ .variant(badge::BadgeVariant::Info)
+ .show(ui);
+
+ // Pre-computed answer text
+ ui.label(
+ egui::RichText::new(&entry.answer)
+ .color(egui::Color32::from_rgb(100, 180, 100)),
+ );
+ }
+ });
+ });
+}
diff --git a/crates/notedeck_dave/src/ui/badge.rs b/crates/notedeck_dave/src/ui/badge.rs
@@ -0,0 +1,311 @@
+use egui::{Color32, Response, Ui, Vec2};
+
+/// Badge variants that determine the color scheme
+#[derive(Clone, Copy, Default)]
+#[allow(dead_code)]
+pub enum BadgeVariant {
+ /// Default muted style
+ #[default]
+ Default,
+ /// Informational blue
+ Info,
+ /// Success green
+ Success,
+ /// Warning amber/yellow
+ Warning,
+ /// Error/danger red
+ Destructive,
+}
+
+impl BadgeVariant {
+ /// Get background and text colors for this variant
+ fn colors(&self, ui: &Ui) -> (Color32, Color32) {
+ let is_dark = ui.visuals().dark_mode;
+
+ match self {
+ BadgeVariant::Default => {
+ let bg = if is_dark {
+ Color32::from_rgba_unmultiplied(255, 255, 255, 20)
+ } else {
+ Color32::from_rgba_unmultiplied(0, 0, 0, 15)
+ };
+ let fg = ui.visuals().text_color();
+ (bg, fg)
+ }
+ BadgeVariant::Info => {
+ // Blue tones
+ let bg = if is_dark {
+ Color32::from_rgba_unmultiplied(59, 130, 246, 30)
+ } else {
+ Color32::from_rgba_unmultiplied(59, 130, 246, 25)
+ };
+ let fg = if is_dark {
+ Color32::from_rgb(147, 197, 253) // blue-300
+ } else {
+ Color32::from_rgb(29, 78, 216) // blue-700
+ };
+ (bg, fg)
+ }
+ BadgeVariant::Success => {
+ // Green tones
+ let bg = if is_dark {
+ Color32::from_rgba_unmultiplied(34, 197, 94, 30)
+ } else {
+ Color32::from_rgba_unmultiplied(34, 197, 94, 25)
+ };
+ let fg = if is_dark {
+ Color32::from_rgb(134, 239, 172) // green-300
+ } else {
+ Color32::from_rgb(21, 128, 61) // green-700
+ };
+ (bg, fg)
+ }
+ BadgeVariant::Warning => {
+ // Amber/yellow tones
+ let bg = if is_dark {
+ Color32::from_rgba_unmultiplied(245, 158, 11, 30)
+ } else {
+ Color32::from_rgba_unmultiplied(245, 158, 11, 25)
+ };
+ let fg = if is_dark {
+ Color32::from_rgb(252, 211, 77) // amber-300
+ } else {
+ Color32::from_rgb(180, 83, 9) // amber-700
+ };
+ (bg, fg)
+ }
+ BadgeVariant::Destructive => {
+ // Red tones
+ let bg = if is_dark {
+ Color32::from_rgba_unmultiplied(239, 68, 68, 30)
+ } else {
+ Color32::from_rgba_unmultiplied(239, 68, 68, 25)
+ };
+ let fg = if is_dark {
+ Color32::from_rgb(252, 165, 165) // red-300
+ } else {
+ Color32::from_rgb(185, 28, 28) // red-700
+ };
+ (bg, fg)
+ }
+ }
+ }
+}
+
+/// A pill-shaped status badge widget (shadcn-style)
+pub struct StatusBadge<'a> {
+ text: &'a str,
+ variant: BadgeVariant,
+ keybind: Option<&'a str>,
+}
+
+impl<'a> StatusBadge<'a> {
+ /// Create a new status badge with the given text
+ pub fn new(text: &'a str) -> Self {
+ Self {
+ text,
+ variant: BadgeVariant::Default,
+ keybind: None,
+ }
+ }
+
+ /// Set the badge variant
+ pub fn variant(mut self, variant: BadgeVariant) -> Self {
+ self.variant = variant;
+ self
+ }
+
+ /// Add a keybind hint inside the badge (e.g., "P" for Ctrl+P)
+ pub fn keybind(mut self, key: &'a str) -> Self {
+ self.keybind = Some(key);
+ self
+ }
+
+ /// Show the badge and return the response
+ pub fn show(self, ui: &mut Ui) -> Response {
+ let (bg_color, text_color) = self.variant.colors(ui);
+
+ // Calculate text size for proper allocation
+ let font_id = egui::FontId::proportional(11.0);
+ let galley =
+ ui.painter()
+ .layout_no_wrap(self.text.to_string(), font_id.clone(), text_color);
+
+ // Calculate keybind box size if present
+ let keybind_box_size = 14.0;
+ let keybind_spacing = 5.0;
+ let keybind_extra = if self.keybind.is_some() {
+ keybind_box_size + keybind_spacing
+ } else {
+ 0.0
+ };
+
+ // Padding: horizontal 8px, vertical 2px
+ let padding = Vec2::new(8.0, 3.0);
+ let desired_size =
+ Vec2::new(galley.size().x + keybind_extra, galley.size().y) + padding * 2.0;
+
+ let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
+
+ if ui.is_rect_visible(rect) {
+ let painter = ui.painter();
+
+ // Full pill rounding (half of height)
+ let rounding = rect.height() / 2.0;
+
+ // Background
+ painter.rect_filled(rect, rounding, bg_color);
+
+ // Text (offset left if keybind present)
+ let text_offset_x = if self.keybind.is_some() {
+ -keybind_extra / 2.0
+ } else {
+ 0.0
+ };
+ let text_pos = rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0;
+ painter.galley(text_pos, galley, text_color);
+
+ // Draw keybind box if present
+ if let Some(key) = self.keybind {
+ let box_center = egui::pos2(
+ rect.right() - padding.x - keybind_box_size / 2.0,
+ rect.center().y,
+ );
+ let box_rect =
+ egui::Rect::from_center_size(box_center, Vec2::splat(keybind_box_size));
+
+ // Keybind box background (slightly darker/lighter than badge bg)
+ let visuals = ui.visuals();
+ let box_bg = visuals.widgets.noninteractive.bg_fill;
+ let box_stroke = text_color.gamma_multiply(0.5);
+
+ painter.rect_filled(box_rect, 3.0, box_bg);
+ painter.rect_stroke(
+ box_rect,
+ 3.0,
+ egui::Stroke::new(1.0, box_stroke),
+ egui::StrokeKind::Inside,
+ );
+
+ // Keybind text
+ painter.text(
+ box_center + Vec2::new(0.0, 1.0),
+ egui::Align2::CENTER_CENTER,
+ key,
+ egui::FontId::monospace(keybind_box_size * 0.65),
+ visuals.text_color(),
+ );
+ }
+ }
+
+ response
+ }
+}
+
+/// A pill-shaped action button with integrated keybind hint
+pub struct ActionButton<'a> {
+ text: &'a str,
+ bg_color: Color32,
+ text_color: Color32,
+ keybind: Option<&'a str>,
+}
+
+impl<'a> ActionButton<'a> {
+ /// Create a new action button with the given text and colors
+ pub fn new(text: &'a str, bg_color: Color32, text_color: Color32) -> Self {
+ Self {
+ text,
+ bg_color,
+ text_color,
+ keybind: None,
+ }
+ }
+
+ /// Add a keybind hint inside the button (e.g., "1" for key 1)
+ pub fn keybind(mut self, key: &'a str) -> Self {
+ self.keybind = Some(key);
+ self
+ }
+
+ /// Show the button and return the response
+ pub fn show(self, ui: &mut Ui) -> Response {
+ // Calculate text size for proper allocation
+ let font_id = egui::FontId::proportional(13.0);
+ let galley =
+ ui.painter()
+ .layout_no_wrap(self.text.to_string(), font_id.clone(), self.text_color);
+
+ // Calculate keybind box size if present
+ let keybind_box_size = 16.0;
+ let keybind_spacing = 6.0;
+ let keybind_extra = if self.keybind.is_some() {
+ keybind_box_size + keybind_spacing
+ } else {
+ 0.0
+ };
+
+ // Padding: horizontal 10px, vertical 4px
+ let padding = Vec2::new(10.0, 4.0);
+ let desired_size =
+ Vec2::new(galley.size().x + keybind_extra, galley.size().y) + padding * 2.0;
+
+ let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
+
+ if ui.is_rect_visible(rect) {
+ let painter = ui.painter();
+
+ // Adjust color based on hover/click state
+ let bg_color = if response.is_pointer_button_down_on() {
+ self.bg_color.gamma_multiply(0.8)
+ } else if response.hovered() {
+ self.bg_color.gamma_multiply(1.15)
+ } else {
+ self.bg_color
+ };
+
+ // Full pill rounding (half of height)
+ let rounding = rect.height() / 2.0;
+
+ // Background
+ painter.rect_filled(rect, rounding, bg_color);
+
+ // Text (offset right if keybind present, since keybind goes on left)
+ let text_offset_x = if self.keybind.is_some() {
+ keybind_extra / 2.0
+ } else {
+ 0.0
+ };
+ let text_pos = rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0;
+ painter.galley(text_pos, galley, self.text_color);
+
+ // Draw keybind hint on left side (white border, no fill)
+ if let Some(key) = self.keybind {
+ let box_center = egui::pos2(
+ rect.left() + padding.x + keybind_box_size / 2.0,
+ rect.center().y,
+ );
+ let box_rect =
+ egui::Rect::from_center_size(box_center, Vec2::splat(keybind_box_size));
+
+ // White border only
+ painter.rect_stroke(
+ box_rect,
+ 3.0,
+ egui::Stroke::new(1.0, Color32::WHITE),
+ egui::StrokeKind::Inside,
+ );
+
+ // Keybind text with vertical nudge for optical centering
+ painter.text(
+ box_center + Vec2::new(0.0, 1.0),
+ egui::Align2::CENTER_CENTER,
+ key,
+ egui::FontId::monospace(keybind_box_size * 0.7),
+ self.text_color,
+ );
+ }
+ }
+
+ response
+ }
+}
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -1,19 +1,47 @@
+use super::badge::{BadgeVariant, StatusBadge};
+use super::diff;
+use super::query_ui::query_call_ui;
+use super::top_buttons::top_buttons_ui;
use crate::{
- messages::Message,
- tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse},
+ config::{AiMode, DaveSettings},
+ file_update::FileUpdate,
+ messages::{
+ AskUserQuestionInput, CompactionInfo, Message, PermissionRequest, PermissionResponse,
+ PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus, ToolResult,
+ },
+ session::PermissionMessageState,
+ tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse},
};
use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
-use nostrdb::{Ndb, Transaction};
-use notedeck::{
- tr, Accounts, AppContext, Images, Localization, MediaJobSender, NoteAction, NoteContext,
-};
-use notedeck_ui::{icons::search_icon, NoteOptions, ProfilePic};
+use nostrdb::Transaction;
+use notedeck::{tr, AppContext, Localization, NoteAction, NoteContext};
+use notedeck_ui::{icons::search_icon, NoteOptions};
+use std::collections::HashMap;
+use uuid::Uuid;
/// DaveUi holds all of the data it needs to render itself
pub struct DaveUi<'a> {
chat: &'a [Message],
trial: bool,
input: &'a mut String,
+ compact: bool,
+ is_working: bool,
+ interrupt_pending: bool,
+ has_pending_permission: bool,
+ focus_requested: &'a mut bool,
+ plan_mode_active: bool,
+ /// State for tentative permission response (waiting for message)
+ permission_message_state: PermissionMessageState,
+ /// State for AskUserQuestion responses (selected options per question)
+ question_answers: Option<&'a mut HashMap<Uuid, Vec<QuestionAnswer>>>,
+ /// Current question index for multi-question AskUserQuestion
+ question_index: Option<&'a mut HashMap<Uuid, usize>>,
+ /// Whether conversation compaction is in progress
+ is_compacting: bool,
+ /// Whether auto-steal focus mode is active
+ auto_steal_focus: bool,
+ /// AI interaction mode (Chat vs Agentic)
+ ai_mode: AiMode,
}
/// The response the app generates. The response contains an optional
@@ -62,23 +90,120 @@ pub enum DaveAction {
Note(NoteAction),
/// Toggle showing the session list (for mobile navigation)
ShowSessionList,
+ /// Open the settings panel
+ OpenSettings,
+ /// Settings were updated and should be persisted
+ UpdateSettings(DaveSettings),
+ /// User responded to a permission request
+ PermissionResponse {
+ request_id: Uuid,
+ response: PermissionResponse,
+ },
+ /// User wants to interrupt/stop the current AI operation
+ Interrupt,
+ /// Enter tentative accept mode (Shift+click on Yes)
+ TentativeAccept,
+ /// Enter tentative deny mode (Shift+click on No)
+ TentativeDeny,
+ /// User responded to an AskUserQuestion
+ QuestionResponse {
+ request_id: Uuid,
+ answers: Vec<QuestionAnswer>,
+ },
+ /// User approved or rejected an ExitPlanMode request
+ ExitPlanMode {
+ request_id: Uuid,
+ approved: bool,
+ },
}
impl<'a> DaveUi<'a> {
- pub fn new(trial: bool, chat: &'a [Message], input: &'a mut String) -> Self {
- DaveUi { trial, chat, input }
+ pub fn new(
+ trial: bool,
+ chat: &'a [Message],
+ input: &'a mut String,
+ focus_requested: &'a mut bool,
+ ai_mode: AiMode,
+ ) -> Self {
+ DaveUi {
+ trial,
+ chat,
+ input,
+ compact: false,
+ is_working: false,
+ interrupt_pending: false,
+ has_pending_permission: false,
+ focus_requested,
+ plan_mode_active: false,
+ permission_message_state: PermissionMessageState::None,
+ question_answers: None,
+ question_index: None,
+ is_compacting: false,
+ auto_steal_focus: false,
+ ai_mode,
+ }
+ }
+
+ pub fn permission_message_state(mut self, state: PermissionMessageState) -> Self {
+ self.permission_message_state = state;
+ self
+ }
+
+ pub fn question_answers(mut self, answers: &'a mut HashMap<Uuid, Vec<QuestionAnswer>>) -> Self {
+ self.question_answers = Some(answers);
+ self
+ }
+
+ pub fn question_index(mut self, index: &'a mut HashMap<Uuid, usize>) -> Self {
+ self.question_index = Some(index);
+ self
+ }
+
+ pub fn compact(mut self, compact: bool) -> Self {
+ self.compact = compact;
+ self
+ }
+
+ pub fn is_working(mut self, is_working: bool) -> Self {
+ self.is_working = is_working;
+ self
+ }
+
+ pub fn interrupt_pending(mut self, interrupt_pending: bool) -> Self {
+ self.interrupt_pending = interrupt_pending;
+ self
+ }
+
+ pub fn has_pending_permission(mut self, has_pending_permission: bool) -> Self {
+ self.has_pending_permission = has_pending_permission;
+ self
+ }
+
+ pub fn plan_mode_active(mut self, plan_mode_active: bool) -> Self {
+ self.plan_mode_active = plan_mode_active;
+ self
+ }
+
+ pub fn is_compacting(mut self, is_compacting: bool) -> Self {
+ self.is_compacting = is_compacting;
+ self
}
- fn chat_margin(ctx: &egui::Context) -> i8 {
- if notedeck::ui::is_narrow(ctx) {
+ pub fn auto_steal_focus(mut self, auto_steal_focus: bool) -> Self {
+ self.auto_steal_focus = auto_steal_focus;
+ self
+ }
+
+ fn chat_margin(&self, ctx: &egui::Context) -> i8 {
+ if self.compact || notedeck::ui::is_narrow(ctx) {
20
} else {
100
}
}
- fn chat_frame(ctx: &egui::Context) -> egui::Frame {
- let margin = Self::chat_margin(ctx);
+ fn chat_frame(&self, ctx: &egui::Context) -> egui::Frame {
+ let margin = self.chat_margin(ctx);
egui::Frame::new().inner_margin(egui::Margin {
left: margin,
right: margin,
@@ -89,19 +214,25 @@ impl<'a> DaveUi<'a> {
/// The main render function. Call this to render Dave
pub fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
- let action = top_buttons_ui(app_ctx, ui);
+ // Skip top buttons in compact mode (scene panel has its own controls)
+ let action = if self.compact {
+ None
+ } else {
+ top_buttons_ui(app_ctx, ui)
+ };
egui::Frame::NONE
.show(ui, |ui| {
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
- let margin = Self::chat_margin(ui.ctx());
+ let margin = self.chat_margin(ui.ctx());
+ let bottom_margin = 100;
let r = egui::Frame::new()
.outer_margin(egui::Margin {
left: margin,
right: margin,
top: 0,
- bottom: 100,
+ bottom: bottom_margin,
})
.inner_margin(egui::Margin::same(8))
.fill(ui.visuals().extreme_bg_color)
@@ -109,11 +240,12 @@ impl<'a> DaveUi<'a> {
.show(ui, |ui| self.inputbox(app_ctx.i18n, ui))
.inner;
- let note_action = egui::ScrollArea::vertical()
+ let chat_response = egui::ScrollArea::vertical()
+ .id_salt("dave_chat_scroll")
.stick_to_bottom(true)
.auto_shrink([false; 2])
.show(ui, |ui| {
- Self::chat_frame(ui.ctx())
+ self.chat_frame(ui.ctx())
.show(ui, |ui| {
ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner
})
@@ -121,11 +253,7 @@ impl<'a> DaveUi<'a> {
})
.inner;
- if let Some(action) = note_action {
- DaveResponse::note(action)
- } else {
- r
- }
+ chat_response.or(r)
})
.inner
})
@@ -149,47 +277,570 @@ impl<'a> DaveUi<'a> {
}
/// Render a chat message (user, assistant, tool call/response, etc)
- fn render_chat(&self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<NoteAction> {
- let mut action: Option<NoteAction> = None;
+ fn render_chat(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
+ let mut response = DaveResponse::default();
+ let is_agentic = self.ai_mode == AiMode::Agentic;
+
for message in self.chat {
- let r = match message {
+ match message {
Message::Error(err) => {
self.error_chat(ctx.i18n, err, ui);
- None
}
Message::User(msg) => {
self.user_chat(msg, ui);
- None
}
Message::Assistant(msg) => {
self.assistant_chat(msg, ui);
- None
}
Message::ToolResponse(msg) => {
Self::tool_response_ui(msg, ui);
- None
}
Message::System(_msg) => {
// system prompt is not rendered. Maybe we could
// have a debug option to show this
- None
}
- Message::ToolCalls(toolcalls) => Self::tool_calls_ui(ctx, toolcalls, ui),
+ Message::ToolCalls(toolcalls) => {
+ if let Some(note_action) = Self::tool_calls_ui(ctx, toolcalls, ui) {
+ response = DaveResponse::note(note_action);
+ }
+ }
+ Message::PermissionRequest(request) => {
+ // Permission requests only in Agentic mode
+ if is_agentic {
+ if let Some(action) = self.permission_request_ui(request, ui) {
+ response = DaveResponse::new(action);
+ }
+ }
+ }
+ Message::ToolResult(result) => {
+ // Tool results only in Agentic mode
+ if is_agentic {
+ Self::tool_result_ui(result, ui);
+ }
+ }
+ Message::CompactionComplete(info) => {
+ // Compaction only in Agentic mode
+ if is_agentic {
+ Self::compaction_complete_ui(info, ui);
+ }
+ }
+ Message::Subagent(info) => {
+ // Subagents only in Agentic mode
+ if is_agentic {
+ Self::subagent_ui(info, ui);
+ }
+ }
};
+ }
- if r.is_some() {
- action = r;
- }
+ // Show status line at the bottom of chat when working or compacting
+ let status_text = if is_agentic && self.is_compacting {
+ Some("compacting...")
+ } else if self.is_working {
+ Some("computing...")
+ } else {
+ None
+ };
+
+ if let Some(status) = status_text {
+ ui.horizontal(|ui| {
+ ui.add(egui::Spinner::new().size(14.0));
+ ui.label(
+ egui::RichText::new(status)
+ .color(ui.visuals().weak_text_color())
+ .italics(),
+ );
+ ui.label(
+ egui::RichText::new("(press esc to interrupt)")
+ .color(ui.visuals().weak_text_color())
+ .small(),
+ );
+ });
}
- action
+ response
}
fn tool_response_ui(_tool_response: &ToolResponse, _ui: &mut egui::Ui) {
//ui.label(format!("tool_response: {:?}", tool_response));
}
- fn search_call_ui(ctx: &mut AppContext, query_call: &QueryCall, ui: &mut egui::Ui) {
+ /// Render a permission request with Allow/Deny buttons or response state
+ fn permission_request_ui(
+ &mut self,
+ request: &PermissionRequest,
+ ui: &mut egui::Ui,
+ ) -> Option<DaveAction> {
+ let mut action = None;
+
+ let inner_margin = 8.0;
+ let corner_radius = 6.0;
+ let spacing_x = 8.0;
+
+ ui.spacing_mut().item_spacing.x = spacing_x;
+
+ match request.response {
+ Some(PermissionResponseType::Allowed) => {
+ // Check if this is an answered AskUserQuestion with stored summary
+ if let Some(summary) = &request.answer_summary {
+ super::ask_user_question_summary_ui(summary, ui);
+ return None;
+ }
+
+ // Responded state: Allowed (generic fallback)
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .show(ui, |ui| {
+ ui.horizontal(|ui| {
+ ui.label(
+ egui::RichText::new("Allowed")
+ .color(egui::Color32::from_rgb(100, 180, 100))
+ .strong(),
+ );
+ ui.label(
+ egui::RichText::new(&request.tool_name)
+ .color(ui.visuals().text_color()),
+ );
+ });
+ });
+ }
+ Some(PermissionResponseType::Denied) => {
+ // Responded state: Denied
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .show(ui, |ui| {
+ ui.horizontal(|ui| {
+ ui.label(
+ egui::RichText::new("Denied")
+ .color(egui::Color32::from_rgb(200, 100, 100))
+ .strong(),
+ );
+ ui.label(
+ egui::RichText::new(&request.tool_name)
+ .color(ui.visuals().text_color()),
+ );
+ });
+ });
+ }
+ None => {
+ // Check if this is an ExitPlanMode tool call
+ if request.tool_name == "ExitPlanMode" {
+ return self.exit_plan_mode_ui(request, ui);
+ }
+
+ // Check if this is an AskUserQuestion tool call
+ if request.tool_name == "AskUserQuestion" {
+ if let Ok(questions) =
+ serde_json::from_value::<AskUserQuestionInput>(request.tool_input.clone())
+ {
+ if let (Some(answers_map), Some(index_map)) =
+ (&mut self.question_answers, &mut self.question_index)
+ {
+ return super::ask_user_question_ui(
+ request,
+ &questions,
+ answers_map,
+ index_map,
+ ui,
+ );
+ }
+ }
+ }
+
+ // Check if this is a file update (Edit or Write tool)
+ if let Some(file_update) =
+ FileUpdate::from_tool_call(&request.tool_name, &request.tool_input)
+ {
+ // Render file update with diff view
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
+ .show(ui, |ui| {
+ // Header with file path
+ diff::file_path_header(&file_update, ui);
+
+ // Diff view
+ diff::file_update_ui(&file_update, ui);
+
+ // Approve/deny buttons at the bottom right
+ ui.with_layout(
+ egui::Layout::right_to_left(egui::Align::Center),
+ |ui| {
+ self.permission_buttons(request, ui, &mut action);
+ },
+ );
+ });
+ } else {
+ // Parse tool input for display (existing logic)
+ let obj = request.tool_input.as_object();
+ let description = obj
+ .and_then(|o| o.get("description"))
+ .and_then(|v| v.as_str());
+ let command = obj.and_then(|o| o.get("command")).and_then(|v| v.as_str());
+ let single_value = obj
+ .filter(|o| o.len() == 1)
+ .and_then(|o| o.values().next())
+ .and_then(|v| v.as_str());
+
+ // Pending state: Show Allow/Deny buttons
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
+ .show(ui, |ui| {
+ // Tool info display
+ if let Some(desc) = description {
+ // Format: ToolName: description
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&request.tool_name).strong());
+ ui.label(desc);
+
+ self.permission_buttons(request, ui, &mut action);
+ });
+ // Command on next line if present
+ if let Some(cmd) = command {
+ ui.add(
+ egui::Label::new(egui::RichText::new(cmd).monospace())
+ .wrap_mode(egui::TextWrapMode::Wrap),
+ );
+ }
+ } else if let Some(value) = single_value {
+ // Format: ToolName `value`
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&request.tool_name).strong());
+ ui.label(egui::RichText::new(value).monospace());
+
+ self.permission_buttons(request, ui, &mut action);
+ });
+ } else {
+ // Fallback: show JSON
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(&request.tool_name).strong());
+
+ self.permission_buttons(request, ui, &mut action);
+ });
+ let formatted = serde_json::to_string_pretty(&request.tool_input)
+ .unwrap_or_else(|_| request.tool_input.to_string());
+ ui.add(
+ egui::Label::new(
+ egui::RichText::new(formatted).monospace().size(11.0),
+ )
+ .wrap_mode(egui::TextWrapMode::Wrap),
+ );
+ }
+ });
+ }
+ }
+ }
+
+ action
+ }
+
+ /// Render Allow/Deny buttons aligned to the right with keybinding hints
+ fn permission_buttons(
+ &self,
+ request: &PermissionRequest,
+ ui: &mut egui::Ui,
+ action: &mut Option<DaveAction>,
+ ) {
+ let shift_held = ui.input(|i| i.modifiers.shift);
+
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
+
+ // Deny button (red) with integrated keybind hint
+ let deny_response = super::badge::ActionButton::new(
+ "Deny",
+ egui::Color32::from_rgb(178, 34, 34),
+ button_text_color,
+ )
+ .keybind("2")
+ .show(ui)
+ .on_hover_text("Press 2 to deny, Shift+2 to deny with message");
+
+ if deny_response.clicked() {
+ if shift_held {
+ // Shift+click: enter tentative deny mode
+ *action = Some(DaveAction::TentativeDeny);
+ } else {
+ // Normal click: immediate deny
+ *action = Some(DaveAction::PermissionResponse {
+ request_id: request.id,
+ response: PermissionResponse::Deny {
+ reason: "User denied".into(),
+ },
+ });
+ }
+ }
+
+ // Allow button (green) with integrated keybind hint
+ let allow_response = super::badge::ActionButton::new(
+ "Allow",
+ egui::Color32::from_rgb(34, 139, 34),
+ button_text_color,
+ )
+ .keybind("1")
+ .show(ui)
+ .on_hover_text("Press 1 to allow, Shift+1 to allow with message");
+
+ if allow_response.clicked() {
+ if shift_held {
+ // Shift+click: enter tentative accept mode
+ *action = Some(DaveAction::TentativeAccept);
+ } else {
+ // Normal click: immediate allow
+ *action = Some(DaveAction::PermissionResponse {
+ request_id: request.id,
+ response: PermissionResponse::Allow { message: None },
+ });
+ }
+ }
+
+ // Show tentative state indicator OR shift hint
+ match self.permission_message_state {
+ PermissionMessageState::TentativeAccept => {
+ ui.label(
+ egui::RichText::new("âś“ Will Allow")
+ .color(egui::Color32::from_rgb(100, 180, 100))
+ .strong(),
+ );
+ }
+ PermissionMessageState::TentativeDeny => {
+ ui.label(
+ egui::RichText::new("âś— Will Deny")
+ .color(egui::Color32::from_rgb(200, 100, 100))
+ .strong(),
+ );
+ }
+ PermissionMessageState::None => {
+ // Always show hint for adding message
+ let hint_color = if shift_held {
+ ui.visuals().warn_fg_color
+ } else {
+ ui.visuals().weak_text_color()
+ };
+ ui.label(
+ egui::RichText::new("(⇧ for message)")
+ .color(hint_color)
+ .small(),
+ );
+ }
+ }
+ });
+ }
+
+ /// Render ExitPlanMode tool call with Approve/Reject buttons
+ fn exit_plan_mode_ui(
+ &self,
+ request: &PermissionRequest,
+ ui: &mut egui::Ui,
+ ) -> Option<DaveAction> {
+ let mut action = None;
+ let inner_margin = 12.0;
+ let corner_radius = 8.0;
+
+ // The plan content is in tool_input.plan field
+ let plan_content = request
+ .tool_input
+ .get("plan")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ egui::Frame::new()
+ .fill(ui.visuals().widgets.noninteractive.bg_fill)
+ .inner_margin(inner_margin)
+ .corner_radius(corner_radius)
+ .stroke(egui::Stroke::new(1.0, ui.visuals().selection.stroke.color))
+ .show(ui, |ui| {
+ ui.vertical(|ui| {
+ // Header with badge
+ ui.horizontal(|ui| {
+ super::badge::StatusBadge::new("PLAN")
+ .variant(super::badge::BadgeVariant::Info)
+ .show(ui);
+ ui.add_space(8.0);
+ ui.label(egui::RichText::new("Plan ready for approval").strong());
+ });
+
+ ui.add_space(8.0);
+
+ // Display the plan content as plain text (TODO: markdown rendering)
+ ui.add(
+ egui::Label::new(
+ egui::RichText::new(&plan_content)
+ .monospace()
+ .size(11.0)
+ .color(ui.visuals().text_color()),
+ )
+ .wrap_mode(egui::TextWrapMode::Wrap),
+ );
+
+ ui.add_space(8.0);
+
+ // Approve/Reject buttons with shift support for adding message
+ let shift_held = ui.input(|i| i.modifiers.shift);
+
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
+
+ // Reject button (red)
+ let reject_response = super::badge::ActionButton::new(
+ "Reject",
+ egui::Color32::from_rgb(178, 34, 34),
+ button_text_color,
+ )
+ .keybind("2")
+ .show(ui)
+ .on_hover_text("Press 2 to reject, Shift+2 to reject with message");
+
+ if reject_response.clicked() {
+ if shift_held {
+ action = Some(DaveAction::TentativeDeny);
+ } else {
+ action = Some(DaveAction::ExitPlanMode {
+ request_id: request.id,
+ approved: false,
+ });
+ }
+ }
+
+ // Approve button (green)
+ let approve_response = super::badge::ActionButton::new(
+ "Approve",
+ egui::Color32::from_rgb(34, 139, 34),
+ button_text_color,
+ )
+ .keybind("1")
+ .show(ui)
+ .on_hover_text("Press 1 to approve, Shift+1 to approve with message");
+
+ if approve_response.clicked() {
+ if shift_held {
+ action = Some(DaveAction::TentativeAccept);
+ } else {
+ action = Some(DaveAction::ExitPlanMode {
+ request_id: request.id,
+ approved: true,
+ });
+ }
+ }
+
+ // Show tentative state indicator OR shift hint
+ match self.permission_message_state {
+ PermissionMessageState::TentativeAccept => {
+ ui.label(
+ egui::RichText::new("âś“ Will Approve")
+ .color(egui::Color32::from_rgb(100, 180, 100))
+ .strong(),
+ );
+ }
+ PermissionMessageState::TentativeDeny => {
+ ui.label(
+ egui::RichText::new("âś— Will Reject")
+ .color(egui::Color32::from_rgb(200, 100, 100))
+ .strong(),
+ );
+ }
+ PermissionMessageState::None => {
+ let hint_color = if shift_held {
+ ui.visuals().warn_fg_color
+ } else {
+ ui.visuals().weak_text_color()
+ };
+ ui.label(
+ egui::RichText::new("(⇧ for message)")
+ .color(hint_color)
+ .small(),
+ );
+ }
+ }
+ });
+ });
+ });
+
+ action
+ }
+
+ /// Render tool result metadata as a compact line
+ fn tool_result_ui(result: &ToolResult, ui: &mut egui::Ui) {
+ // Compact single-line display with subdued styling
+ ui.horizontal(|ui| {
+ // Tool name in slightly brighter text
+ ui.add(egui::Label::new(
+ egui::RichText::new(&result.tool_name)
+ .size(11.0)
+ .color(ui.visuals().text_color().gamma_multiply(0.6))
+ .monospace(),
+ ));
+ // Summary in more subdued text
+ if !result.summary.is_empty() {
+ ui.add(egui::Label::new(
+ egui::RichText::new(&result.summary)
+ .size(11.0)
+ .color(ui.visuals().text_color().gamma_multiply(0.4))
+ .monospace(),
+ ));
+ }
+ });
+ }
+
+ /// Render compaction complete notification
+ fn compaction_complete_ui(info: &CompactionInfo, ui: &mut egui::Ui) {
+ ui.horizontal(|ui| {
+ ui.add(egui::Label::new(
+ egui::RichText::new("âś“")
+ .size(11.0)
+ .color(egui::Color32::from_rgb(100, 180, 100)),
+ ));
+ ui.add(egui::Label::new(
+ egui::RichText::new(format!("Compacted ({} tokens)", info.pre_tokens))
+ .size(11.0)
+ .color(ui.visuals().weak_text_color())
+ .italics(),
+ ));
+ });
+ }
+
+ /// Render a single subagent's status
+ fn subagent_ui(info: &SubagentInfo, ui: &mut egui::Ui) {
+ ui.horizontal(|ui| {
+ // Status badge with color based on status
+ let variant = match info.status {
+ SubagentStatus::Running => BadgeVariant::Warning,
+ SubagentStatus::Completed => BadgeVariant::Success,
+ SubagentStatus::Failed => BadgeVariant::Destructive,
+ };
+ StatusBadge::new(&info.subagent_type)
+ .variant(variant)
+ .show(ui);
+
+ // Description
+ ui.label(
+ egui::RichText::new(&info.description)
+ .size(11.0)
+ .color(ui.visuals().text_color().gamma_multiply(0.7)),
+ );
+
+ // Show spinner for running subagents
+ if info.status == SubagentStatus::Running {
+ ui.add(egui::Spinner::new().size(11.0));
+ }
+ });
+ }
+
+ fn search_call_ui(
+ ctx: &mut AppContext,
+ query_call: &crate::tools::QueryCall,
+ ui: &mut egui::Ui,
+ ) {
ui.add(search_icon(16.0, 16.0));
ui.add_space(8.0);
@@ -306,7 +957,28 @@ impl<'a> DaveUi<'a> {
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
let mut dave_response = DaveResponse::none();
- if ui
+
+ // Show Stop button when working, Ask button otherwise
+ if self.is_working {
+ if ui
+ .add(egui::Button::new(tr!(
+ i18n,
+ "Stop",
+ "Button to interrupt/stop the AI operation"
+ )))
+ .clicked()
+ {
+ dave_response = DaveResponse::new(DaveAction::Interrupt);
+ }
+
+ // Show "Press Esc again" indicator when interrupt is pending
+ if self.interrupt_pending {
+ ui.label(
+ egui::RichText::new("Press Esc again to stop")
+ .color(ui.visuals().warn_fg_color),
+ );
+ }
+ } else if ui
.add(egui::Button::new(tr!(
i18n,
"Ask",
@@ -317,6 +989,39 @@ impl<'a> DaveUi<'a> {
dave_response = DaveResponse::send();
}
+ // Show plan mode and auto-steal indicators only in Agentic mode
+ if self.ai_mode == AiMode::Agentic {
+ let ctrl_held = ui.input(|i| i.modifiers.ctrl);
+
+ // Plan mode indicator with optional keybind hint when Ctrl is held
+ let mut plan_badge =
+ super::badge::StatusBadge::new("PLAN").variant(if self.plan_mode_active {
+ super::badge::BadgeVariant::Info
+ } else {
+ super::badge::BadgeVariant::Default
+ });
+ if ctrl_held {
+ plan_badge = plan_badge.keybind("M");
+ }
+ plan_badge
+ .show(ui)
+ .on_hover_text("Ctrl+M to toggle plan mode");
+
+ // Auto-steal focus indicator
+ let mut auto_badge =
+ super::badge::StatusBadge::new("AUTO").variant(if self.auto_steal_focus {
+ super::badge::BadgeVariant::Info
+ } else {
+ super::badge::BadgeVariant::Default
+ });
+ if ctrl_held {
+ auto_badge = auto_badge.keybind("\\");
+ }
+ auto_badge
+ .show(ui)
+ .on_hover_text("Ctrl+\\ to toggle auto-focus mode");
+ }
+
let r = ui.add(
egui::TextEdit::multiline(self.input)
.desired_width(f32::INFINITY)
@@ -339,6 +1044,20 @@ impl<'a> DaveUi<'a> {
);
notedeck_ui::include_input(ui, &r);
+ // Request focus if flagged (e.g., after spawning a new agent or entering tentative state)
+ if *self.focus_requested {
+ r.request_focus();
+ *self.focus_requested = false;
+ }
+
+ // Unfocus text input when there's a pending permission request
+ // UNLESS we're in tentative state (user needs to type message)
+ let in_tentative_state =
+ self.permission_message_state != PermissionMessageState::None;
+ if self.has_pending_permission && !in_tentative_state {
+ r.surrender_focus();
+ }
+
if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
DaveResponse::send()
} else {
@@ -357,159 +1076,22 @@ impl<'a> DaveUi<'a> {
.corner_radius(10.0)
.fill(ui.visuals().widgets.inactive.weak_bg_fill)
.show(ui, |ui| {
- ui.label(msg);
+ ui.add(
+ egui::Label::new(msg)
+ .wrap_mode(egui::TextWrapMode::Wrap)
+ .selectable(true),
+ );
})
});
}
fn assistant_chat(&self, msg: &str, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
- ui.add(egui::Label::new(msg).wrap_mode(egui::TextWrapMode::Wrap));
- });
- }
-}
-
-fn query_call_ui(
- cache: &mut notedeck::Images,
- ndb: &Ndb,
- query: &QueryCall,
- jobs: &MediaJobSender,
- ui: &mut egui::Ui,
-) {
- ui.spacing_mut().item_spacing.x = 8.0;
- if let Some(pubkey) = query.author() {
- let txn = Transaction::new(ndb).unwrap();
- pill_label_ui(
- "author",
- move |ui| {
- ui.add(
- &mut ProfilePic::from_profile_or_default(
- cache,
- jobs,
- ndb.get_profile_by_pubkey(&txn, pubkey.bytes())
- .ok()
- .as_ref(),
- )
- .size(ProfilePic::small_size() as f32),
- );
- },
- ui,
- );
- }
-
- if let Some(limit) = query.limit {
- pill_label("limit", &limit.to_string(), ui);
- }
-
- if let Some(since) = query.since {
- pill_label("since", &since.to_string(), ui);
- }
-
- if let Some(kind) = query.kind {
- pill_label("kind", &kind.to_string(), ui);
- }
-
- if let Some(until) = query.until {
- pill_label("until", &until.to_string(), ui);
- }
-
- if let Some(search) = query.search.as_ref() {
- pill_label("search", search, ui);
- }
-}
-
-fn pill_label(name: &str, value: &str, ui: &mut egui::Ui) {
- pill_label_ui(
- name,
- move |ui| {
- ui.label(value);
- },
- ui,
- );
-}
-
-fn pill_label_ui(name: &str, mut value: impl FnMut(&mut egui::Ui), ui: &mut egui::Ui) {
- egui::Frame::new()
- .fill(ui.visuals().noninteractive().bg_fill)
- .inner_margin(egui::Margin::same(4))
- .corner_radius(egui::CornerRadius::same(10))
- .stroke(egui::Stroke::new(
- 1.0,
- ui.visuals().noninteractive().bg_stroke.color,
- ))
- .show(ui, |ui| {
- egui::Frame::new()
- .fill(ui.visuals().noninteractive().weak_bg_fill)
- .inner_margin(egui::Margin::same(4))
- .corner_radius(egui::CornerRadius::same(10))
- .stroke(egui::Stroke::new(
- 1.0,
- ui.visuals().noninteractive().bg_stroke.color,
- ))
- .show(ui, |ui| {
- ui.label(name);
- });
-
- value(ui);
+ ui.add(
+ egui::Label::new(msg)
+ .wrap_mode(egui::TextWrapMode::Wrap)
+ .selectable(true),
+ );
});
-}
-
-fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAction> {
- // Scroll area for chat messages
- let mut action: Option<DaveAction> = None;
- let mut rect = ui.available_rect_before_wrap();
- rect = rect.translate(egui::vec2(20.0, 20.0));
- rect.set_height(32.0);
- rect.set_width(32.0);
-
- // Show session list button on mobile/narrow screens
- if notedeck::ui::is_narrow(ui.ctx()) {
- let r = ui
- .put(rect, egui::Button::new("\u{2630}").frame(false))
- .on_hover_text("Show chats")
- .on_hover_cursor(egui::CursorIcon::PointingHand);
-
- if r.clicked() {
- action = Some(DaveAction::ShowSessionList);
- }
-
- rect = rect.translate(egui::vec2(30.0, 0.0));
}
-
- let txn = Transaction::new(app_ctx.ndb).unwrap();
- let r = ui
- .put(
- rect,
- &mut pfp_button(
- &txn,
- app_ctx.accounts,
- app_ctx.img_cache,
- app_ctx.ndb,
- app_ctx.media_jobs.sender(),
- ),
- )
- .on_hover_cursor(egui::CursorIcon::PointingHand);
-
- if r.clicked() {
- action = Some(DaveAction::ToggleChrome);
- }
-
- action
-}
-
-fn pfp_button<'me, 'a>(
- txn: &'a Transaction,
- accounts: &Accounts,
- img_cache: &'me mut Images,
- ndb: &Ndb,
- jobs: &'me MediaJobSender,
-) -> ProfilePic<'me, 'a> {
- let account = accounts.get_selected_account();
- let profile = ndb
- .get_profile_by_pubkey(txn, account.key.pubkey.bytes())
- .ok();
-
- ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref())
- .size(24.0)
- .sense(egui::Sense::click())
}
diff --git a/crates/notedeck_dave/src/ui/diff.rs b/crates/notedeck_dave/src/ui/diff.rs
@@ -0,0 +1,95 @@
+use super::super::file_update::{DiffLine, DiffTag, FileUpdate, FileUpdateType};
+use egui::{Color32, RichText, Ui};
+
+/// Colors for diff rendering
+const DELETE_COLOR: Color32 = Color32::from_rgb(200, 60, 60);
+const INSERT_COLOR: Color32 = Color32::from_rgb(60, 180, 60);
+const LINE_NUMBER_COLOR: Color32 = Color32::from_rgb(128, 128, 128);
+
+/// Render a file update diff view
+pub fn file_update_ui(update: &FileUpdate, ui: &mut Ui) {
+ // Code block frame - no scroll, just show full diff height
+ egui::Frame::new()
+ .fill(ui.visuals().extreme_bg_color)
+ .inner_margin(8.0)
+ .corner_radius(4.0)
+ .show(ui, |ui| {
+ render_diff_lines(update.diff_lines(), &update.update_type, ui);
+ });
+}
+
+/// Render the diff lines with proper coloring
+fn render_diff_lines(lines: &[DiffLine], update_type: &FileUpdateType, ui: &mut Ui) {
+ // Track line numbers for old and new
+ let mut old_line = 1usize;
+ let mut new_line = 1usize;
+
+ for diff_line in lines {
+ ui.horizontal(|ui| {
+ // Line number gutter
+ let (old_num, new_num) = match diff_line.tag {
+ DiffTag::Equal => {
+ let result = (Some(old_line), Some(new_line));
+ old_line += 1;
+ new_line += 1;
+ result
+ }
+ DiffTag::Delete => {
+ let result = (Some(old_line), None);
+ old_line += 1;
+ result
+ }
+ DiffTag::Insert => {
+ let result = (None, Some(new_line));
+ new_line += 1;
+ result
+ }
+ };
+
+ // Render line numbers (only for edits, not writes)
+ if matches!(update_type, FileUpdateType::Edit { .. }) {
+ let old_str = old_num
+ .map(|n| format!("{:4}", n))
+ .unwrap_or_else(|| " ".to_string());
+ let new_str = new_num
+ .map(|n| format!("{:4}", n))
+ .unwrap_or_else(|| " ".to_string());
+
+ ui.label(
+ RichText::new(format!("{} {}", old_str, new_str))
+ .monospace()
+ .size(11.0)
+ .color(LINE_NUMBER_COLOR),
+ );
+ }
+
+ // Render the prefix and content
+ let (prefix, color) = match diff_line.tag {
+ DiffTag::Equal => (" ", ui.visuals().text_color()),
+ DiffTag::Delete => ("-", DELETE_COLOR),
+ DiffTag::Insert => ("+", INSERT_COLOR),
+ };
+
+ // Remove trailing newline for display
+ let content = diff_line.content.trim_end_matches('\n');
+
+ ui.label(
+ RichText::new(format!("{} {}", prefix, content))
+ .monospace()
+ .size(12.0)
+ .color(color),
+ );
+ });
+ }
+}
+
+/// Render the file path header (call within a horizontal layout)
+pub fn file_path_header(update: &FileUpdate, ui: &mut Ui) {
+ let type_label = match &update.update_type {
+ FileUpdateType::Edit { .. } => "Edit",
+ FileUpdateType::Write { .. } => "Write",
+ };
+
+ ui.label(RichText::new(type_label).strong());
+ ui.label(RichText::new(&update.file_path).monospace());
+}
diff --git a/crates/notedeck_dave/src/ui/directory_picker.rs b/crates/notedeck_dave/src/ui/directory_picker.rs
@@ -0,0 +1,345 @@
+use crate::ui::keybind_hint::paint_keybind_hint;
+use crate::ui::path_utils::abbreviate_path;
+use egui::{RichText, Vec2};
+use std::path::PathBuf;
+
+/// Maximum number of recent directories to store
+const MAX_RECENT_DIRECTORIES: usize = 10;
+
+/// Actions that can be triggered from the directory picker
+#[derive(Debug, Clone)]
+pub enum DirectoryPickerAction {
+ /// User selected a directory
+ DirectorySelected(PathBuf),
+ /// User cancelled the picker
+ Cancelled,
+ /// User requested to browse for a directory (opens native dialog)
+ BrowseRequested,
+}
+
+/// State for the directory picker modal
+pub struct DirectoryPicker {
+ /// List of recently used directories
+ pub recent_directories: Vec<PathBuf>,
+ /// Whether the picker is currently open
+ pub is_open: bool,
+ /// Text input for manual path entry
+ path_input: String,
+ /// Pending async folder picker result
+ pending_folder_pick: Option<std::sync::mpsc::Receiver<Option<PathBuf>>>,
+}
+
+impl Default for DirectoryPicker {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl DirectoryPicker {
+ pub fn new() -> Self {
+ Self {
+ recent_directories: Vec::new(),
+ is_open: false,
+ path_input: String::new(),
+ pending_folder_pick: None,
+ }
+ }
+
+ /// Open the picker
+ pub fn open(&mut self) {
+ self.is_open = true;
+ self.path_input.clear();
+ }
+
+ /// Close the picker
+ pub fn close(&mut self) {
+ self.is_open = false;
+ self.pending_folder_pick = None;
+ }
+
+ /// Add a directory to the recent list
+ pub fn add_recent(&mut self, path: PathBuf) {
+ // Remove if already exists (we'll re-add at front)
+ self.recent_directories.retain(|p| p != &path);
+ // Add to front
+ self.recent_directories.insert(0, path);
+ // Trim to max size
+ self.recent_directories.truncate(MAX_RECENT_DIRECTORIES);
+ }
+
+ /// Check for pending folder picker result
+ fn check_pending_pick(&mut self) -> Option<PathBuf> {
+ if let Some(rx) = &self.pending_folder_pick {
+ match rx.try_recv() {
+ Ok(Some(path)) => {
+ self.pending_folder_pick = None;
+ return Some(path);
+ }
+ Ok(None) => {
+ // User cancelled the dialog
+ self.pending_folder_pick = None;
+ }
+ Err(std::sync::mpsc::TryRecvError::Disconnected) => {
+ self.pending_folder_pick = None;
+ }
+ Err(std::sync::mpsc::TryRecvError::Empty) => {
+ // Still waiting
+ }
+ }
+ }
+ None
+ }
+
+ /// Render the directory picker as a full-panel overlay
+ /// `has_sessions` indicates whether there are existing sessions (enables cancel)
+ pub fn overlay_ui(
+ &mut self,
+ ui: &mut egui::Ui,
+ has_sessions: bool,
+ ) -> Option<DirectoryPickerAction> {
+ // Check for pending folder pick result first
+ if let Some(path) = self.check_pending_pick() {
+ return Some(DirectoryPickerAction::DirectorySelected(path));
+ }
+
+ let mut action = None;
+ let is_narrow = notedeck::ui::is_narrow(ui.ctx());
+ let ctrl_held = ui.input(|i| i.modifiers.ctrl);
+
+ // Handle keyboard shortcuts for recent directories (Ctrl+1-9)
+ // Only trigger when Ctrl is held to avoid intercepting TextEdit input
+ if ctrl_held {
+ for (idx, path) in self.recent_directories.iter().take(9).enumerate() {
+ let key = match idx {
+ 0 => egui::Key::Num1,
+ 1 => egui::Key::Num2,
+ 2 => egui::Key::Num3,
+ 3 => egui::Key::Num4,
+ 4 => egui::Key::Num5,
+ 5 => egui::Key::Num6,
+ 6 => egui::Key::Num7,
+ 7 => egui::Key::Num8,
+ 8 => egui::Key::Num9,
+ _ => continue,
+ };
+ if ui.input(|i| i.key_pressed(key)) {
+ return Some(DirectoryPickerAction::DirectorySelected(path.clone()));
+ }
+ }
+ }
+
+ // Handle Ctrl+B key for browse (track whether we need to trigger it)
+ // Only trigger when Ctrl is held to avoid intercepting TextEdit input
+ let trigger_browse = ctrl_held
+ && ui.input(|i| i.key_pressed(egui::Key::B))
+ && self.pending_folder_pick.is_none();
+
+ // Full panel frame
+ egui::Frame::new()
+ .fill(ui.visuals().panel_fill)
+ .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20))
+ .show(ui, |ui| {
+ // Header
+ ui.horizontal(|ui| {
+ // Only show back button if there are existing sessions
+ if has_sessions {
+ if ui.button("< Back").clicked() {
+ action = Some(DirectoryPickerAction::Cancelled);
+ }
+ ui.add_space(16.0);
+ }
+ ui.heading("Select Working Directory");
+ });
+
+ ui.add_space(16.0);
+
+ // Centered content (max width for desktop)
+ let max_content_width = if is_narrow {
+ ui.available_width()
+ } else {
+ 500.0
+ };
+ let available_height = ui.available_height();
+
+ ui.allocate_ui_with_layout(
+ egui::vec2(max_content_width, available_height),
+ egui::Layout::top_down(egui::Align::LEFT),
+ |ui| {
+ // Recent directories section
+ if !self.recent_directories.is_empty() {
+ ui.label(RichText::new("Recent Directories").strong());
+ ui.add_space(8.0);
+
+ // Use more vertical space on mobile
+ let scroll_height = if is_narrow {
+ (ui.available_height() - 150.0).max(100.0)
+ } else {
+ 300.0
+ };
+
+ egui::ScrollArea::vertical()
+ .max_height(scroll_height)
+ .show(ui, |ui| {
+ for (idx, path) in
+ self.recent_directories.clone().iter().enumerate()
+ {
+ let display = abbreviate_path(path);
+
+ // Full-width button style with larger touch targets on mobile
+ let button_height = if is_narrow { 44.0 } else { 32.0 };
+ let hint_width =
+ if ctrl_held && idx < 9 { 24.0 } else { 0.0 };
+ let button_width = ui.available_width() - hint_width - 4.0;
+
+ ui.horizontal(|ui| {
+ let button = egui::Button::new(
+ RichText::new(&display).monospace(),
+ )
+ .min_size(Vec2::new(button_width, button_height))
+ .fill(ui.visuals().widgets.inactive.weak_bg_fill);
+
+ let response = ui.add(button);
+
+ // Show keybind hint when Ctrl is held (for first 9 items)
+ if ctrl_held && idx < 9 {
+ let hint_text = format!("{}", idx + 1);
+ let hint_center = response.rect.right_center()
+ + egui::vec2(hint_width / 2.0 + 2.0, 0.0);
+ paint_keybind_hint(
+ ui,
+ hint_center,
+ &hint_text,
+ 18.0,
+ );
+ }
+
+ if response
+ .on_hover_text(path.display().to_string())
+ .clicked()
+ {
+ action =
+ Some(DirectoryPickerAction::DirectorySelected(
+ path.clone(),
+ ));
+ }
+ });
+
+ ui.add_space(4.0);
+ }
+ });
+
+ ui.add_space(16.0);
+ ui.separator();
+ ui.add_space(12.0);
+ }
+
+ // Browse button (larger touch target on mobile)
+ ui.horizontal(|ui| {
+ let browse_button =
+ egui::Button::new(RichText::new("Browse...").size(if is_narrow {
+ 16.0
+ } else {
+ 14.0
+ }))
+ .min_size(Vec2::new(
+ if is_narrow {
+ ui.available_width() - 28.0
+ } else {
+ 120.0
+ },
+ if is_narrow { 48.0 } else { 32.0 },
+ ));
+
+ let response = ui.add(browse_button);
+
+ // Show keybind hint when Ctrl is held
+ if ctrl_held {
+ let hint_center =
+ response.rect.right_center() + egui::vec2(14.0, 0.0);
+ paint_keybind_hint(ui, hint_center, "B", 18.0);
+ }
+
+ #[cfg(any(
+ target_os = "windows",
+ target_os = "macos",
+ target_os = "linux"
+ ))]
+ if response
+ .on_hover_text("Open folder picker dialog (B)")
+ .clicked()
+ || trigger_browse
+ {
+ // Spawn async folder picker
+ let (tx, rx) = std::sync::mpsc::channel();
+ let ctx_clone = ui.ctx().clone();
+ std::thread::spawn(move || {
+ let result = rfd::FileDialog::new().pick_folder();
+ let _ = tx.send(result);
+ ctx_clone.request_repaint();
+ });
+ self.pending_folder_pick = Some(rx);
+ }
+
+ // On platforms without rfd (e.g., Android), just show the button disabled
+ #[cfg(not(any(
+ target_os = "windows",
+ target_os = "macos",
+ target_os = "linux"
+ )))]
+ {
+ let _ = response;
+ let _ = trigger_browse;
+ }
+ });
+
+ if self.pending_folder_pick.is_some() {
+ ui.horizontal(|ui| {
+ ui.spinner();
+ ui.label("Opening dialog...");
+ });
+ }
+
+ ui.add_space(16.0);
+
+ // Manual path input
+ ui.label("Or enter path:");
+ ui.add_space(4.0);
+
+ let response = ui.add(
+ egui::TextEdit::singleline(&mut self.path_input)
+ .hint_text("/path/to/project")
+ .desired_width(ui.available_width()),
+ );
+
+ ui.add_space(8.0);
+
+ let go_button = egui::Button::new("Go").min_size(Vec2::new(
+ if is_narrow {
+ ui.available_width()
+ } else {
+ 50.0
+ },
+ if is_narrow { 44.0 } else { 28.0 },
+ ));
+
+ if ui.add(go_button).clicked()
+ || response.lost_focus()
+ && ui.input(|i| i.key_pressed(egui::Key::Enter))
+ {
+ let path = PathBuf::from(&self.path_input);
+ if path.exists() && path.is_dir() {
+ action = Some(DirectoryPickerAction::DirectorySelected(path));
+ }
+ }
+ },
+ );
+ });
+
+ // Handle Escape key (only if cancellation is allowed)
+ if has_sessions && ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
+ action = Some(DirectoryPickerAction::Cancelled);
+ }
+
+ action
+ }
+}
diff --git a/crates/notedeck_dave/src/ui/keybind_hint.rs b/crates/notedeck_dave/src/ui/keybind_hint.rs
@@ -0,0 +1,76 @@
+use egui::{Pos2, Rect, Response, Sense, Ui, Vec2};
+
+/// A visual keybinding hint - a small framed box with a letter or number inside.
+/// Used to indicate keyboard shortcuts in the UI.
+pub struct KeybindHint<'a> {
+ text: &'a str,
+ size: f32,
+}
+
+impl<'a> KeybindHint<'a> {
+ /// Create a new keybinding hint with the given text
+ pub fn new(text: &'a str) -> Self {
+ Self { text, size: 18.0 }
+ }
+
+ /// Set the size of the hint box (default: 18.0)
+ pub fn size(mut self, size: f32) -> Self {
+ self.size = size;
+ self
+ }
+
+ /// Show the keybinding hint and return the response
+ pub fn show(self, ui: &mut Ui) -> Response {
+ let (rect, response) = ui.allocate_exact_size(Vec2::splat(self.size), Sense::hover());
+ self.paint(ui, rect);
+ response
+ }
+
+ /// Paint the keybinding hint at a specific position (for use with painters)
+ pub fn paint_at(self, ui: &Ui, center: Pos2) {
+ let rect = Rect::from_center_size(center, Vec2::splat(self.size));
+ self.paint(ui, rect);
+ }
+
+ fn paint(self, ui: &Ui, rect: Rect) {
+ let painter = ui.painter();
+ let visuals = ui.visuals();
+
+ // Frame/border
+ let stroke_color = visuals.widgets.noninteractive.fg_stroke.color;
+ let bg_color = visuals.widgets.noninteractive.bg_fill;
+ let corner_radius = 3.0;
+
+ // Background fill
+ painter.rect_filled(rect, corner_radius, bg_color);
+
+ // Border stroke
+ painter.rect_stroke(
+ rect,
+ corner_radius,
+ egui::Stroke::new(1.0, stroke_color.gamma_multiply(0.6)),
+ egui::StrokeKind::Inside,
+ );
+
+ // Text in center (slight vertical nudge for better optical centering)
+ let font_size = self.size * 0.65;
+ let text_pos = rect.center() + Vec2::new(0.0, 2.0);
+ painter.text(
+ text_pos,
+ egui::Align2::CENTER_CENTER,
+ self.text,
+ egui::FontId::monospace(font_size),
+ visuals.text_color(),
+ );
+ }
+}
+
+/// Draw a keybinding hint inline (for use in horizontal layouts)
+pub fn keybind_hint(ui: &mut Ui, text: &str) -> Response {
+ KeybindHint::new(text).show(ui)
+}
+
+/// Draw a keybinding hint at a specific position using the painter
+pub fn paint_keybind_hint(ui: &Ui, center: Pos2, text: &str, size: f32) {
+ KeybindHint::new(text).size(size).paint_at(ui, center);
+}
diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs
@@ -0,0 +1,217 @@
+use crate::config::AiMode;
+use egui::Key;
+
+/// Keybinding actions that can be triggered globally
+#[derive(Debug, Clone, PartialEq)]
+pub enum KeyAction {
+ /// Accept/Allow a pending permission request
+ AcceptPermission,
+ /// Deny a pending permission request
+ DenyPermission,
+ /// Tentatively accept, waiting for message (Shift+1)
+ TentativeAccept,
+ /// Tentatively deny, waiting for message (Shift+2)
+ TentativeDeny,
+ /// Cancel tentative state (Escape when tentative)
+ CancelTentative,
+ /// Switch to agent by number (0-indexed)
+ SwitchToAgent(usize),
+ /// Cycle to next agent
+ NextAgent,
+ /// Cycle to previous agent
+ PreviousAgent,
+ /// Spawn a new agent (Ctrl+T)
+ NewAgent,
+ /// Interrupt/stop the current AI operation
+ Interrupt,
+ /// Toggle between scene view and classic view
+ ToggleView,
+ /// Toggle plan mode for the active session (Ctrl+M)
+ TogglePlanMode,
+ /// Delete the active session
+ DeleteActiveSession,
+ /// Navigate to next item in focus queue (Ctrl+N)
+ FocusQueueNext,
+ /// Navigate to previous item in focus queue (Ctrl+P)
+ FocusQueuePrev,
+ /// Toggle Done status for current focus queue item (Ctrl+D)
+ FocusQueueToggleDone,
+ /// Toggle auto-steal focus mode (Ctrl+\)
+ ToggleAutoSteal,
+ /// Open external editor for composing input (Ctrl+G)
+ OpenExternalEditor,
+ /// Clone the active agent with the same working directory (Ctrl+Shift+T)
+ CloneAgent,
+}
+
+/// Check for keybinding actions.
+/// Most keybindings use Ctrl modifier to avoid conflicts with text input.
+/// Exception: 1/2 for permission responses work without Ctrl but only when no text input has focus.
+/// In Chat mode, agentic-specific keybindings (scene view, plan mode, focus queue) are disabled.
+pub fn check_keybindings(
+ ctx: &egui::Context,
+ has_pending_permission: bool,
+ has_pending_question: bool,
+ in_tentative_state: bool,
+ ai_mode: AiMode,
+) -> Option<KeyAction> {
+ let is_agentic = ai_mode == AiMode::Agentic;
+
+ // Escape in tentative state cancels the tentative mode (agentic only)
+ if is_agentic && in_tentative_state && ctx.input(|i| i.key_pressed(Key::Escape)) {
+ return Some(KeyAction::CancelTentative);
+ }
+
+ // Escape otherwise works to interrupt AI (even when text input has focus)
+ if ctx.input(|i| i.key_pressed(Key::Escape)) {
+ return Some(KeyAction::Interrupt);
+ }
+
+ let ctrl = egui::Modifiers::CTRL;
+ let ctrl_shift = egui::Modifiers::CTRL | egui::Modifiers::SHIFT;
+
+ // Ctrl+Tab / Ctrl+Shift+Tab for cycling through agents/chats
+ // Works even with text input focus since Ctrl modifier makes it unambiguous
+ // IMPORTANT: Check Ctrl+Shift+Tab first because consume_key uses matches_logically
+ // which ignores extra Shift, so Ctrl+Tab would consume Ctrl+Shift+Tab otherwise
+ if let Some(action) = ctx.input_mut(|i| {
+ if i.consume_key(ctrl_shift, Key::Tab) {
+ Some(KeyAction::PreviousAgent)
+ } else if i.consume_key(ctrl, Key::Tab) {
+ Some(KeyAction::NextAgent)
+ } else {
+ None
+ }
+ }) {
+ return Some(action);
+ }
+
+ // Focus queue navigation - agentic only
+ if is_agentic {
+ // Ctrl+N for higher priority (toward NeedsInput)
+ if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::N)) {
+ return Some(KeyAction::FocusQueueNext);
+ }
+
+ // Ctrl+P for lower priority (toward Done)
+ if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::P)) {
+ return Some(KeyAction::FocusQueuePrev);
+ }
+ }
+
+ // Ctrl+Shift+T to clone the active agent (check before Ctrl+T) - agentic only
+ if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl_shift) && i.key_pressed(Key::T)) {
+ return Some(KeyAction::CloneAgent);
+ }
+
+ // Ctrl+T to spawn a new agent/chat
+ if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::T)) {
+ return Some(KeyAction::NewAgent);
+ }
+
+ // Ctrl+L to toggle between scene view and list view - agentic only
+ if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::L)) {
+ return Some(KeyAction::ToggleView);
+ }
+
+ // Ctrl+G to open external editor for composing input
+ if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::G)) {
+ return Some(KeyAction::OpenExternalEditor);
+ }
+
+ // Ctrl+M to toggle plan mode - agentic only
+ if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::M)) {
+ return Some(KeyAction::TogglePlanMode);
+ }
+
+ // Ctrl+D to toggle Done status for current focus queue item - agentic only
+ if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::D)) {
+ return Some(KeyAction::FocusQueueToggleDone);
+ }
+
+ // Ctrl+\ to toggle auto-steal focus mode (Ctrl+Space conflicts with macOS input source switching) - agentic only
+ if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::Backslash))
+ {
+ return Some(KeyAction::ToggleAutoSteal);
+ }
+
+ // Delete key to delete active session (only when no text input has focus)
+ if !ctx.wants_keyboard_input() && ctx.input(|i| i.key_pressed(Key::Delete)) {
+ return Some(KeyAction::DeleteActiveSession);
+ }
+
+ // Ctrl+1-9 for switching agents/chats (works even with text input focus)
+ // Check this BEFORE permission bindings so Ctrl+number always switches agents
+ if let Some(action) = ctx.input(|i| {
+ if !i.modifiers.matches_exact(ctrl) {
+ return None;
+ }
+
+ for (idx, key) in [
+ Key::Num1,
+ Key::Num2,
+ Key::Num3,
+ Key::Num4,
+ Key::Num5,
+ Key::Num6,
+ Key::Num7,
+ Key::Num8,
+ Key::Num9,
+ ]
+ .iter()
+ .enumerate()
+ {
+ if i.key_pressed(*key) {
+ return Some(KeyAction::SwitchToAgent(idx));
+ }
+ }
+
+ None
+ }) {
+ return Some(action);
+ }
+
+ // Permission keybindings - agentic only
+ // When there's a pending permission (but NOT an AskUserQuestion):
+ // - 1 = accept, 2 = deny (no modifiers)
+ // - Shift+1 = tentative accept, Shift+2 = tentative deny (for adding message)
+ // This is checked AFTER Ctrl+number so Ctrl bindings take precedence
+ // IMPORTANT: Only handle these when no text input has focus, to avoid
+ // capturing keypresses when user is typing a message in tentative state
+ // AskUserQuestion uses number keys for option selection, so we skip these bindings
+ if is_agentic && has_pending_permission && !has_pending_question && !ctx.wants_keyboard_input()
+ {
+ // Shift+1 = tentative accept, Shift+2 = tentative deny
+ // Note: egui may report shifted keys as their symbol (e.g., Shift+1 as Exclamationmark)
+ // We check for both the symbol key and Shift+Num key to handle different behaviors
+ if let Some(action) = ctx.input_mut(|i| {
+ // Shift+1: check for '!' (Exclamationmark) which egui reports on some systems
+ if i.key_pressed(Key::Exclamationmark) {
+ return Some(KeyAction::TentativeAccept);
+ }
+ // Shift+2: check with shift modifier (egui may report Num2 with shift held)
+ if i.modifiers.shift && i.key_pressed(Key::Num2) {
+ return Some(KeyAction::TentativeDeny);
+ }
+ None
+ }) {
+ return Some(action);
+ }
+
+ // Bare keypresses (no modifiers) for immediate accept/deny
+ if let Some(action) = ctx.input(|i| {
+ if !i.modifiers.any() {
+ if i.key_pressed(Key::Num1) {
+ return Some(KeyAction::AcceptPermission);
+ } else if i.key_pressed(Key::Num2) {
+ return Some(KeyAction::DenyPermission);
+ }
+ }
+ None
+ }) {
+ return Some(action);
+ }
+ }
+
+ None
+}
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -1,5 +1,724 @@
+mod ask_question;
+pub mod badge;
mod dave;
+pub mod diff;
+pub mod directory_picker;
+pub mod keybind_hint;
+pub mod keybindings;
+pub mod path_utils;
+mod pill;
+mod query_ui;
+pub mod scene;
pub mod session_list;
+pub mod session_picker;
+mod settings;
+mod top_buttons;
+pub use ask_question::{ask_user_question_summary_ui, ask_user_question_ui};
pub use dave::{DaveAction, DaveResponse, DaveUi};
+pub use directory_picker::{DirectoryPicker, DirectoryPickerAction};
+pub use keybind_hint::{keybind_hint, paint_keybind_hint};
+pub use keybindings::{check_keybindings, KeyAction};
+pub use scene::{AgentScene, SceneAction, SceneResponse};
pub use session_list::{SessionListAction, SessionListUi};
+pub use session_picker::{SessionPicker, SessionPickerAction};
+pub use settings::{DaveSettingsPanel, SettingsPanelAction};
+
+// =============================================================================
+// Standalone UI Functions
+// =============================================================================
+
+use crate::agent_status::AgentStatus;
+use crate::config::{AiMode, DaveSettings, ModelConfig};
+use crate::focus_queue::FocusQueue;
+use crate::messages::PermissionResponse;
+use crate::session::{PermissionMessageState, SessionId, SessionManager};
+use crate::session_discovery::discover_sessions;
+use crate::update;
+use crate::DaveOverlay;
+
+/// UI result from overlay rendering
+pub enum OverlayResult {
+ /// No action taken
+ None,
+ /// Close the overlay
+ Close,
+ /// Directory was selected (no resumable sessions)
+ DirectorySelected(std::path::PathBuf),
+ /// Show session picker for the given directory
+ ShowSessionPicker(std::path::PathBuf),
+ /// Resume a session
+ ResumeSession {
+ cwd: std::path::PathBuf,
+ session_id: String,
+ title: String,
+ },
+ /// Create a new session in the given directory
+ NewSession { cwd: std::path::PathBuf },
+ /// Go back to directory picker
+ BackToDirectoryPicker,
+ /// Apply new settings
+ ApplySettings(DaveSettings),
+}
+
+/// Render the settings overlay UI.
+pub fn settings_overlay_ui(
+ settings_panel: &mut DaveSettingsPanel,
+ settings: &DaveSettings,
+ ui: &mut egui::Ui,
+) -> OverlayResult {
+ if let Some(action) = settings_panel.overlay_ui(ui, settings) {
+ match action {
+ SettingsPanelAction::Save(new_settings) => {
+ return OverlayResult::ApplySettings(new_settings);
+ }
+ SettingsPanelAction::Cancel => {
+ return OverlayResult::Close;
+ }
+ }
+ }
+ OverlayResult::None
+}
+
+/// Render the directory picker overlay UI.
+pub fn directory_picker_overlay_ui(
+ directory_picker: &mut DirectoryPicker,
+ has_sessions: bool,
+ ui: &mut egui::Ui,
+) -> OverlayResult {
+ if let Some(action) = directory_picker.overlay_ui(ui, has_sessions) {
+ match action {
+ DirectoryPickerAction::DirectorySelected(path) => {
+ let resumable_sessions = discover_sessions(&path);
+ if resumable_sessions.is_empty() {
+ return OverlayResult::DirectorySelected(path);
+ } else {
+ return OverlayResult::ShowSessionPicker(path);
+ }
+ }
+ DirectoryPickerAction::Cancelled => {
+ if has_sessions {
+ return OverlayResult::Close;
+ }
+ }
+ DirectoryPickerAction::BrowseRequested => {}
+ }
+ }
+ OverlayResult::None
+}
+
+/// Render the session picker overlay UI.
+pub fn session_picker_overlay_ui(
+ session_picker: &mut SessionPicker,
+ ui: &mut egui::Ui,
+) -> OverlayResult {
+ if let Some(action) = session_picker.overlay_ui(ui) {
+ match action {
+ SessionPickerAction::ResumeSession {
+ cwd,
+ session_id,
+ title,
+ } => {
+ return OverlayResult::ResumeSession {
+ cwd,
+ session_id,
+ title,
+ };
+ }
+ SessionPickerAction::NewSession { cwd } => {
+ return OverlayResult::NewSession { cwd };
+ }
+ SessionPickerAction::BackToDirectoryPicker => {
+ return OverlayResult::BackToDirectoryPicker;
+ }
+ }
+ }
+ OverlayResult::None
+}
+
+/// Scene view action returned after rendering
+pub enum SceneViewAction {
+ None,
+ ToggleToListView,
+ SpawnAgent,
+ DeleteSelected(Vec<SessionId>),
+}
+
+/// Render the scene view with RTS-style agent visualization and chat side panel.
+#[allow(clippy::too_many_arguments)]
+pub fn scene_ui(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ focus_queue: &FocusQueue,
+ model_config: &ModelConfig,
+ is_interrupt_pending: bool,
+ auto_steal_focus: bool,
+ app_ctx: &mut notedeck::AppContext,
+ ui: &mut egui::Ui,
+) -> (DaveResponse, SceneViewAction) {
+ use egui_extras::{Size, StripBuilder};
+
+ let mut dave_response = DaveResponse::default();
+ let mut scene_response_opt: Option<SceneResponse> = None;
+ let mut view_action = SceneViewAction::None;
+
+ let ctrl_held = ui.input(|i| i.modifiers.ctrl);
+
+ StripBuilder::new(ui)
+ .size(Size::relative(0.25))
+ .size(Size::remainder())
+ .clip(true)
+ .horizontal(|mut strip| {
+ strip.cell(|ui| {
+ ui.horizontal(|ui| {
+ if ui
+ .button("+ New Agent")
+ .on_hover_text("Hold Ctrl to see keybindings")
+ .clicked()
+ {
+ view_action = SceneViewAction::SpawnAgent;
+ }
+ if ctrl_held {
+ keybind_hint(ui, "N");
+ }
+ ui.separator();
+ if ui
+ .button("List View")
+ .on_hover_text("Ctrl+L to toggle views")
+ .clicked()
+ {
+ view_action = SceneViewAction::ToggleToListView;
+ }
+ if ctrl_held {
+ keybind_hint(ui, "L");
+ }
+ });
+ ui.separator();
+ scene_response_opt = Some(scene.ui(session_manager, focus_queue, ui, ctrl_held));
+ });
+
+ strip.cell(|ui| {
+ egui::Frame::new()
+ .fill(ui.visuals().faint_bg_color)
+ .inner_margin(egui::Margin::symmetric(8, 12))
+ .show(ui, |ui| {
+ if let Some(selected_id) = scene.primary_selection() {
+ if let Some(session) = session_manager.get_mut(selected_id) {
+ ui.heading(&session.title);
+ ui.separator();
+
+ let is_working = session.status() == AgentStatus::Working;
+ let has_pending_permission = session.has_pending_permissions();
+ let plan_mode_active = session.is_plan_mode();
+
+ let mut ui_builder = DaveUi::new(
+ model_config.trial,
+ &session.chat,
+ &mut session.input,
+ &mut session.focus_requested,
+ session.ai_mode,
+ )
+ .compact(true)
+ .is_working(is_working)
+ .interrupt_pending(is_interrupt_pending)
+ .has_pending_permission(has_pending_permission)
+ .plan_mode_active(plan_mode_active)
+ .auto_steal_focus(auto_steal_focus);
+
+ if let Some(agentic) = &mut session.agentic {
+ ui_builder = ui_builder
+ .permission_message_state(agentic.permission_message_state)
+ .question_answers(&mut agentic.question_answers)
+ .question_index(&mut agentic.question_index)
+ .is_compacting(agentic.is_compacting);
+ }
+
+ let response = ui_builder.ui(app_ctx, ui);
+ if response.action.is_some() {
+ dave_response = response;
+ }
+ }
+ } else {
+ ui.centered_and_justified(|ui| {
+ ui.label("Select an agent to view chat");
+ });
+ }
+ });
+ });
+ });
+
+ // Handle scene actions
+ if let Some(response) = scene_response_opt {
+ if let Some(action) = response.action {
+ match action {
+ SceneAction::SelectionChanged(ids) => {
+ if let Some(id) = ids.first() {
+ session_manager.switch_to(*id);
+ }
+ }
+ SceneAction::SpawnAgent => {
+ view_action = SceneViewAction::SpawnAgent;
+ }
+ SceneAction::DeleteSelected => {
+ view_action = SceneViewAction::DeleteSelected(scene.selected.clone());
+ }
+ SceneAction::AgentMoved { id, position } => {
+ if let Some(session) = session_manager.get_mut(id) {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.scene_position = position;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ (dave_response, view_action)
+}
+
+/// Desktop layout with sidebar for session list.
+#[allow(clippy::too_many_arguments)]
+pub fn desktop_ui(
+ session_manager: &mut SessionManager,
+ focus_queue: &FocusQueue,
+ model_config: &ModelConfig,
+ is_interrupt_pending: bool,
+ auto_steal_focus: bool,
+ ai_mode: AiMode,
+ app_ctx: &mut notedeck::AppContext,
+ ui: &mut egui::Ui,
+) -> (DaveResponse, Option<SessionListAction>, bool) {
+ let available = ui.available_rect_before_wrap();
+ let sidebar_width = 280.0;
+ let ctrl_held = ui.input(|i| i.modifiers.ctrl);
+ let mut toggle_scene = false;
+
+ let sidebar_rect =
+ egui::Rect::from_min_size(available.min, egui::vec2(sidebar_width, available.height()));
+ let chat_rect = egui::Rect::from_min_size(
+ egui::pos2(available.min.x + sidebar_width, available.min.y),
+ egui::vec2(available.width() - sidebar_width, available.height()),
+ );
+
+ let session_action = ui
+ .allocate_new_ui(egui::UiBuilder::new().max_rect(sidebar_rect), |ui| {
+ egui::Frame::new()
+ .fill(ui.visuals().faint_bg_color)
+ .inner_margin(egui::Margin::symmetric(8, 12))
+ .show(ui, |ui| {
+ if ai_mode == AiMode::Agentic {
+ ui.horizontal(|ui| {
+ if ui
+ .button("Scene View")
+ .on_hover_text("Ctrl+L to toggle views")
+ .clicked()
+ {
+ toggle_scene = true;
+ }
+ if ctrl_held {
+ keybind_hint(ui, "L");
+ }
+ });
+ ui.separator();
+ }
+ SessionListUi::new(session_manager, focus_queue, ctrl_held, ai_mode).ui(ui)
+ })
+ .inner
+ })
+ .inner;
+
+ let chat_response = ui
+ .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| {
+ if let Some(session) = session_manager.get_active_mut() {
+ let is_working = session.status() == AgentStatus::Working;
+ let has_pending_permission = session.has_pending_permissions();
+ let plan_mode_active = session.is_plan_mode();
+
+ let mut ui_builder = DaveUi::new(
+ model_config.trial,
+ &session.chat,
+ &mut session.input,
+ &mut session.focus_requested,
+ session.ai_mode,
+ )
+ .is_working(is_working)
+ .interrupt_pending(is_interrupt_pending)
+ .has_pending_permission(has_pending_permission)
+ .plan_mode_active(plan_mode_active)
+ .auto_steal_focus(auto_steal_focus);
+
+ if let Some(agentic) = &mut session.agentic {
+ ui_builder = ui_builder
+ .permission_message_state(agentic.permission_message_state)
+ .question_answers(&mut agentic.question_answers)
+ .question_index(&mut agentic.question_index)
+ .is_compacting(agentic.is_compacting);
+ }
+
+ ui_builder.ui(app_ctx, ui)
+ } else {
+ DaveResponse::default()
+ }
+ })
+ .inner;
+
+ (chat_response, session_action, toggle_scene)
+}
+
+/// Narrow/mobile layout - shows either session list or chat.
+#[allow(clippy::too_many_arguments)]
+pub fn narrow_ui(
+ session_manager: &mut SessionManager,
+ focus_queue: &FocusQueue,
+ model_config: &ModelConfig,
+ is_interrupt_pending: bool,
+ auto_steal_focus: bool,
+ ai_mode: AiMode,
+ show_session_list: bool,
+ app_ctx: &mut notedeck::AppContext,
+ ui: &mut egui::Ui,
+) -> (DaveResponse, Option<SessionListAction>) {
+ if show_session_list {
+ let ctrl_held = ui.input(|i| i.modifiers.ctrl);
+ let session_action = egui::Frame::new()
+ .fill(ui.visuals().faint_bg_color)
+ .inner_margin(egui::Margin::symmetric(8, 12))
+ .show(ui, |ui| {
+ SessionListUi::new(session_manager, focus_queue, ctrl_held, ai_mode).ui(ui)
+ })
+ .inner;
+ (DaveResponse::default(), session_action)
+ } else if let Some(session) = session_manager.get_active_mut() {
+ let is_working = session.status() == AgentStatus::Working;
+ let has_pending_permission = session.has_pending_permissions();
+ let plan_mode_active = session.is_plan_mode();
+
+ let mut ui_builder = DaveUi::new(
+ model_config.trial,
+ &session.chat,
+ &mut session.input,
+ &mut session.focus_requested,
+ session.ai_mode,
+ )
+ .is_working(is_working)
+ .interrupt_pending(is_interrupt_pending)
+ .has_pending_permission(has_pending_permission)
+ .plan_mode_active(plan_mode_active)
+ .auto_steal_focus(auto_steal_focus);
+
+ if let Some(agentic) = &mut session.agentic {
+ ui_builder = ui_builder
+ .permission_message_state(agentic.permission_message_state)
+ .question_answers(&mut agentic.question_answers)
+ .question_index(&mut agentic.question_index)
+ .is_compacting(agentic.is_compacting);
+ }
+
+ (ui_builder.ui(app_ctx, ui), None)
+ } else {
+ (DaveResponse::default(), None)
+ }
+}
+
+/// Result from handling a key action
+pub enum KeyActionResult {
+ None,
+ ToggleView,
+ HandleInterrupt,
+ CloneAgent,
+ DeleteSession(SessionId),
+ SetAutoSteal(bool),
+}
+
+/// Handle a keybinding action.
+#[allow(clippy::too_many_arguments)]
+pub fn handle_key_action(
+ key_action: KeyAction,
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ focus_queue: &mut FocusQueue,
+ backend: &dyn crate::backend::AiBackend,
+ show_scene: bool,
+ auto_steal_focus: bool,
+ home_session: &mut Option<SessionId>,
+ active_overlay: &mut DaveOverlay,
+ ctx: &egui::Context,
+) -> KeyActionResult {
+ match key_action {
+ KeyAction::AcceptPermission => {
+ if let Some(request_id) = update::first_pending_permission(session_manager) {
+ update::handle_permission_response(
+ session_manager,
+ request_id,
+ PermissionResponse::Allow { message: None },
+ );
+ if let Some(session) = session_manager.get_active_mut() {
+ session.focus_requested = true;
+ }
+ }
+ KeyActionResult::None
+ }
+ KeyAction::DenyPermission => {
+ if let Some(request_id) = update::first_pending_permission(session_manager) {
+ update::handle_permission_response(
+ session_manager,
+ request_id,
+ PermissionResponse::Deny {
+ reason: "User denied".into(),
+ },
+ );
+ if let Some(session) = session_manager.get_active_mut() {
+ session.focus_requested = true;
+ }
+ }
+ KeyActionResult::None
+ }
+ KeyAction::TentativeAccept => {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_message_state = PermissionMessageState::TentativeAccept;
+ }
+ session.focus_requested = true;
+ }
+ KeyActionResult::None
+ }
+ KeyAction::TentativeDeny => {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_message_state = PermissionMessageState::TentativeDeny;
+ }
+ session.focus_requested = true;
+ }
+ KeyActionResult::None
+ }
+ KeyAction::CancelTentative => {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_message_state = PermissionMessageState::None;
+ }
+ }
+ KeyActionResult::None
+ }
+ KeyAction::SwitchToAgent(index) => {
+ update::switch_to_agent_by_index(session_manager, scene, show_scene, index);
+ KeyActionResult::None
+ }
+ KeyAction::NextAgent => {
+ update::cycle_next_agent(session_manager, scene, show_scene);
+ KeyActionResult::None
+ }
+ KeyAction::PreviousAgent => {
+ update::cycle_prev_agent(session_manager, scene, show_scene);
+ KeyActionResult::None
+ }
+ KeyAction::NewAgent => {
+ *active_overlay = DaveOverlay::DirectoryPicker;
+ KeyActionResult::None
+ }
+ KeyAction::CloneAgent => KeyActionResult::CloneAgent,
+ KeyAction::Interrupt => KeyActionResult::HandleInterrupt,
+ KeyAction::ToggleView => KeyActionResult::ToggleView,
+ KeyAction::TogglePlanMode => {
+ update::toggle_plan_mode(session_manager, backend, ctx);
+ if let Some(session) = session_manager.get_active_mut() {
+ session.focus_requested = true;
+ }
+ KeyActionResult::None
+ }
+ KeyAction::DeleteActiveSession => {
+ if let Some(id) = session_manager.active_id() {
+ KeyActionResult::DeleteSession(id)
+ } else {
+ KeyActionResult::None
+ }
+ }
+ KeyAction::FocusQueueNext => {
+ update::focus_queue_next(session_manager, focus_queue, scene, show_scene);
+ KeyActionResult::None
+ }
+ KeyAction::FocusQueuePrev => {
+ update::focus_queue_prev(session_manager, focus_queue, scene, show_scene);
+ KeyActionResult::None
+ }
+ KeyAction::FocusQueueToggleDone => {
+ update::focus_queue_toggle_done(focus_queue);
+ KeyActionResult::None
+ }
+ KeyAction::ToggleAutoSteal => {
+ let new_state = update::toggle_auto_steal(
+ session_manager,
+ scene,
+ show_scene,
+ auto_steal_focus,
+ home_session,
+ );
+ KeyActionResult::SetAutoSteal(new_state)
+ }
+ KeyAction::OpenExternalEditor => {
+ update::open_external_editor(session_manager);
+ KeyActionResult::None
+ }
+ }
+}
+
+/// Result from handling a send action
+pub enum SendActionResult {
+ /// Permission response was sent, no further action needed
+ Handled,
+ /// Normal send - caller should send the user message
+ SendMessage,
+}
+
+/// Handle the Send action, including tentative permission states.
+pub fn handle_send_action(
+ session_manager: &mut SessionManager,
+ backend: &dyn crate::backend::AiBackend,
+ ctx: &egui::Context,
+) -> SendActionResult {
+ let tentative_state = session_manager
+ .get_active()
+ .and_then(|s| s.agentic.as_ref())
+ .map(|a| a.permission_message_state)
+ .unwrap_or(PermissionMessageState::None);
+
+ match tentative_state {
+ PermissionMessageState::TentativeAccept => {
+ let is_exit_plan_mode = update::has_pending_exit_plan_mode(session_manager);
+ if let Some(request_id) = update::first_pending_permission(session_manager) {
+ let message = session_manager
+ .get_active()
+ .map(|s| s.input.clone())
+ .filter(|m| !m.is_empty());
+ if let Some(session) = session_manager.get_active_mut() {
+ session.input.clear();
+ }
+ if is_exit_plan_mode {
+ update::exit_plan_mode(session_manager, backend, ctx);
+ }
+ update::handle_permission_response(
+ session_manager,
+ request_id,
+ PermissionResponse::Allow { message },
+ );
+ }
+ SendActionResult::Handled
+ }
+ PermissionMessageState::TentativeDeny => {
+ if let Some(request_id) = update::first_pending_permission(session_manager) {
+ let reason = session_manager
+ .get_active()
+ .map(|s| s.input.clone())
+ .filter(|m| !m.is_empty())
+ .unwrap_or_else(|| "User denied".into());
+ if let Some(session) = session_manager.get_active_mut() {
+ session.input.clear();
+ }
+ update::handle_permission_response(
+ session_manager,
+ request_id,
+ PermissionResponse::Deny { reason },
+ );
+ }
+ SendActionResult::Handled
+ }
+ PermissionMessageState::None => SendActionResult::SendMessage,
+ }
+}
+
+/// Result from handling a UI action
+pub enum UiActionResult {
+ /// Action was fully handled
+ Handled,
+ /// Send action - caller should handle send
+ SendAction,
+ /// Return an AppAction
+ AppAction(notedeck::AppAction),
+}
+
+/// Handle a UI action from DaveUi.
+#[allow(clippy::too_many_arguments)]
+pub fn handle_ui_action(
+ action: DaveAction,
+ session_manager: &mut SessionManager,
+ backend: &dyn crate::backend::AiBackend,
+ active_overlay: &mut DaveOverlay,
+ show_session_list: &mut bool,
+ ctx: &egui::Context,
+) -> UiActionResult {
+ match action {
+ DaveAction::ToggleChrome => UiActionResult::AppAction(notedeck::AppAction::ToggleChrome),
+ DaveAction::Note(n) => UiActionResult::AppAction(notedeck::AppAction::Note(n)),
+ DaveAction::NewChat => {
+ *active_overlay = DaveOverlay::DirectoryPicker;
+ UiActionResult::Handled
+ }
+ DaveAction::Send => UiActionResult::SendAction,
+ DaveAction::ShowSessionList => {
+ *show_session_list = !*show_session_list;
+ UiActionResult::Handled
+ }
+ DaveAction::OpenSettings => {
+ *active_overlay = DaveOverlay::Settings;
+ UiActionResult::Handled
+ }
+ DaveAction::UpdateSettings(_settings) => UiActionResult::Handled,
+ DaveAction::PermissionResponse {
+ request_id,
+ response,
+ } => {
+ update::handle_permission_response(session_manager, request_id, response);
+ UiActionResult::Handled
+ }
+ DaveAction::Interrupt => {
+ update::execute_interrupt(session_manager, backend, ctx);
+ UiActionResult::Handled
+ }
+ DaveAction::TentativeAccept => {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_message_state = PermissionMessageState::TentativeAccept;
+ }
+ session.focus_requested = true;
+ }
+ UiActionResult::Handled
+ }
+ DaveAction::TentativeDeny => {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_message_state = PermissionMessageState::TentativeDeny;
+ }
+ session.focus_requested = true;
+ }
+ UiActionResult::Handled
+ }
+ DaveAction::QuestionResponse {
+ request_id,
+ answers,
+ } => {
+ update::handle_question_response(session_manager, request_id, answers);
+ UiActionResult::Handled
+ }
+ DaveAction::ExitPlanMode {
+ request_id,
+ approved,
+ } => {
+ if approved {
+ update::exit_plan_mode(session_manager, backend, ctx);
+ update::handle_permission_response(
+ session_manager,
+ request_id,
+ PermissionResponse::Allow { message: None },
+ );
+ } else {
+ update::handle_permission_response(
+ session_manager,
+ request_id,
+ PermissionResponse::Deny {
+ reason: "User rejected plan".into(),
+ },
+ );
+ }
+ UiActionResult::Handled
+ }
+ }
+}
diff --git a/crates/notedeck_dave/src/ui/path_utils.rs b/crates/notedeck_dave/src/ui/path_utils.rs
@@ -0,0 +1,11 @@
+use std::path::Path;
+
+/// Abbreviate a path for display (e.g., replace home dir with ~)
+pub fn abbreviate_path(path: &Path) -> String {
+ if let Some(home) = dirs::home_dir() {
+ if let Ok(relative) = path.strip_prefix(&home) {
+ return format!("~/{}", relative.display());
+ }
+ }
+ path.display().to_string()
+}
diff --git a/crates/notedeck_dave/src/ui/pill.rs b/crates/notedeck_dave/src/ui/pill.rs
@@ -0,0 +1,43 @@
+/// Pill-style UI components for displaying labeled values.
+///
+/// Pills are compact, rounded UI elements used to display key-value pairs
+/// in a visually distinct way, commonly used in query displays.
+use egui::Ui;
+
+/// Render a pill label with a text value
+pub fn pill_label(name: &str, value: &str, ui: &mut Ui) {
+ pill_label_ui(
+ name,
+ move |ui| {
+ ui.label(value);
+ },
+ ui,
+ );
+}
+
+/// Render a pill label with a custom UI closure for the value
+pub fn pill_label_ui(name: &str, mut value: impl FnMut(&mut Ui), ui: &mut Ui) {
+ egui::Frame::new()
+ .fill(ui.visuals().noninteractive().bg_fill)
+ .inner_margin(egui::Margin::same(4))
+ .corner_radius(egui::CornerRadius::same(10))
+ .stroke(egui::Stroke::new(
+ 1.0,
+ ui.visuals().noninteractive().bg_stroke.color,
+ ))
+ .show(ui, |ui| {
+ egui::Frame::new()
+ .fill(ui.visuals().noninteractive().weak_bg_fill)
+ .inner_margin(egui::Margin::same(4))
+ .corner_radius(egui::CornerRadius::same(10))
+ .stroke(egui::Stroke::new(
+ 1.0,
+ ui.visuals().noninteractive().bg_stroke.color,
+ ))
+ .show(ui, |ui| {
+ ui.label(name);
+ });
+
+ value(ui);
+ });
+}
diff --git a/crates/notedeck_dave/src/ui/query_ui.rs b/crates/notedeck_dave/src/ui/query_ui.rs
@@ -0,0 +1,59 @@
+/// UI components for displaying query call information.
+///
+/// These components render the parameters of a nostr query in a visual format,
+/// using pill labels to show search terms, authors, limits, and other filter criteria.
+use super::pill::{pill_label, pill_label_ui};
+use crate::tools::QueryCall;
+use nostrdb::{Ndb, Transaction};
+use notedeck::{Images, MediaJobSender};
+use notedeck_ui::ProfilePic;
+
+/// Render query call parameters as pill labels
+pub fn query_call_ui(
+ cache: &mut Images,
+ ndb: &Ndb,
+ query: &QueryCall,
+ jobs: &MediaJobSender,
+ ui: &mut egui::Ui,
+) {
+ ui.spacing_mut().item_spacing.x = 8.0;
+ if let Some(pubkey) = query.author() {
+ let txn = Transaction::new(ndb).unwrap();
+ pill_label_ui(
+ "author",
+ move |ui| {
+ ui.add(
+ &mut ProfilePic::from_profile_or_default(
+ cache,
+ jobs,
+ ndb.get_profile_by_pubkey(&txn, pubkey.bytes())
+ .ok()
+ .as_ref(),
+ )
+ .size(ProfilePic::small_size() as f32),
+ );
+ },
+ ui,
+ );
+ }
+
+ if let Some(limit) = query.limit {
+ pill_label("limit", &limit.to_string(), ui);
+ }
+
+ if let Some(since) = query.since {
+ pill_label("since", &since.to_string(), ui);
+ }
+
+ if let Some(kind) = query.kind {
+ pill_label("kind", &kind.to_string(), ui);
+ }
+
+ if let Some(until) = query.until {
+ pill_label("until", &until.to_string(), ui);
+ }
+
+ if let Some(search) = query.search.as_ref() {
+ pill_label("search", search, ui);
+ }
+}
diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs
@@ -0,0 +1,431 @@
+use std::path::Path;
+
+use crate::agent_status::AgentStatus;
+use crate::focus_queue::{FocusPriority, FocusQueue};
+use crate::session::{SessionId, SessionManager};
+use crate::ui::paint_keybind_hint;
+use egui::{Color32, Pos2, Rect, Response, Sense, Vec2};
+
+/// The RTS-style scene view for managing agents
+pub struct AgentScene {
+ /// Camera/view transform state managed by egui::Scene
+ scene_rect: Rect,
+ /// Currently selected agent IDs
+ pub selected: Vec<SessionId>,
+ /// Drag selection state
+ drag_select: Option<DragSelect>,
+ /// Target camera position for smooth animation
+ camera_target: Option<Vec2>,
+ /// Animation progress (0.0 to 1.0)
+ animation_progress: f32,
+}
+
+/// State for box/marquee selection
+struct DragSelect {
+ start: Pos2,
+ current: Pos2,
+}
+
+/// Action generated by the scene UI
+#[derive(Debug, Clone)]
+pub enum SceneAction {
+ /// Selection changed
+ SelectionChanged(Vec<SessionId>),
+ /// Request to spawn a new agent
+ SpawnAgent,
+ /// Request to delete selected agents
+ DeleteSelected,
+ /// Agent was dragged to new position
+ AgentMoved { id: SessionId, position: Vec2 },
+}
+
+/// Response from scene rendering
+#[derive(Default)]
+pub struct SceneResponse {
+ pub action: Option<SceneAction>,
+}
+
+impl SceneResponse {
+ pub fn new(action: SceneAction) -> Self {
+ Self {
+ action: Some(action),
+ }
+ }
+}
+
+impl Default for AgentScene {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl AgentScene {
+ pub fn new() -> Self {
+ Self {
+ scene_rect: Rect::from_min_max(Pos2::new(-500.0, -500.0), Pos2::new(500.0, 500.0)),
+ selected: Vec::new(),
+ drag_select: None,
+ camera_target: None,
+ animation_progress: 1.0,
+ }
+ }
+
+ /// Check if an agent is selected
+ pub fn is_selected(&self, id: SessionId) -> bool {
+ self.selected.contains(&id)
+ }
+
+ /// Set selection to a single agent
+ pub fn select(&mut self, id: SessionId) {
+ self.selected.clear();
+ self.selected.push(id);
+ }
+
+ /// Add an agent to the selection
+ pub fn add_to_selection(&mut self, id: SessionId) {
+ if !self.selected.contains(&id) {
+ self.selected.push(id);
+ }
+ }
+
+ /// Clear all selection
+ pub fn clear_selection(&mut self) {
+ self.selected.clear();
+ }
+
+ /// Get the first selected agent (for chat panel)
+ pub fn primary_selection(&self) -> Option<SessionId> {
+ self.selected.first().copied()
+ }
+
+ /// Animate camera to focus on a position
+ pub fn focus_on(&mut self, position: Vec2) {
+ self.camera_target = Some(position);
+ self.animation_progress = 0.0;
+ }
+
+ /// Render the scene
+ pub fn ui(
+ &mut self,
+ session_manager: &SessionManager,
+ focus_queue: &FocusQueue,
+ ui: &mut egui::Ui,
+ ctrl_held: bool,
+ ) -> SceneResponse {
+ let mut response = SceneResponse::default();
+
+ // Update camera animation towards target
+ if let Some(target) = self.camera_target {
+ if self.animation_progress < 1.0 {
+ self.animation_progress += 0.08;
+ self.animation_progress = self.animation_progress.min(1.0);
+
+ // Smoothly interpolate scene_rect center towards target
+ let current_center = self.scene_rect.center();
+ let target_pos = Pos2::new(target.x, target.y);
+ let t = ease_out_cubic(self.animation_progress);
+ let new_center = current_center.lerp(target_pos, t);
+
+ // Shift the scene_rect to center on new position
+ let offset = new_center - current_center;
+ self.scene_rect = self.scene_rect.translate(offset);
+
+ ui.ctx().request_repaint();
+ } else {
+ // Animation complete
+ self.camera_target = None;
+ }
+ }
+
+ // Track interactions from inside the scene closure
+ let mut clicked_agent: Option<(SessionId, bool, Vec2)> = None; // (id, shift_held, position)
+ let mut dragged_agent: Option<(SessionId, Vec2)> = None; // (id, new_position)
+ let mut bg_clicked = false;
+ let mut bg_drag_started = false;
+
+ // Use a local copy of scene_rect to avoid borrow conflict
+ let mut scene_rect = self.scene_rect;
+ let selected_ids = &self.selected;
+
+ let scene_response =
+ egui::Scene::new()
+ .zoom_range(0.1..=1.0)
+ .show(ui, &mut scene_rect, |ui| {
+ // Draw agents and collect interaction responses
+ // Use sessions_ordered() to match keybinding order (Ctrl+1 = first in order, etc.)
+ for (keybind_idx, session) in
+ session_manager.sessions_ordered().into_iter().enumerate()
+ {
+ // Scene view only makes sense for agentic sessions
+ let Some(agentic) = &session.agentic else {
+ continue;
+ };
+
+ let id = session.id;
+ let keybind_number = keybind_idx + 1; // 1-indexed for display
+ let position = agentic.scene_position;
+ let status = session.status();
+ let title = &session.title;
+ let is_selected = selected_ids.contains(&id);
+ let queue_priority = focus_queue.get_session_priority(id);
+
+ let agent_response = Self::draw_agent(
+ ui,
+ id,
+ keybind_number,
+ position,
+ status,
+ title,
+ &agentic.cwd,
+ is_selected,
+ ctrl_held,
+ queue_priority,
+ );
+
+ if agent_response.clicked() {
+ let shift = ui.input(|i| i.modifiers.shift);
+ clicked_agent = Some((id, shift, position));
+ }
+
+ if agent_response.dragged() && is_selected {
+ let delta = agent_response.drag_delta();
+ dragged_agent = Some((id, position + delta));
+ }
+ }
+
+ // Handle click on empty space to deselect
+ let bg_response = ui.interact(
+ ui.max_rect(),
+ ui.id().with("scene_bg"),
+ Sense::click_and_drag(),
+ );
+
+ if bg_response.clicked() && clicked_agent.is_none() {
+ bg_clicked = true;
+ }
+
+ if bg_response.drag_started() && clicked_agent.is_none() {
+ bg_drag_started = true;
+ }
+ });
+
+ // Get the viewport rect for coordinate transforms
+ let viewport_rect = scene_response.response.rect;
+
+ self.scene_rect = scene_rect;
+
+ // Process agent click
+ if let Some((id, shift, _position)) = clicked_agent {
+ if shift {
+ self.add_to_selection(id);
+ } else {
+ self.select(id);
+ }
+ response = SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone()));
+ }
+
+ // Process agent drag
+ if let Some((id, new_pos)) = dragged_agent {
+ response = SceneResponse::new(SceneAction::AgentMoved {
+ id,
+ position: new_pos,
+ });
+ }
+
+ // Process background click
+ if bg_clicked && response.action.is_none() && !self.selected.is_empty() {
+ self.selected.clear();
+ response = SceneResponse::new(SceneAction::SelectionChanged(Vec::new()));
+ }
+
+ // Start drag selection
+ if bg_drag_started {
+ if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
+ self.drag_select = Some(DragSelect {
+ start: pos,
+ current: pos,
+ });
+ }
+ }
+
+ // Update drag selection position
+ if let Some(drag) = &mut self.drag_select {
+ if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
+ drag.current = pos;
+ }
+ }
+
+ // Handle keyboard input (only when no text input has focus)
+ // Note: N key for spawning agents is handled globally in keybindings.rs
+ if !ui.ctx().wants_keyboard_input()
+ && ui.input(|i| i.key_pressed(egui::Key::Delete))
+ && !self.selected.is_empty()
+ {
+ response = SceneResponse::new(SceneAction::DeleteSelected);
+ }
+
+ // Handle box selection completion
+ if let Some(drag) = &self.drag_select {
+ if ui.input(|i| i.pointer.primary_released()) {
+ // Convert screen-space drag coordinates to scene-space
+ // Screen -> Scene: scene_pos = scene_rect.min + (screen_pos - viewport.min) / viewport.size() * scene_rect.size()
+ let screen_to_scene = |screen_pos: Pos2| -> Pos2 {
+ let rel = (screen_pos - viewport_rect.min) / viewport_rect.size();
+ scene_rect.min + rel * scene_rect.size()
+ };
+
+ let scene_start = screen_to_scene(drag.start);
+ let scene_current = screen_to_scene(drag.current);
+ let selection_rect = Rect::from_two_pos(scene_start, scene_current);
+
+ self.selected.clear();
+
+ for session in session_manager.iter() {
+ if let Some(agentic) = &session.agentic {
+ let agent_pos =
+ Pos2::new(agentic.scene_position.x, agentic.scene_position.y);
+ if selection_rect.contains(agent_pos) {
+ self.selected.push(session.id);
+ }
+ }
+ }
+
+ if !self.selected.is_empty() {
+ response =
+ SceneResponse::new(SceneAction::SelectionChanged(self.selected.clone()));
+ }
+
+ self.drag_select = None;
+ }
+ }
+
+ // Draw box selection overlay
+ if let Some(drag) = &self.drag_select {
+ let rect = Rect::from_two_pos(drag.start, drag.current);
+ let painter = ui.painter();
+ painter.rect_filled(
+ rect,
+ 0.0,
+ Color32::from_rgba_unmultiplied(100, 150, 255, 30),
+ );
+ painter.rect_stroke(
+ rect,
+ 0.0,
+ egui::Stroke::new(1.0, Color32::from_rgb(100, 150, 255)),
+ egui::StrokeKind::Outside,
+ );
+ }
+
+ response
+ }
+
+ /// Draw a single agent unit and return the interaction Response
+ /// `keybind_number` is the 1-indexed number displayed when Ctrl is held (matches Ctrl+N keybindings)
+ #[allow(clippy::too_many_arguments)]
+ fn draw_agent(
+ ui: &mut egui::Ui,
+ id: SessionId,
+ keybind_number: usize,
+ position: Vec2,
+ status: AgentStatus,
+ title: &str,
+ cwd: &Path,
+ is_selected: bool,
+ show_keybinding: bool,
+ queue_priority: Option<FocusPriority>,
+ ) -> Response {
+ let agent_radius = 30.0;
+ let center = Pos2::new(position.x, position.y);
+ let agent_rect = Rect::from_center_size(center, Vec2::splat(agent_radius * 2.0));
+
+ // Interact with the agent
+ let response = ui.interact(
+ agent_rect,
+ ui.id().with(("agent", id)),
+ Sense::click_and_drag(),
+ );
+
+ let painter = ui.painter();
+
+ // Selection highlight (outer ring)
+ if is_selected {
+ painter.circle_stroke(
+ center,
+ agent_radius + 4.0,
+ egui::Stroke::new(3.0, Color32::from_rgb(255, 255, 100)),
+ );
+ }
+
+ // Status ring
+ let status_color = status.color();
+ painter.circle_stroke(center, agent_radius, egui::Stroke::new(3.0, status_color));
+
+ // Fill
+ let fill_color = if response.hovered() {
+ ui.visuals().widgets.hovered.bg_fill
+ } else {
+ ui.visuals().widgets.inactive.bg_fill
+ };
+ painter.circle_filled(center, agent_radius - 2.0, fill_color);
+
+ // Focus queue indicator dot (top-right of the agent circle)
+ if let Some(priority) = queue_priority {
+ let dot_radius = 6.0;
+ let dot_offset = Vec2::new(agent_radius * 0.7, -agent_radius * 0.7);
+ let dot_center = center + dot_offset;
+ painter.circle_filled(dot_center, dot_radius, priority.color());
+ }
+
+ // Agent icon in center: show keybind frame when Ctrl held, otherwise first letter
+ if show_keybinding {
+ paint_keybind_hint(ui, center, &keybind_number.to_string(), 24.0);
+ } else {
+ let icon_text: String = title.chars().next().unwrap_or('?').to_uppercase().collect();
+ painter.text(
+ center,
+ egui::Align2::CENTER_CENTER,
+ &icon_text,
+ egui::FontId::proportional(20.0),
+ ui.visuals().text_color(),
+ );
+ }
+
+ // Title below
+ let title_pos = center + Vec2::new(0.0, agent_radius + 10.0);
+ painter.text(
+ title_pos,
+ egui::Align2::CENTER_TOP,
+ title,
+ egui::FontId::proportional(11.0),
+ ui.visuals().text_color().gamma_multiply(0.8),
+ );
+
+ // Status label
+ let status_pos = center + Vec2::new(0.0, agent_radius + 24.0);
+ painter.text(
+ status_pos,
+ egui::Align2::CENTER_TOP,
+ status.label(),
+ egui::FontId::proportional(9.0),
+ status_color.gamma_multiply(0.9),
+ );
+
+ // Cwd label (monospace, weak+small)
+ let cwd_text = cwd.to_string_lossy();
+ let cwd_pos = center + Vec2::new(0.0, agent_radius + 38.0);
+ painter.text(
+ cwd_pos,
+ egui::Align2::CENTER_TOP,
+ &cwd_text,
+ egui::FontId::monospace(8.0),
+ ui.visuals().weak_text_color(),
+ );
+
+ response
+ }
+}
+
+/// Easing function for smooth camera animation
+fn ease_out_cubic(t: f32) -> f32 {
+ 1.0 - (1.0 - t).powi(3)
+}
diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs
@@ -1,7 +1,13 @@
-use egui::{Align, Layout, Sense};
+use std::path::{Path, PathBuf};
+
+use egui::{Align, Color32, Layout, Sense};
use notedeck_ui::app_images;
+use crate::agent_status::AgentStatus;
+use crate::config::AiMode;
+use crate::focus_queue::{FocusPriority, FocusQueue};
use crate::session::{SessionId, SessionManager};
+use crate::ui::keybind_hint::paint_keybind_hint;
/// Actions that can be triggered from the session list UI
#[derive(Debug, Clone)]
@@ -14,24 +20,38 @@ pub enum SessionListAction {
/// UI component for displaying the session list sidebar
pub struct SessionListUi<'a> {
session_manager: &'a SessionManager,
+ focus_queue: &'a FocusQueue,
+ ctrl_held: bool,
+ ai_mode: AiMode,
}
impl<'a> SessionListUi<'a> {
- pub fn new(session_manager: &'a SessionManager) -> Self {
- SessionListUi { session_manager }
+ pub fn new(
+ session_manager: &'a SessionManager,
+ focus_queue: &'a FocusQueue,
+ ctrl_held: bool,
+ ai_mode: AiMode,
+ ) -> Self {
+ SessionListUi {
+ session_manager,
+ focus_queue,
+ ctrl_held,
+ ai_mode,
+ }
}
pub fn ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> {
let mut action: Option<SessionListAction> = None;
ui.vertical(|ui| {
- // Header with New Chat button
+ // Header with New Agent button
action = self.header_ui(ui);
ui.add_space(8.0);
// Scrollable list of sessions
egui::ScrollArea::vertical()
+ .id_salt("session_list_scroll")
.auto_shrink([false; 2])
.show(ui, |ui| {
if let Some(session_action) = self.sessions_list_ui(ui) {
@@ -46,9 +66,15 @@ impl<'a> SessionListUi<'a> {
fn header_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> {
let mut action = None;
+ // Header text and tooltip depend on mode
+ let (header_text, new_tooltip) = match self.ai_mode {
+ AiMode::Chat => ("Chats", "New Chat"),
+ AiMode::Agentic => ("Agents", "New Agent"),
+ };
+
ui.horizontal(|ui| {
ui.add_space(4.0);
- ui.label(egui::RichText::new("Chats").size(18.0).strong());
+ ui.label(egui::RichText::new(header_text).size(18.0).strong());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let icon = app_images::new_message_image()
@@ -58,7 +84,7 @@ impl<'a> SessionListUi<'a> {
if ui
.add(icon)
.on_hover_cursor(egui::CursorIcon::PointingHand)
- .on_hover_text("New Chat")
+ .on_hover_text(new_tooltip)
.clicked()
{
action = Some(SessionListAction::NewSession);
@@ -73,10 +99,31 @@ impl<'a> SessionListUi<'a> {
let mut action = None;
let active_id = self.session_manager.active_id();
- for session in self.session_manager.sessions_ordered() {
+ for (index, session) in self.session_manager.sessions_ordered().iter().enumerate() {
let is_active = Some(session.id) == active_id;
+ // Show keyboard shortcut hint for first 9 sessions (1-9 keys), only when Ctrl held
+ let shortcut_hint = if self.ctrl_held && index < 9 {
+ Some(index + 1)
+ } else {
+ None
+ };
+
+ // Check if this session is in the focus queue
+ let queue_priority = self.focus_queue.get_session_priority(session.id);
- let response = self.session_item_ui(ui, &session.title, is_active);
+ // Get cwd from agentic data, fallback to empty path for Chat mode
+ let empty_path = PathBuf::new();
+ let cwd = session.cwd().unwrap_or(&empty_path);
+
+ let response = self.session_item_ui(
+ ui,
+ &session.title,
+ cwd,
+ is_active,
+ shortcut_hint,
+ session.status(),
+ queue_priority,
+ );
if response.clicked() {
action = Some(SessionListAction::SwitchTo(session.id));
@@ -94,10 +141,29 @@ impl<'a> SessionListUi<'a> {
action
}
- fn session_item_ui(&self, ui: &mut egui::Ui, title: &str, is_active: bool) -> egui::Response {
- let desired_size = egui::vec2(ui.available_width(), 36.0);
+ #[allow(clippy::too_many_arguments)]
+ fn session_item_ui(
+ &self,
+ ui: &mut egui::Ui,
+ title: &str,
+ cwd: &Path,
+ is_active: bool,
+ shortcut_hint: Option<usize>,
+ status: AgentStatus,
+ queue_priority: Option<FocusPriority>,
+ ) -> egui::Response {
+ // In Chat mode: shorter height (no CWD), no status bar
+ // In Agentic mode: taller height with CWD and status bar
+ let show_cwd = self.ai_mode == AiMode::Agentic;
+ let show_status_bar = self.ai_mode == AiMode::Agentic;
+
+ let item_height = if show_cwd { 48.0 } else { 32.0 };
+ let desired_size = egui::vec2(ui.available_width(), item_height);
let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
- let response = response.on_hover_cursor(egui::CursorIcon::PointingHand);
+ let hover_text = format!("Ctrl+{} to switch", shortcut_hint.unwrap_or(0));
+ let response = response
+ .on_hover_cursor(egui::CursorIcon::PointingHand)
+ .on_hover_text_at_pointer(hover_text);
// Paint background: active > hovered > transparent
let fill = if is_active {
@@ -105,22 +171,117 @@ impl<'a> SessionListUi<'a> {
} else if response.hovered() {
ui.visuals().widgets.hovered.weak_bg_fill
} else {
- egui::Color32::TRANSPARENT
+ Color32::TRANSPARENT
};
let corner_radius = 8.0;
ui.painter().rect_filled(rect, corner_radius, fill);
- // Draw title text (left-aligned, vertically centered)
- let text_pos = rect.left_center() + egui::vec2(8.0, 0.0);
+ // Status color indicator (left edge vertical bar) - only in Agentic mode
+ let text_start_x = if show_status_bar {
+ let status_color = status.color();
+ let status_bar_rect = egui::Rect::from_min_size(
+ rect.left_top() + egui::vec2(2.0, 4.0),
+ egui::vec2(3.0, rect.height() - 8.0),
+ );
+ ui.painter().rect_filled(status_bar_rect, 1.5, status_color);
+ 12.0 // Left padding (room for status bar)
+ } else {
+ 8.0 // Smaller padding in Chat mode (no status bar)
+ };
+
+ // Draw shortcut hint at the far right
+ let mut right_offset = 8.0; // Start with normal right padding
+
+ if let Some(num) = shortcut_hint {
+ let hint_text = format!("{}", num);
+ let hint_size = 18.0;
+ let hint_center = rect.right_center() - egui::vec2(8.0 + hint_size / 2.0, 0.0);
+ paint_keybind_hint(ui, hint_center, &hint_text, hint_size);
+ right_offset = 8.0 + hint_size + 6.0; // padding + hint width + spacing
+ }
+
+ // Draw focus queue indicator dot to the left of the shortcut hint
+ let text_end_x = if let Some(priority) = queue_priority {
+ let dot_radius = 5.0;
+ let dot_center = rect.right_center() - egui::vec2(right_offset + dot_radius + 4.0, 0.0);
+ ui.painter()
+ .circle_filled(dot_center, dot_radius, priority.color());
+ right_offset + dot_radius * 2.0 + 8.0 // Space reserved for the dot
+ } else {
+ right_offset
+ };
+
+ // Calculate text position - offset title upward only if showing CWD
+ let title_y_offset = if show_cwd { -7.0 } else { 0.0 };
+ let text_pos = rect.left_center() + egui::vec2(text_start_x, title_y_offset);
+ let max_text_width = rect.width() - text_start_x - text_end_x;
+
+ // Draw title text (with clipping to avoid overlapping the dot)
+ let font_id = egui::FontId::proportional(14.0);
+ let text_color = ui.visuals().text_color();
+ let galley = ui
+ .painter()
+ .layout_no_wrap(title.to_string(), font_id.clone(), text_color);
+
+ if galley.size().x > max_text_width {
+ // Text is too long, use ellipsis
+ let clip_rect = egui::Rect::from_min_size(
+ text_pos - egui::vec2(0.0, galley.size().y / 2.0),
+ egui::vec2(max_text_width, galley.size().y),
+ );
+ ui.painter().with_clip_rect(clip_rect).galley(
+ text_pos - egui::vec2(0.0, galley.size().y / 2.0),
+ galley,
+ text_color,
+ );
+ } else {
+ ui.painter().text(
+ text_pos,
+ egui::Align2::LEFT_CENTER,
+ title,
+ font_id,
+ text_color,
+ );
+ }
+
+ // Draw cwd below title - only in Agentic mode
+ if show_cwd {
+ let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0);
+ cwd_ui(ui, cwd, cwd_pos, max_text_width);
+ }
+
+ response
+ }
+}
+
+/// Draw cwd text (monospace, weak+small) with clipping
+fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, pos: egui::Pos2, max_width: f32) {
+ let cwd_text = cwd_path.to_string_lossy();
+ let cwd_font = egui::FontId::monospace(10.0);
+ let cwd_color = ui.visuals().weak_text_color();
+
+ let cwd_galley = ui
+ .painter()
+ .layout_no_wrap(cwd_text.to_string(), cwd_font.clone(), cwd_color);
+
+ if cwd_galley.size().x > max_width {
+ let clip_rect = egui::Rect::from_min_size(
+ pos - egui::vec2(0.0, cwd_galley.size().y / 2.0),
+ egui::vec2(max_width, cwd_galley.size().y),
+ );
+ ui.painter().with_clip_rect(clip_rect).galley(
+ pos - egui::vec2(0.0, cwd_galley.size().y / 2.0),
+ cwd_galley,
+ cwd_color,
+ );
+ } else {
ui.painter().text(
- text_pos,
+ pos,
egui::Align2::LEFT_CENTER,
- title,
- egui::FontId::proportional(14.0),
- ui.visuals().text_color(),
+ &cwd_text,
+ cwd_font,
+ cwd_color,
);
-
- response
}
}
diff --git a/crates/notedeck_dave/src/ui/session_picker.rs b/crates/notedeck_dave/src/ui/session_picker.rs
@@ -0,0 +1,311 @@
+//! UI component for selecting resumable Claude sessions.
+
+use crate::session_discovery::{discover_sessions, format_relative_time, ResumableSession};
+use crate::ui::keybind_hint::paint_keybind_hint;
+use crate::ui::path_utils::abbreviate_path;
+use egui::{RichText, Vec2};
+use std::path::{Path, PathBuf};
+
+/// Maximum number of sessions to display
+const MAX_SESSIONS_DISPLAYED: usize = 10;
+
+/// Actions that can be triggered from the session picker
+#[derive(Debug, Clone)]
+pub enum SessionPickerAction {
+ /// User selected a session to resume
+ ResumeSession {
+ cwd: PathBuf,
+ session_id: String,
+ title: String,
+ },
+ /// User wants to start a new session (no resume)
+ NewSession { cwd: PathBuf },
+ /// User cancelled and wants to go back to directory picker
+ BackToDirectoryPicker,
+}
+
+/// State for the session picker modal
+pub struct SessionPicker {
+ /// The working directory we're showing sessions for
+ cwd: Option<PathBuf>,
+ /// Cached list of resumable sessions
+ sessions: Vec<ResumableSession>,
+ /// Whether the picker is currently open
+ pub is_open: bool,
+}
+
+impl Default for SessionPicker {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl SessionPicker {
+ pub fn new() -> Self {
+ Self {
+ cwd: None,
+ sessions: Vec::new(),
+ is_open: false,
+ }
+ }
+
+ /// Open the picker for a specific working directory
+ pub fn open(&mut self, cwd: PathBuf) {
+ self.sessions = discover_sessions(&cwd);
+ self.cwd = Some(cwd);
+ self.is_open = true;
+ }
+
+ /// Close the picker
+ pub fn close(&mut self) {
+ self.is_open = false;
+ self.cwd = None;
+ self.sessions.clear();
+ }
+
+ /// Check if there are sessions available to resume
+ pub fn has_sessions(&self) -> bool {
+ !self.sessions.is_empty()
+ }
+
+ /// Get the current working directory
+ pub fn cwd(&self) -> Option<&Path> {
+ self.cwd.as_deref()
+ }
+
+ /// Render the session picker as a full-panel overlay
+ pub fn overlay_ui(&mut self, ui: &mut egui::Ui) -> Option<SessionPickerAction> {
+ let cwd = self.cwd.clone()?;
+
+ let mut action = None;
+ let is_narrow = notedeck::ui::is_narrow(ui.ctx());
+ let ctrl_held = ui.input(|i| i.modifiers.ctrl);
+
+ // Handle keyboard shortcuts for sessions (Ctrl+1-9)
+ // Only trigger when Ctrl is held to avoid intercepting TextEdit input
+ if ctrl_held {
+ for (idx, session) in self.sessions.iter().take(9).enumerate() {
+ let key = match idx {
+ 0 => egui::Key::Num1,
+ 1 => egui::Key::Num2,
+ 2 => egui::Key::Num3,
+ 3 => egui::Key::Num4,
+ 4 => egui::Key::Num5,
+ 5 => egui::Key::Num6,
+ 6 => egui::Key::Num7,
+ 7 => egui::Key::Num8,
+ 8 => egui::Key::Num9,
+ _ => continue,
+ };
+ if ui.input(|i| i.key_pressed(key)) {
+ return Some(SessionPickerAction::ResumeSession {
+ cwd,
+ session_id: session.session_id.clone(),
+ title: session.summary.clone(),
+ });
+ }
+ }
+ }
+
+ // Handle Ctrl+N key for new session
+ // Only trigger when Ctrl is held to avoid intercepting TextEdit input
+ if ctrl_held && ui.input(|i| i.key_pressed(egui::Key::N)) {
+ return Some(SessionPickerAction::NewSession { cwd });
+ }
+
+ // Handle Escape key or Ctrl+B to go back
+ // B key requires Ctrl to avoid intercepting TextEdit input
+ if ui.input(|i| i.key_pressed(egui::Key::Escape))
+ || (ctrl_held && ui.input(|i| i.key_pressed(egui::Key::B)))
+ {
+ return Some(SessionPickerAction::BackToDirectoryPicker);
+ }
+
+ // Full panel frame
+ egui::Frame::new()
+ .fill(ui.visuals().panel_fill)
+ .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20))
+ .show(ui, |ui| {
+ // Header
+ ui.horizontal(|ui| {
+ if ui.button("< Back").clicked() {
+ action = Some(SessionPickerAction::BackToDirectoryPicker);
+ }
+ ui.add_space(16.0);
+ ui.heading("Resume Session");
+ });
+
+ ui.add_space(8.0);
+
+ // Show the cwd
+ ui.label(RichText::new(abbreviate_path(&cwd)).monospace().weak());
+
+ ui.add_space(16.0);
+
+ // Centered content
+ let max_content_width = if is_narrow {
+ ui.available_width()
+ } else {
+ 600.0
+ };
+ let available_height = ui.available_height();
+
+ ui.allocate_ui_with_layout(
+ egui::vec2(max_content_width, available_height),
+ egui::Layout::top_down(egui::Align::LEFT),
+ |ui| {
+ // New session button at top
+ ui.horizontal(|ui| {
+ let new_button = egui::Button::new(
+ RichText::new("+ New Session").size(if is_narrow {
+ 16.0
+ } else {
+ 14.0
+ }),
+ )
+ .min_size(Vec2::new(
+ if is_narrow {
+ ui.available_width() - 28.0
+ } else {
+ 150.0
+ },
+ if is_narrow { 48.0 } else { 36.0 },
+ ));
+
+ let response = ui.add(new_button);
+
+ // Show keybind hint when Ctrl is held
+ if ctrl_held {
+ let hint_center =
+ response.rect.right_center() + egui::vec2(14.0, 0.0);
+ paint_keybind_hint(ui, hint_center, "N", 18.0);
+ }
+
+ if response
+ .on_hover_text("Start a new conversation (N)")
+ .clicked()
+ {
+ action = Some(SessionPickerAction::NewSession { cwd: cwd.clone() });
+ }
+ });
+
+ ui.add_space(16.0);
+ ui.separator();
+ ui.add_space(12.0);
+
+ // Sessions list
+ if self.sessions.is_empty() {
+ ui.label(
+ RichText::new("No previous sessions found for this directory.")
+ .weak(),
+ );
+ } else {
+ ui.label(RichText::new("Recent Sessions").strong());
+ ui.add_space(8.0);
+
+ let scroll_height = if is_narrow {
+ (ui.available_height() - 80.0).max(100.0)
+ } else {
+ 400.0
+ };
+
+ egui::ScrollArea::vertical()
+ .max_height(scroll_height)
+ .show(ui, |ui| {
+ for (idx, session) in self
+ .sessions
+ .iter()
+ .take(MAX_SESSIONS_DISPLAYED)
+ .enumerate()
+ {
+ let button_height = if is_narrow { 64.0 } else { 50.0 };
+ let hint_width =
+ if ctrl_held && idx < 9 { 24.0 } else { 0.0 };
+ let button_width = ui.available_width() - hint_width - 4.0;
+
+ ui.horizontal(|ui| {
+ // Create a frame for the session button
+ let response = ui.add(
+ egui::Button::new("")
+ .min_size(Vec2::new(
+ button_width,
+ button_height,
+ ))
+ .fill(
+ ui.visuals().widgets.inactive.weak_bg_fill,
+ ),
+ );
+
+ // Draw the content over the button
+ let rect = response.rect;
+ let painter = ui.painter();
+
+ // Summary text (truncated)
+ let summary_text = &session.summary;
+ let text_color = ui.visuals().text_color();
+ painter.text(
+ rect.left_top() + egui::vec2(8.0, 8.0),
+ egui::Align2::LEFT_TOP,
+ summary_text,
+ egui::FontId::proportional(13.0),
+ text_color,
+ );
+
+ // Metadata line (time + message count)
+ let meta_text = format!(
+ "{} • {} messages",
+ format_relative_time(&session.last_timestamp),
+ session.message_count
+ );
+ painter.text(
+ rect.left_bottom() + egui::vec2(8.0, -8.0),
+ egui::Align2::LEFT_BOTTOM,
+ meta_text,
+ egui::FontId::proportional(11.0),
+ ui.visuals().weak_text_color(),
+ );
+
+ // Show keybind hint when Ctrl is held
+ if ctrl_held && idx < 9 {
+ let hint_text = format!("{}", idx + 1);
+ let hint_center = response.rect.right_center()
+ + egui::vec2(hint_width / 2.0 + 2.0, 0.0);
+ paint_keybind_hint(
+ ui,
+ hint_center,
+ &hint_text,
+ 18.0,
+ );
+ }
+
+ if response.clicked() {
+ action = Some(SessionPickerAction::ResumeSession {
+ cwd: cwd.clone(),
+ session_id: session.session_id.clone(),
+ title: session.summary.clone(),
+ });
+ }
+ });
+
+ ui.add_space(4.0);
+ }
+
+ if self.sessions.len() > MAX_SESSIONS_DISPLAYED {
+ ui.add_space(8.0);
+ ui.label(
+ RichText::new(format!(
+ "... and {} more sessions",
+ self.sessions.len() - MAX_SESSIONS_DISPLAYED
+ ))
+ .weak(),
+ );
+ }
+ });
+ }
+ },
+ );
+ });
+
+ action
+ }
+}
diff --git a/crates/notedeck_dave/src/ui/settings.rs b/crates/notedeck_dave/src/ui/settings.rs
@@ -0,0 +1,250 @@
+use crate::config::{AiProvider, DaveSettings};
+use crate::ui::keybind_hint::keybind_hint;
+
+/// Tracks the state of the settings panel
+pub struct DaveSettingsPanel {
+ /// Whether the panel is currently open
+ open: bool,
+ /// Working copy of settings being edited
+ editing: DaveSettings,
+ /// Custom model input (when user wants to type a model not in the list)
+ custom_model: String,
+ /// Whether to use custom model input
+ use_custom_model: bool,
+}
+
+/// Actions that can result from the settings panel
+#[derive(Debug)]
+pub enum SettingsPanelAction {
+ /// User saved the settings
+ Save(DaveSettings),
+ /// User cancelled the settings panel
+ Cancel,
+}
+
+impl Default for DaveSettingsPanel {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl DaveSettingsPanel {
+ pub fn new() -> Self {
+ DaveSettingsPanel {
+ open: false,
+ editing: DaveSettings::default(),
+ custom_model: String::new(),
+ use_custom_model: false,
+ }
+ }
+
+ pub fn is_open(&self) -> bool {
+ self.open
+ }
+
+ /// Open the panel with a copy of current settings to edit
+ pub fn open(&mut self, current: &DaveSettings) {
+ self.editing = current.clone();
+ self.custom_model = current.model.clone();
+ // Check if current model is in the available list
+ self.use_custom_model = !current
+ .provider
+ .available_models()
+ .contains(¤t.model.as_str());
+ self.open = true;
+ }
+
+ pub fn close(&mut self) {
+ self.open = false;
+ }
+
+ /// Prepare editing state for overlay mode
+ pub fn prepare_edit(&mut self, current: &DaveSettings) {
+ if !self.open {
+ self.editing = current.clone();
+ self.custom_model = current.model.clone();
+ self.use_custom_model = !current
+ .provider
+ .available_models()
+ .contains(¤t.model.as_str());
+ self.open = true;
+ }
+ }
+
+ /// Render settings as a full-panel overlay (replaces the main content)
+ pub fn overlay_ui(
+ &mut self,
+ ui: &mut egui::Ui,
+ current: &DaveSettings,
+ ) -> Option<SettingsPanelAction> {
+ // Initialize editing state if not already set
+ self.prepare_edit(current);
+
+ let mut action: Option<SettingsPanelAction> = None;
+ let is_narrow = notedeck::ui::is_narrow(ui.ctx());
+ let ctrl_held = ui.input(|i| i.modifiers.ctrl);
+
+ // Handle Ctrl+S to save
+ if ui.input(|i| i.modifiers.ctrl && i.key_pressed(egui::Key::S)) {
+ action = Some(SettingsPanelAction::Save(self.editing.clone()));
+ }
+
+ // Full panel frame with padding
+ egui::Frame::new()
+ .fill(ui.visuals().panel_fill)
+ .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20))
+ .show(ui, |ui| {
+ // Header with back button
+ ui.horizontal(|ui| {
+ if ui.button("< Back").clicked() {
+ action = Some(SettingsPanelAction::Cancel);
+ }
+ if ctrl_held {
+ keybind_hint(ui, "Esc");
+ }
+ ui.add_space(16.0);
+ ui.heading("Settings");
+ });
+
+ ui.add_space(24.0);
+
+ // Centered content container (max width for readability on desktop)
+ let max_content_width = if is_narrow {
+ ui.available_width()
+ } else {
+ 500.0
+ };
+ ui.allocate_ui_with_layout(
+ egui::vec2(max_content_width, ui.available_height()),
+ egui::Layout::top_down(egui::Align::LEFT),
+ |ui| {
+ self.settings_form(ui);
+
+ ui.add_space(24.0);
+
+ // Action buttons with keyboard hints
+ ui.horizontal(|ui| {
+ if ui.button("Save").clicked() {
+ action = Some(SettingsPanelAction::Save(self.editing.clone()));
+ }
+ if ctrl_held {
+ keybind_hint(ui, "S");
+ }
+ ui.add_space(8.0);
+ if ui.button("Cancel").clicked() {
+ action = Some(SettingsPanelAction::Cancel);
+ }
+ if ctrl_held {
+ keybind_hint(ui, "Esc");
+ }
+ });
+ },
+ );
+ });
+
+ // Handle Escape key
+ if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
+ action = Some(SettingsPanelAction::Cancel);
+ }
+
+ if action.is_some() {
+ self.close();
+ }
+
+ action
+ }
+
+ /// Render the settings form content (shared between overlay and window modes)
+ fn settings_form(&mut self, ui: &mut egui::Ui) {
+ egui::Grid::new("settings_grid")
+ .num_columns(2)
+ .spacing([10.0, 12.0])
+ .show(ui, |ui| {
+ // Provider dropdown
+ ui.label("Provider:");
+ let prev_provider = self.editing.provider;
+ egui::ComboBox::from_id_salt("provider_combo")
+ .selected_text(self.editing.provider.name())
+ .show_ui(ui, |ui| {
+ for provider in AiProvider::ALL {
+ ui.selectable_value(
+ &mut self.editing.provider,
+ provider,
+ provider.name(),
+ );
+ }
+ });
+ ui.end_row();
+
+ // If provider changed, reset to provider defaults
+ if self.editing.provider != prev_provider {
+ self.editing.model = self.editing.provider.default_model().to_string();
+ self.editing.endpoint = self
+ .editing
+ .provider
+ .default_endpoint()
+ .map(|s| s.to_string());
+ self.custom_model = self.editing.model.clone();
+ self.use_custom_model = false;
+ }
+
+ // Model selection
+ ui.label("Model:");
+ ui.vertical(|ui| {
+ // Checkbox for custom model
+ ui.checkbox(&mut self.use_custom_model, "Custom model");
+
+ if self.use_custom_model {
+ // Custom text input
+ let response = ui.text_edit_singleline(&mut self.custom_model);
+ if response.changed() {
+ self.editing.model = self.custom_model.clone();
+ }
+ } else {
+ // Dropdown with available models
+ egui::ComboBox::from_id_salt("model_combo")
+ .selected_text(&self.editing.model)
+ .show_ui(ui, |ui| {
+ for model in self.editing.provider.available_models() {
+ ui.selectable_value(
+ &mut self.editing.model,
+ model.to_string(),
+ *model,
+ );
+ }
+ });
+ }
+ });
+ ui.end_row();
+
+ // Endpoint field
+ ui.label("Endpoint:");
+ let mut endpoint_str = self.editing.endpoint.clone().unwrap_or_default();
+ if ui.text_edit_singleline(&mut endpoint_str).changed() {
+ self.editing.endpoint = if endpoint_str.is_empty() {
+ None
+ } else {
+ Some(endpoint_str)
+ };
+ }
+ ui.end_row();
+
+ // API Key field (only shown when required)
+ if self.editing.provider.requires_api_key() {
+ ui.label("API Key:");
+ let mut key_str = self.editing.api_key.clone().unwrap_or_default();
+ if ui
+ .add(egui::TextEdit::singleline(&mut key_str).password(true))
+ .changed()
+ {
+ self.editing.api_key = if key_str.is_empty() {
+ None
+ } else {
+ Some(key_str)
+ };
+ }
+ ui.end_row();
+ }
+ });
+ }
+}
diff --git a/crates/notedeck_dave/src/ui/top_buttons.rs b/crates/notedeck_dave/src/ui/top_buttons.rs
@@ -0,0 +1,109 @@
+/// Top buttons UI for the Dave chat interface.
+///
+/// Contains the profile picture button, settings button, and session list toggle
+/// that appear at the top of the chat view.
+use super::DaveAction;
+use nostrdb::{Ndb, Transaction};
+use notedeck::{Accounts, AppContext, Images, MediaJobSender};
+use notedeck_ui::{app_images, ProfilePic};
+
+/// Render the top buttons UI (profile pic, settings, session list toggle)
+pub fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAction> {
+ let mut action: Option<DaveAction> = None;
+ let mut rect = ui.available_rect_before_wrap();
+ rect = rect.translate(egui::vec2(20.0, 20.0));
+ rect.set_height(32.0);
+ rect.set_width(32.0);
+
+ // Show session list button on mobile/narrow screens
+ if notedeck::ui::is_narrow(ui.ctx()) {
+ let r = ui
+ .put(rect, egui::Button::new("\u{2630}").frame(false))
+ .on_hover_text("Show chats")
+ .on_hover_cursor(egui::CursorIcon::PointingHand);
+
+ if r.clicked() {
+ action = Some(DaveAction::ShowSessionList);
+ }
+
+ rect = rect.translate(egui::vec2(30.0, 0.0));
+ }
+
+ let txn = Transaction::new(app_ctx.ndb).unwrap();
+ let r = ui
+ .put(
+ rect,
+ &mut pfp_button(
+ &txn,
+ app_ctx.accounts,
+ app_ctx.img_cache,
+ app_ctx.ndb,
+ app_ctx.media_jobs.sender(),
+ ),
+ )
+ .on_hover_cursor(egui::CursorIcon::PointingHand);
+
+ if r.clicked() {
+ action = Some(DaveAction::ToggleChrome);
+ }
+
+ // Settings button
+ rect = rect.translate(egui::vec2(30.0, 0.0));
+ let dark_mode = ui.visuals().dark_mode;
+ let r = ui
+ .put(rect, settings_button(dark_mode))
+ .on_hover_cursor(egui::CursorIcon::PointingHand);
+
+ if r.clicked() {
+ action = Some(DaveAction::OpenSettings);
+ }
+
+ action
+}
+
+fn settings_button(dark_mode: bool) -> impl egui::Widget {
+ move |ui: &mut egui::Ui| {
+ let img_size = 24.0;
+ let max_size = 32.0;
+
+ let img = if dark_mode {
+ app_images::settings_dark_image()
+ } else {
+ app_images::settings_light_image()
+ }
+ .max_width(img_size);
+
+ let helper = notedeck_ui::anim::AnimationHelper::new(
+ ui,
+ "settings-button",
+ egui::vec2(max_size, max_size),
+ );
+
+ let cur_img_size = helper.scale_1d_pos(img_size);
+ img.paint_at(
+ ui,
+ helper
+ .get_animation_rect()
+ .shrink((max_size - cur_img_size) / 2.0),
+ );
+
+ helper.take_animation_response()
+ }
+}
+
+fn pfp_button<'me, 'a>(
+ txn: &'a Transaction,
+ accounts: &Accounts,
+ img_cache: &'me mut Images,
+ ndb: &Ndb,
+ jobs: &'me MediaJobSender,
+) -> ProfilePic<'me, 'a> {
+ let account = accounts.get_selected_account();
+ let profile = ndb
+ .get_profile_by_pubkey(txn, account.key.pubkey.bytes())
+ .ok();
+
+ ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref())
+ .size(24.0)
+ .sense(egui::Sense::click())
+}
diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs
@@ -0,0 +1,872 @@
+//! Helper functions for the Dave update loop.
+//!
+//! These are standalone functions with explicit inputs to reduce the complexity
+//! of the main Dave struct and make the code more testable and reusable.
+
+use crate::backend::AiBackend;
+use crate::config::AiMode;
+use crate::focus_queue::{FocusPriority, FocusQueue};
+use crate::messages::{
+ AnswerSummary, AnswerSummaryEntry, AskUserQuestionInput, Message, PermissionResponse,
+ QuestionAnswer,
+};
+use crate::session::{ChatSession, EditorJob, PermissionMessageState, SessionId, SessionManager};
+use crate::ui::{AgentScene, DirectoryPicker};
+use claude_agent_sdk_rs::PermissionMode;
+use std::path::PathBuf;
+use std::time::Instant;
+
+/// Timeout for confirming interrupt (in seconds)
+pub const INTERRUPT_CONFIRM_TIMEOUT_SECS: f32 = 1.5;
+
+// =============================================================================
+// Interrupt Handling
+// =============================================================================
+
+/// Handle an interrupt request - requires double-Escape to confirm.
+/// Returns the new pending_since state.
+pub fn handle_interrupt_request(
+ session_manager: &SessionManager,
+ backend: &dyn AiBackend,
+ pending_since: Option<Instant>,
+ ctx: &egui::Context,
+) -> Option<Instant> {
+ // Only allow interrupt if there's an active AI operation
+ let has_active_operation = session_manager
+ .get_active()
+ .map(|s| s.incoming_tokens.is_some())
+ .unwrap_or(false);
+
+ if !has_active_operation {
+ return None;
+ }
+
+ let now = Instant::now();
+
+ if let Some(pending) = pending_since {
+ if now.duration_since(pending).as_secs_f32() < INTERRUPT_CONFIRM_TIMEOUT_SECS {
+ // Second Escape within timeout - confirm interrupt
+ if let Some(session) = session_manager.get_active() {
+ let session_id = format!("dave-session-{}", session.id);
+ backend.interrupt_session(session_id, ctx.clone());
+ }
+ None
+ } else {
+ // Timeout expired, treat as new first press
+ Some(now)
+ }
+ } else {
+ // First Escape press
+ Some(now)
+ }
+}
+
+/// Execute the actual interrupt on the active session.
+pub fn execute_interrupt(
+ session_manager: &mut SessionManager,
+ backend: &dyn AiBackend,
+ ctx: &egui::Context,
+) {
+ if let Some(session) = session_manager.get_active_mut() {
+ let session_id = format!("dave-session-{}", session.id);
+ backend.interrupt_session(session_id, ctx.clone());
+ session.incoming_tokens = None;
+ if let Some(agentic) = &mut session.agentic {
+ agentic.pending_permissions.clear();
+ }
+ tracing::debug!("Interrupted session {}", session.id);
+ }
+}
+
+/// Check if interrupt confirmation has timed out.
+/// Returns None if timed out, otherwise returns the original value.
+pub fn check_interrupt_timeout(pending_since: Option<Instant>) -> Option<Instant> {
+ pending_since.filter(|pending| {
+ Instant::now().duration_since(*pending).as_secs_f32() < INTERRUPT_CONFIRM_TIMEOUT_SECS
+ })
+}
+
+// =============================================================================
+// Plan Mode
+// =============================================================================
+
+/// Toggle plan mode for the active session.
+pub fn toggle_plan_mode(
+ session_manager: &mut SessionManager,
+ backend: &dyn AiBackend,
+ ctx: &egui::Context,
+) {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ let new_mode = match agentic.permission_mode {
+ PermissionMode::Plan => PermissionMode::Default,
+ _ => PermissionMode::Plan,
+ };
+ agentic.permission_mode = new_mode;
+
+ let session_id = format!("dave-session-{}", session.id);
+ backend.set_permission_mode(session_id, new_mode, ctx.clone());
+
+ tracing::debug!(
+ "Toggled plan mode for session {} to {:?}",
+ session.id,
+ new_mode
+ );
+ }
+ }
+}
+
+/// Exit plan mode for the active session (switch to Default mode).
+pub fn exit_plan_mode(
+ session_manager: &mut SessionManager,
+ backend: &dyn AiBackend,
+ ctx: &egui::Context,
+) {
+ if let Some(session) = session_manager.get_active_mut() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_mode = PermissionMode::Default;
+ let session_id = format!("dave-session-{}", session.id);
+ backend.set_permission_mode(session_id, PermissionMode::Default, ctx.clone());
+ tracing::debug!("Exited plan mode for session {}", session.id);
+ }
+ }
+}
+
+// =============================================================================
+// Permission Handling
+// =============================================================================
+
+/// Get the first pending permission request ID for the active session.
+pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid::Uuid> {
+ session_manager
+ .get_active()
+ .and_then(|session| session.agentic.as_ref())
+ .and_then(|agentic| agentic.pending_permissions.keys().next().copied())
+}
+
+/// Get the tool name of the first pending permission request.
+pub fn pending_permission_tool_name(session_manager: &SessionManager) -> Option<&str> {
+ let session = session_manager.get_active()?;
+ let agentic = session.agentic.as_ref()?;
+ let request_id = agentic.pending_permissions.keys().next()?;
+
+ for msg in &session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if &req.id == request_id {
+ return Some(&req.tool_name);
+ }
+ }
+ }
+
+ None
+}
+
+/// Check if the first pending permission is an AskUserQuestion tool call.
+pub fn has_pending_question(session_manager: &SessionManager) -> bool {
+ pending_permission_tool_name(session_manager) == Some("AskUserQuestion")
+}
+
+/// Check if the first pending permission is an ExitPlanMode tool call.
+pub fn has_pending_exit_plan_mode(session_manager: &SessionManager) -> bool {
+ pending_permission_tool_name(session_manager) == Some("ExitPlanMode")
+}
+
+/// Handle a permission response (from UI button or keybinding).
+pub fn handle_permission_response(
+ session_manager: &mut SessionManager,
+ request_id: uuid::Uuid,
+ response: PermissionResponse,
+) {
+ let Some(session) = session_manager.get_active_mut() else {
+ return;
+ };
+
+ // Record the response type in the message for UI display
+ let response_type = match &response {
+ PermissionResponse::Allow { .. } => crate::messages::PermissionResponseType::Allowed,
+ PermissionResponse::Deny { .. } => crate::messages::PermissionResponseType::Denied,
+ };
+
+ // If Allow has a message, add it as a User message to the chat
+ if let PermissionResponse::Allow { message: Some(msg) } = &response {
+ if !msg.is_empty() {
+ session.chat.push(Message::User(msg.clone()));
+ }
+ }
+
+ // Clear permission message state (agentic only)
+ if let Some(agentic) = &mut session.agentic {
+ agentic.permission_message_state = PermissionMessageState::None;
+ }
+
+ for msg in &mut session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id {
+ req.response = Some(response_type);
+ break;
+ }
+ }
+ }
+
+ if let Some(agentic) = &mut session.agentic {
+ if let Some(sender) = agentic.pending_permissions.remove(&request_id) {
+ if sender.send(response).is_err() {
+ tracing::error!(
+ "Failed to send permission response for request {}",
+ request_id
+ );
+ }
+ } else {
+ tracing::warn!("No pending permission found for request {}", request_id);
+ }
+ }
+}
+
+/// Handle a user's response to an AskUserQuestion tool call.
+pub fn handle_question_response(
+ session_manager: &mut SessionManager,
+ request_id: uuid::Uuid,
+ answers: Vec<QuestionAnswer>,
+) {
+ let Some(session) = session_manager.get_active_mut() else {
+ return;
+ };
+
+ // Find the original AskUserQuestion request to get the question labels
+ let questions_input = session.chat.iter().find_map(|msg| {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id && req.tool_name == "AskUserQuestion" {
+ serde_json::from_value::<AskUserQuestionInput>(req.tool_input.clone()).ok()
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ });
+
+ // Format answers as JSON for the tool response, and build summary for display
+ let (formatted_response, answer_summary) = if let Some(ref questions) = questions_input {
+ let mut answers_obj = serde_json::Map::new();
+ let mut summary_entries = Vec::with_capacity(questions.questions.len());
+
+ for (q_idx, (question, answer)) in
+ questions.questions.iter().zip(answers.iter()).enumerate()
+ {
+ let mut answer_obj = serde_json::Map::new();
+
+ // Map selected indices to option labels
+ let selected_labels: Vec<String> = answer
+ .selected
+ .iter()
+ .filter_map(|&idx| question.options.get(idx).map(|o| o.label.clone()))
+ .collect();
+
+ answer_obj.insert(
+ "selected".to_string(),
+ serde_json::Value::Array(
+ selected_labels
+ .iter()
+ .cloned()
+ .map(serde_json::Value::String)
+ .collect(),
+ ),
+ );
+
+ // Build display text for summary
+ let mut display_parts = selected_labels;
+ if let Some(ref other) = answer.other_text {
+ if !other.is_empty() {
+ answer_obj.insert(
+ "other".to_string(),
+ serde_json::Value::String(other.clone()),
+ );
+ display_parts.push(format!("Other: {}", other));
+ }
+ }
+
+ // Use header as the key, fall back to question index
+ let key = if !question.header.is_empty() {
+ question.header.clone()
+ } else {
+ format!("question_{}", q_idx)
+ };
+ answers_obj.insert(key.clone(), serde_json::Value::Object(answer_obj));
+
+ summary_entries.push(AnswerSummaryEntry {
+ header: key,
+ answer: display_parts.join(", "),
+ });
+ }
+
+ (
+ serde_json::json!({ "answers": answers_obj }).to_string(),
+ Some(AnswerSummary {
+ entries: summary_entries,
+ }),
+ )
+ } else {
+ // Fallback: just serialize the answers directly
+ (
+ serde_json::to_string(&answers).unwrap_or_else(|_| "{}".to_string()),
+ None,
+ )
+ };
+
+ // Mark the request as allowed in the UI and store the summary for display
+ for msg in &mut session.chat {
+ if let Message::PermissionRequest(req) = msg {
+ if req.id == request_id {
+ req.response = Some(crate::messages::PermissionResponseType::Allowed);
+ req.answer_summary = answer_summary.clone();
+ break;
+ }
+ }
+ }
+
+ // Clean up transient answer state and send response (agentic only)
+ if let Some(agentic) = &mut session.agentic {
+ agentic.question_answers.remove(&request_id);
+ agentic.question_index.remove(&request_id);
+
+ // Send the response through the permission channel
+ if let Some(sender) = agentic.pending_permissions.remove(&request_id) {
+ let response = PermissionResponse::Allow {
+ message: Some(formatted_response),
+ };
+ if sender.send(response).is_err() {
+ tracing::error!(
+ "Failed to send question response for request {}",
+ request_id
+ );
+ }
+ } else {
+ tracing::warn!("No pending permission found for request {}", request_id);
+ }
+ }
+}
+
+// =============================================================================
+// Agent Navigation
+// =============================================================================
+
+/// Switch to agent by index in the ordered list (0-indexed).
+pub fn switch_to_agent_by_index(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ index: usize,
+) {
+ let ids = session_manager.session_ids();
+ if let Some(&id) = ids.get(index) {
+ session_manager.switch_to(id);
+ if show_scene {
+ scene.select(id);
+ }
+ if let Some(session) = session_manager.get_mut(id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+/// Cycle to the next agent.
+pub fn cycle_next_agent(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ show_scene: bool,
+) {
+ let ids = session_manager.session_ids();
+ if ids.is_empty() {
+ return;
+ }
+ let current_idx = session_manager
+ .active_id()
+ .and_then(|active| ids.iter().position(|&id| id == active))
+ .unwrap_or(0);
+ let next_idx = (current_idx + 1) % ids.len();
+ if let Some(&id) = ids.get(next_idx) {
+ session_manager.switch_to(id);
+ if show_scene {
+ scene.select(id);
+ }
+ if let Some(session) = session_manager.get_mut(id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+/// Cycle to the previous agent.
+pub fn cycle_prev_agent(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ show_scene: bool,
+) {
+ let ids = session_manager.session_ids();
+ if ids.is_empty() {
+ return;
+ }
+ let current_idx = session_manager
+ .active_id()
+ .and_then(|active| ids.iter().position(|&id| id == active))
+ .unwrap_or(0);
+ let prev_idx = if current_idx == 0 {
+ ids.len() - 1
+ } else {
+ current_idx - 1
+ };
+ if let Some(&id) = ids.get(prev_idx) {
+ session_manager.switch_to(id);
+ if show_scene {
+ scene.select(id);
+ }
+ if let Some(session) = session_manager.get_mut(id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+// =============================================================================
+// Focus Queue Operations
+// =============================================================================
+
+/// Navigate to the next item in the focus queue.
+pub fn focus_queue_next(
+ session_manager: &mut SessionManager,
+ focus_queue: &mut FocusQueue,
+ scene: &mut AgentScene,
+ show_scene: bool,
+) {
+ if let Some(session_id) = focus_queue.next() {
+ session_manager.switch_to(session_id);
+ if show_scene {
+ scene.select(session_id);
+ if let Some(session) = session_manager.get(session_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ if let Some(session) = session_manager.get_mut(session_id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+/// Navigate to the previous item in the focus queue.
+pub fn focus_queue_prev(
+ session_manager: &mut SessionManager,
+ focus_queue: &mut FocusQueue,
+ scene: &mut AgentScene,
+ show_scene: bool,
+) {
+ if let Some(session_id) = focus_queue.prev() {
+ session_manager.switch_to(session_id);
+ if show_scene {
+ scene.select(session_id);
+ if let Some(session) = session_manager.get(session_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ if let Some(session) = session_manager.get_mut(session_id) {
+ if !session.has_pending_permissions() {
+ session.focus_requested = true;
+ }
+ }
+ }
+}
+
+/// Toggle Done status for the current focus queue item.
+pub fn focus_queue_toggle_done(focus_queue: &mut FocusQueue) {
+ if let Some(entry) = focus_queue.current() {
+ if entry.priority == FocusPriority::Done {
+ focus_queue.dequeue(entry.session_id);
+ }
+ }
+}
+
+/// Toggle auto-steal focus mode.
+/// Returns the new auto_steal_focus state.
+pub fn toggle_auto_steal(
+ session_manager: &mut SessionManager,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ auto_steal_focus: bool,
+ home_session: &mut Option<SessionId>,
+) -> bool {
+ let new_state = !auto_steal_focus;
+
+ if new_state {
+ // Enabling: record current session as home
+ *home_session = session_manager.active_id();
+ tracing::debug!("Auto-steal focus enabled, home session: {:?}", home_session);
+ } else {
+ // Disabling: switch back to home session if set
+ if let Some(home_id) = home_session.take() {
+ session_manager.switch_to(home_id);
+ if show_scene {
+ scene.select(home_id);
+ if let Some(session) = session_manager.get(home_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ tracing::debug!("Auto-steal focus disabled, returned to home session");
+ }
+ }
+
+ // Request focus on input after toggle
+ if let Some(session) = session_manager.get_active_mut() {
+ session.focus_requested = true;
+ }
+
+ new_state
+}
+
+/// Process auto-steal focus logic: switch to focus queue items as needed.
+pub fn process_auto_steal_focus(
+ session_manager: &mut SessionManager,
+ focus_queue: &mut FocusQueue,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ auto_steal_focus: bool,
+ home_session: &mut Option<SessionId>,
+) {
+ if !auto_steal_focus {
+ return;
+ }
+
+ let has_needs_input = focus_queue.has_needs_input();
+
+ if has_needs_input {
+ // There are NeedsInput items - check if we need to steal focus
+ let current_session = session_manager.active_id();
+ let current_priority = current_session.and_then(|id| focus_queue.get_session_priority(id));
+ let already_on_needs_input = current_priority == Some(FocusPriority::NeedsInput);
+
+ if !already_on_needs_input {
+ // Save current session before stealing (only if we haven't saved yet)
+ if home_session.is_none() {
+ *home_session = current_session;
+ tracing::debug!("Auto-steal: saved home session {:?}", home_session);
+ }
+
+ // Jump to first NeedsInput item
+ if let Some(idx) = focus_queue.first_needs_input_index() {
+ focus_queue.set_cursor(idx);
+ if let Some(entry) = focus_queue.current() {
+ session_manager.switch_to(entry.session_id);
+ if show_scene {
+ scene.select(entry.session_id);
+ if let Some(session) = session_manager.get(entry.session_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id);
+ }
+ }
+ }
+ } else if let Some(home_id) = home_session.take() {
+ // No more NeedsInput items - return to saved session
+ session_manager.switch_to(home_id);
+ if show_scene {
+ scene.select(home_id);
+ if let Some(session) = session_manager.get(home_id) {
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ tracing::debug!("Auto-steal: returned to home session {:?}", home_id);
+ }
+}
+
+// =============================================================================
+// External Editor
+// =============================================================================
+
+/// Try to find a common terminal emulator.
+pub fn find_terminal() -> Option<String> {
+ use std::process::Command;
+ let terminals = [
+ "alacritty",
+ "kitty",
+ "gnome-terminal",
+ "konsole",
+ "urxvtc",
+ "urxvt",
+ "xterm",
+ ];
+ for term in terminals {
+ if Command::new("which")
+ .arg(term)
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+ {
+ return Some(term.to_string());
+ }
+ }
+ None
+}
+
+/// Open an external editor for composing the input text (non-blocking).
+pub fn open_external_editor(session_manager: &mut SessionManager) {
+ use std::process::Command;
+
+ // Don't spawn another editor if one is already pending
+ if session_manager.pending_editor.is_some() {
+ tracing::warn!("External editor already in progress");
+ return;
+ }
+
+ let Some(session) = session_manager.get_active_mut() else {
+ return;
+ };
+ let session_id = session.id;
+ let input_content = session.input.clone();
+
+ // Create temp file with current input content
+ let temp_path = std::env::temp_dir().join("notedeck_input.txt");
+ if let Err(e) = std::fs::write(&temp_path, &input_content) {
+ tracing::error!("Failed to write temp file for external editor: {}", e);
+ return;
+ }
+
+ // Try $VISUAL first (GUI editors), then fall back to terminal + $EDITOR
+ let visual = std::env::var("VISUAL").ok();
+ let editor = std::env::var("EDITOR").ok();
+
+ let spawn_result = if let Some(visual_editor) = visual {
+ // $VISUAL is set - use it directly (assumes GUI editor)
+ tracing::debug!("Opening external editor via $VISUAL: {}", visual_editor);
+ Command::new(&visual_editor).arg(&temp_path).spawn()
+ } else {
+ // Fall back to terminal + $EDITOR
+ let editor_cmd = editor.unwrap_or_else(|| "vim".to_string());
+ let terminal = std::env::var("TERMINAL")
+ .ok()
+ .or_else(find_terminal)
+ .unwrap_or_else(|| "xterm".to_string());
+
+ tracing::debug!(
+ "Opening external editor via terminal: {} -e {} {}",
+ terminal,
+ editor_cmd,
+ temp_path.display()
+ );
+ Command::new(&terminal)
+ .arg("-e")
+ .arg(&editor_cmd)
+ .arg(&temp_path)
+ .spawn()
+ };
+
+ match spawn_result {
+ Ok(child) => {
+ session_manager.pending_editor = Some(EditorJob {
+ child,
+ temp_path,
+ session_id,
+ });
+ tracing::debug!("External editor spawned for session {}", session_id);
+ }
+ Err(e) => {
+ tracing::error!("Failed to spawn external editor: {}", e);
+ let _ = std::fs::remove_file(&temp_path);
+ }
+ }
+}
+
+/// Poll for external editor completion (called each frame).
+pub fn poll_editor_job(session_manager: &mut SessionManager) {
+ let Some(ref mut job) = session_manager.pending_editor else {
+ return;
+ };
+
+ // Non-blocking check if child has exited
+ match job.child.try_wait() {
+ Ok(Some(status)) => {
+ let session_id = job.session_id;
+ let temp_path = job.temp_path.clone();
+
+ if status.success() {
+ match std::fs::read_to_string(&temp_path) {
+ Ok(content) => {
+ if let Some(session) = session_manager.get_mut(session_id) {
+ session.input = content;
+ session.focus_requested = true;
+ tracing::debug!(
+ "External editor completed, updated input for session {}",
+ session_id
+ );
+ }
+ }
+ Err(e) => {
+ tracing::error!("Failed to read temp file after editing: {}", e);
+ }
+ }
+ } else {
+ tracing::warn!("External editor exited with status: {}", status);
+ }
+
+ if let Err(e) = std::fs::remove_file(&temp_path) {
+ tracing::error!("Failed to remove temp file: {}", e);
+ }
+
+ session_manager.pending_editor = None;
+ }
+ Ok(None) => {
+ // Editor still running
+ }
+ Err(e) => {
+ tracing::error!("Failed to poll editor process: {}", e);
+ let temp_path = job.temp_path.clone();
+ let _ = std::fs::remove_file(&temp_path);
+ session_manager.pending_editor = None;
+ }
+ }
+}
+
+// =============================================================================
+// Session Management
+// =============================================================================
+
+/// Create a new session with the given cwd.
+pub fn create_session_with_cwd(
+ session_manager: &mut SessionManager,
+ directory_picker: &mut DirectoryPicker,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ ai_mode: AiMode,
+ cwd: PathBuf,
+) -> SessionId {
+ directory_picker.add_recent(cwd.clone());
+
+ let id = session_manager.new_session(cwd, ai_mode);
+ if let Some(session) = session_manager.get_mut(id) {
+ session.focus_requested = true;
+ if show_scene {
+ scene.select(id);
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ id
+}
+
+/// Create a new session that resumes an existing Claude conversation.
+#[allow(clippy::too_many_arguments)]
+pub fn create_resumed_session_with_cwd(
+ session_manager: &mut SessionManager,
+ directory_picker: &mut DirectoryPicker,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ ai_mode: AiMode,
+ cwd: PathBuf,
+ resume_session_id: String,
+ title: String,
+) -> SessionId {
+ directory_picker.add_recent(cwd.clone());
+
+ let id = session_manager.new_resumed_session(cwd, resume_session_id, title, ai_mode);
+ if let Some(session) = session_manager.get_mut(id) {
+ session.focus_requested = true;
+ if show_scene {
+ scene.select(id);
+ if let Some(agentic) = &session.agentic {
+ scene.focus_on(agentic.scene_position);
+ }
+ }
+ }
+ id
+}
+
+/// Clone the active agent, creating a new session with the same working directory.
+pub fn clone_active_agent(
+ session_manager: &mut SessionManager,
+ directory_picker: &mut DirectoryPicker,
+ scene: &mut AgentScene,
+ show_scene: bool,
+ ai_mode: AiMode,
+) -> Option<SessionId> {
+ let cwd = session_manager
+ .get_active()
+ .and_then(|s| s.cwd().cloned())?;
+ Some(create_session_with_cwd(
+ session_manager,
+ directory_picker,
+ scene,
+ show_scene,
+ ai_mode,
+ cwd,
+ ))
+}
+
+/// Delete a session and clean up backend resources.
+pub fn delete_session(
+ session_manager: &mut SessionManager,
+ focus_queue: &mut FocusQueue,
+ backend: &dyn AiBackend,
+ directory_picker: &mut DirectoryPicker,
+ id: SessionId,
+) -> bool {
+ focus_queue.remove_session(id);
+ if session_manager.delete_session(id) {
+ let session_id = format!("dave-session-{}", id);
+ backend.cleanup_session(session_id);
+
+ if session_manager.is_empty() {
+ directory_picker.open();
+ }
+ true
+ } else {
+ false
+ }
+}
+
+// =============================================================================
+// Send Action Handling
+// =============================================================================
+
+/// Handle the /cd command if present in input.
+/// Returns Some(Ok(path)) if cd succeeded, Some(Err(())) if cd failed, None if not a cd command.
+pub fn handle_cd_command(session: &mut ChatSession) -> Option<Result<PathBuf, ()>> {
+ let input = session.input.trim().to_string();
+ if !input.starts_with("/cd ") {
+ return None;
+ }
+
+ let path_str = input.strip_prefix("/cd ").unwrap().trim();
+ let path = PathBuf::from(path_str);
+ session.input.clear();
+
+ if path.exists() && path.is_dir() {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.cwd = path.clone();
+ }
+ session.chat.push(Message::System(format!(
+ "Working directory set to: {}",
+ path.display()
+ )));
+ Some(Ok(path))
+ } else {
+ session
+ .chat
+ .push(Message::Error(format!("Invalid directory: {}", path_str)));
+ Some(Err(()))
+ }
+}
diff --git a/crates/notedeck_dave/tests/claude_integration.rs b/crates/notedeck_dave/tests/claude_integration.rs
@@ -0,0 +1,556 @@
+//! Integration tests for Claude Code SDK
+//!
+//! These tests require Claude Code CLI to be installed and authenticated.
+//! Run with: cargo test -p notedeck_dave --test claude_integration -- --ignored
+//!
+//! The SDK spawns the Claude Code CLI as a subprocess and communicates via JSON streaming.
+//! The CLAUDE_API_KEY environment variable is read by the CLI subprocess.
+
+use claude_agent_sdk_rs::{
+ get_claude_code_version, query_stream, ClaudeAgentOptions, ClaudeClient, ContentBlock,
+ Message as ClaudeMessage, PermissionMode, PermissionResult, PermissionResultAllow,
+ PermissionResultDeny, TextBlock, ToolPermissionContext,
+};
+use futures::future::BoxFuture;
+use futures::StreamExt;
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::Arc;
+
+/// Check if Claude CLI is available
+fn cli_available() -> bool {
+ get_claude_code_version().is_some()
+}
+
+/// Build test options with cost controls.
+/// Uses BypassPermissions to avoid interactive prompts in automated testing.
+/// Includes a stderr callback to prevent subprocess blocking.
+fn test_options() -> ClaudeAgentOptions {
+ // A stderr callback is needed to prevent the subprocess from blocking
+ // when stderr buffer fills up. We just discard the output.
+ let stderr_callback = |_msg: String| {};
+
+ ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::BypassPermissions)
+ .max_turns(1)
+ .skip_version_check(true)
+ .stderr_callback(Arc::new(stderr_callback))
+ .build()
+}
+
+/// Non-ignored test that checks CLI availability without failing.
+/// This test always passes - it just reports whether the CLI is present.
+#[test]
+fn test_cli_version_available() {
+ let version = get_claude_code_version();
+ match version {
+ Some(v) => println!("Claude Code CLI version: {}", v),
+ None => println!("Claude Code CLI not installed - integration tests will be skipped"),
+ }
+}
+
+/// Test that the Claude Code SDK returns a text response.
+/// Validates that we receive actual text content from Claude.
+#[tokio::test]
+#[ignore = "Requires Claude Code CLI to be installed and authenticated"]
+async fn test_simple_query_returns_text() {
+ if !cli_available() {
+ println!("Skipping: Claude CLI not available");
+ return;
+ }
+
+ let prompt = "Respond with exactly: Hello";
+ let options = test_options();
+
+ let mut stream = match query_stream(prompt.to_string(), Some(options)).await {
+ Ok(s) => s,
+ Err(e) => {
+ panic!("Failed to create stream: {}", e);
+ }
+ };
+
+ let mut received_text = String::new();
+
+ while let Some(result) = stream.next().await {
+ match result {
+ Ok(message) => {
+ if let ClaudeMessage::Assistant(assistant_msg) = message {
+ for block in &assistant_msg.message.content {
+ if let ContentBlock::Text(TextBlock { text }) = block {
+ received_text.push_str(text);
+ }
+ }
+ }
+ }
+ Err(e) => {
+ panic!("Stream error: {}", e);
+ }
+ }
+ }
+
+ assert!(
+ !received_text.is_empty(),
+ "Should receive text response from Claude"
+ );
+}
+
+/// Test that the Result message is received to mark completion.
+#[tokio::test]
+#[ignore = "Requires Claude Code CLI to be installed and authenticated"]
+async fn test_result_message_received() {
+ if !cli_available() {
+ println!("Skipping: Claude CLI not available");
+ return;
+ }
+
+ let prompt = "Say hi";
+ let options = test_options();
+
+ let mut stream = match query_stream(prompt.to_string(), Some(options)).await {
+ Ok(s) => s,
+ Err(e) => {
+ panic!("Failed to create stream: {}", e);
+ }
+ };
+
+ let mut received_result = false;
+
+ while let Some(result) = stream.next().await {
+ match result {
+ Ok(message) => {
+ if let ClaudeMessage::Result(_) = message {
+ received_result = true;
+ break;
+ }
+ }
+ Err(e) => {
+ panic!("Stream error: {}", e);
+ }
+ }
+ }
+
+ assert!(
+ received_result,
+ "Should receive Result message marking completion"
+ );
+}
+
+/// Test that empty prompt is handled gracefully (no panic).
+#[tokio::test]
+#[ignore = "Requires Claude Code CLI to be installed and authenticated"]
+async fn test_empty_prompt_handled() {
+ if !cli_available() {
+ println!("Skipping: Claude CLI not available");
+ return;
+ }
+
+ let prompt = "";
+ let options = test_options();
+
+ let result = query_stream(prompt.to_string(), Some(options)).await;
+
+ // Empty prompt should either work or fail gracefully - either is acceptable
+ if let Ok(mut stream) = result {
+ // Consume the stream - we just care it doesn't panic
+ while let Some(_) = stream.next().await {}
+ }
+ // If result is Err, that's also fine - as long as we didn't panic
+}
+
+/// Verify that our prompt formatting produces substantial output.
+/// This is a pure unit test that doesn't require Claude CLI.
+#[test]
+fn test_prompt_formatting_is_substantial() {
+ // Simulate what messages_to_prompt should produce
+ let system = "You are Dave, a helpful Nostr assistant.";
+ let user_msg = "Hi";
+
+ // Build a proper prompt like messages_to_prompt should
+ let prompt = format!("{}\n\nHuman: {}\n\n", system, user_msg);
+
+ // The prompt should be much longer than just "Hi" (2 chars)
+ // If only the user message was sent (the bug), length would be ~2
+ // With system message, it should be ~60+
+ assert!(
+ prompt.len() > 50,
+ "Prompt with system message should be substantial. Got {} chars: {:?}",
+ prompt.len(),
+ prompt
+ );
+
+ // Verify the prompt contains what we expect
+ assert!(
+ prompt.contains(system),
+ "Prompt should contain system message"
+ );
+ assert!(
+ prompt.contains("Human: Hi"),
+ "Prompt should contain formatted user message"
+ );
+}
+
+/// Test that the can_use_tool callback is invoked when Claude tries to use a tool.
+#[tokio::test]
+#[ignore = "Requires Claude Code CLI to be installed and authenticated"]
+async fn test_can_use_tool_callback_invoked() {
+ if !cli_available() {
+ println!("Skipping: Claude CLI not available");
+ return;
+ }
+
+ let callback_count = Arc::new(AtomicUsize::new(0));
+ let callback_count_clone = callback_count.clone();
+
+ // Create a callback that counts invocations and always allows
+ let can_use_tool: Arc<
+ dyn Fn(
+ String,
+ serde_json::Value,
+ ToolPermissionContext,
+ ) -> BoxFuture<'static, PermissionResult>
+ + Send
+ + Sync,
+ > = Arc::new(move |tool_name: String, _tool_input, _context| {
+ let count = callback_count_clone.clone();
+ Box::pin(async move {
+ count.fetch_add(1, Ordering::SeqCst);
+ println!("Permission requested for tool: {}", tool_name);
+ PermissionResult::Allow(PermissionResultAllow::default())
+ })
+ });
+
+ let stderr_callback = |_msg: String| {};
+
+ let options = ClaudeAgentOptions::builder()
+ .tools(["Read"])
+ .permission_mode(PermissionMode::Default)
+ .max_turns(3)
+ .skip_version_check(true)
+ .stderr_callback(Arc::new(stderr_callback))
+ .can_use_tool(can_use_tool)
+ .build();
+
+ // Ask Claude to read a file - this should trigger the Read tool
+ let prompt = "Read the file /etc/hostname";
+
+ // Use ClaudeClient which wires up the control protocol for can_use_tool callbacks
+ let mut client = ClaudeClient::new(options);
+ client.connect().await.expect("Failed to connect");
+ client.query(prompt).await.expect("Failed to send query");
+
+ // Consume the stream
+ let mut stream = client.receive_response();
+ while let Some(result) = stream.next().await {
+ match result {
+ Ok(msg) => println!("Stream message: {:?}", msg),
+ Err(e) => println!("Stream error: {:?}", e),
+ }
+ }
+
+ let count = callback_count.load(Ordering::SeqCst);
+ assert!(
+ count > 0,
+ "can_use_tool callback should have been invoked at least once, but was invoked {} times",
+ count
+ );
+ println!("can_use_tool callback was invoked {} time(s)", count);
+}
+
+/// Test session management - sending multiple queries with session context maintained.
+/// The ClaudeClient must be kept connected to maintain session context.
+#[tokio::test]
+#[ignore = "Requires Claude Code CLI to be installed and authenticated"]
+async fn test_session_context_maintained() {
+ if !cli_available() {
+ println!("Skipping: Claude CLI not available");
+ return;
+ }
+
+ let stderr_callback = |_msg: String| {};
+
+ let options = ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::BypassPermissions)
+ .max_turns(1)
+ .skip_version_check(true)
+ .stderr_callback(Arc::new(stderr_callback))
+ .build();
+
+ let mut client = ClaudeClient::new(options);
+ client.connect().await.expect("Failed to connect");
+
+ // First query - tell Claude a secret
+ let session_id = "test-session-context";
+ println!("Sending first query to session: {}", session_id);
+ client
+ .query_with_session(
+ "Remember this secret code: BANANA42. Just acknowledge.",
+ session_id,
+ )
+ .await
+ .expect("Failed to send first query");
+
+ // Consume first response
+ let mut first_response = String::new();
+ {
+ let mut stream = client.receive_response();
+ while let Some(result) = stream.next().await {
+ if let Ok(ClaudeMessage::Assistant(msg)) = result {
+ for block in &msg.message.content {
+ if let ContentBlock::Text(TextBlock { text }) = block {
+ first_response.push_str(text);
+ }
+ }
+ }
+ }
+ }
+ println!("First response: {}", first_response);
+
+ // Second query - ask about the secret (should remember within same session)
+ println!("Sending second query to same session");
+ client
+ .query_with_session("What was the secret code I told you?", session_id)
+ .await
+ .expect("Failed to send second query");
+
+ // Check if second response mentions the secret
+ let mut second_response = String::new();
+ {
+ let mut stream = client.receive_response();
+ while let Some(result) = stream.next().await {
+ if let Ok(ClaudeMessage::Assistant(msg)) = result {
+ for block in &msg.message.content {
+ if let ContentBlock::Text(TextBlock { text }) = block {
+ second_response.push_str(text);
+ }
+ }
+ }
+ }
+ }
+ println!("Second response: {}", second_response);
+
+ client.disconnect().await.expect("Failed to disconnect");
+
+ // The second response should contain the secret code if context is maintained
+ assert!(
+ second_response.to_uppercase().contains("BANANA42"),
+ "Claude should remember the secret code from the same session. Got: {}",
+ second_response
+ );
+}
+
+/// Test that different session IDs maintain separate contexts.
+#[tokio::test]
+#[ignore = "Requires Claude Code CLI to be installed and authenticated"]
+async fn test_separate_sessions_have_separate_context() {
+ if !cli_available() {
+ println!("Skipping: Claude CLI not available");
+ return;
+ }
+
+ let stderr_callback = |_msg: String| {};
+
+ let options = ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::BypassPermissions)
+ .max_turns(1)
+ .skip_version_check(true)
+ .stderr_callback(Arc::new(stderr_callback))
+ .build();
+
+ let mut client = ClaudeClient::new(options);
+ client.connect().await.expect("Failed to connect");
+
+ // First session - tell a secret
+ println!("Session A: Setting secret");
+ client
+ .query_with_session(
+ "Remember: The password is APPLE123. Just acknowledge.",
+ "session-A",
+ )
+ .await
+ .expect("Failed to send to session A");
+
+ {
+ let mut stream = client.receive_response();
+ while let Some(_) = stream.next().await {}
+ }
+
+ // Different session - should NOT know the secret
+ println!("Session B: Asking about secret");
+ client
+ .query_with_session(
+ "What password did I tell you? If you don't know, just say 'I don't know any password'.",
+ "session-B",
+ )
+ .await
+ .expect("Failed to send to session B");
+
+ let mut response_b = String::new();
+ {
+ let mut stream = client.receive_response();
+ while let Some(result) = stream.next().await {
+ if let Ok(ClaudeMessage::Assistant(msg)) = result {
+ for block in &msg.message.content {
+ if let ContentBlock::Text(TextBlock { text }) = block {
+ response_b.push_str(text);
+ }
+ }
+ }
+ }
+ }
+ println!("Session B response: {}", response_b);
+
+ client.disconnect().await.expect("Failed to disconnect");
+
+ // Session B should NOT know the password from Session A
+ assert!(
+ !response_b.to_uppercase().contains("APPLE123"),
+ "Session B should NOT know the password from Session A. Got: {}",
+ response_b
+ );
+}
+
+/// Test --continue flag for resuming the last conversation.
+/// This tests the simpler approach of continuing the most recent conversation.
+#[tokio::test]
+#[ignore = "Requires Claude Code CLI to be installed and authenticated"]
+async fn test_continue_conversation_flag() {
+ if !cli_available() {
+ println!("Skipping: Claude CLI not available");
+ return;
+ }
+
+ let stderr_callback = |_msg: String| {};
+
+ // First: Start a fresh conversation
+ let options1 = ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::BypassPermissions)
+ .max_turns(1)
+ .skip_version_check(true)
+ .stderr_callback(Arc::new(stderr_callback))
+ .build();
+
+ let mut stream1 = query_stream(
+ "Remember this code: ZEBRA999. Just acknowledge.".to_string(),
+ Some(options1),
+ )
+ .await
+ .expect("First query failed");
+
+ let mut first_response = String::new();
+ while let Some(result) = stream1.next().await {
+ if let Ok(ClaudeMessage::Assistant(msg)) = result {
+ for block in &msg.message.content {
+ if let ContentBlock::Text(TextBlock { text }) = block {
+ first_response.push_str(text);
+ }
+ }
+ }
+ }
+ println!("First response: {}", first_response);
+
+ // Second: Use --continue to resume and ask about the code
+ let stderr_callback2 = |_msg: String| {};
+ let options2 = ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::BypassPermissions)
+ .max_turns(1)
+ .skip_version_check(true)
+ .stderr_callback(Arc::new(stderr_callback2))
+ .continue_conversation(true)
+ .build();
+
+ let mut stream2 = query_stream("What was the code I told you?".to_string(), Some(options2))
+ .await
+ .expect("Second query failed");
+
+ let mut second_response = String::new();
+ while let Some(result) = stream2.next().await {
+ if let Ok(ClaudeMessage::Assistant(msg)) = result {
+ for block in &msg.message.content {
+ if let ContentBlock::Text(TextBlock { text }) = block {
+ second_response.push_str(text);
+ }
+ }
+ }
+ }
+ println!("Second response (with --continue): {}", second_response);
+
+ // Claude should remember the code when using --continue
+ assert!(
+ second_response.to_uppercase().contains("ZEBRA999"),
+ "Claude should remember the code with --continue. Got: {}",
+ second_response
+ );
+}
+
+/// Test that denying a tool permission prevents the tool from executing.
+#[tokio::test]
+#[ignore = "Requires Claude Code CLI to be installed and authenticated"]
+async fn test_can_use_tool_deny_prevents_execution() {
+ if !cli_available() {
+ println!("Skipping: Claude CLI not available");
+ return;
+ }
+
+ let was_denied = Arc::new(AtomicBool::new(false));
+ let was_denied_clone = was_denied.clone();
+
+ // Create a callback that always denies
+ let can_use_tool: Arc<
+ dyn Fn(
+ String,
+ serde_json::Value,
+ ToolPermissionContext,
+ ) -> BoxFuture<'static, PermissionResult>
+ + Send
+ + Sync,
+ > = Arc::new(move |tool_name: String, _tool_input, _context| {
+ let denied = was_denied_clone.clone();
+ Box::pin(async move {
+ denied.store(true, Ordering::SeqCst);
+ println!("Denying permission for tool: {}", tool_name);
+ PermissionResult::Deny(PermissionResultDeny {
+ message: "Test denial - permission not granted".to_string(),
+ interrupt: false,
+ })
+ })
+ });
+
+ let stderr_callback = |_msg: String| {};
+
+ let options = ClaudeAgentOptions::builder()
+ .tools(["Read"])
+ .permission_mode(PermissionMode::Default)
+ .max_turns(3)
+ .skip_version_check(true)
+ .stderr_callback(Arc::new(stderr_callback))
+ .can_use_tool(can_use_tool)
+ .build();
+
+ // Ask Claude to read a file
+ let prompt = "Read the file /etc/hostname";
+
+ // Use ClaudeClient which wires up the control protocol for can_use_tool callbacks
+ let mut client = ClaudeClient::new(options);
+ client.connect().await.expect("Failed to connect");
+ client.query(prompt).await.expect("Failed to send query");
+
+ let mut response_text = String::new();
+ let mut stream = client.receive_response();
+ while let Some(result) = stream.next().await {
+ match result {
+ Ok(ClaudeMessage::Assistant(msg)) => {
+ for block in &msg.message.content {
+ if let ContentBlock::Text(TextBlock { text }) = block {
+ response_text.push_str(text);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ assert!(
+ was_denied.load(Ordering::SeqCst),
+ "The can_use_tool callback should have been invoked and denied"
+ );
+ println!("Response after denial: {}", response_text);
+}
diff --git a/crates/notedeck_dave/tests/tool_result_integration.rs b/crates/notedeck_dave/tests/tool_result_integration.rs
@@ -0,0 +1,156 @@
+//! Integration test for tool result metadata display
+//!
+//! Tests that tool results are captured from the message stream
+//! by correlating ToolUse and ToolResult content blocks.
+
+use claude_agent_sdk_rs::{ContentBlock, ToolResultBlock, ToolResultContent, ToolUseBlock};
+use std::collections::HashMap;
+
+/// Unit test that verifies ToolUse and ToolResult correlation logic
+#[test]
+fn test_tool_use_result_correlation() {
+ // Simulate the pending_tools tracking
+ let mut pending_tools: HashMap<String, (String, serde_json::Value)> = HashMap::new();
+ let mut tool_results: Vec<(String, String, serde_json::Value)> = Vec::new();
+
+ // Simulate receiving a ToolUse block in an Assistant message
+ let tool_use = ToolUseBlock {
+ id: "toolu_123".to_string(),
+ name: "Read".to_string(),
+ input: serde_json::json!({"file_path": "/etc/hostname"}),
+ };
+
+ // Store the tool use (as the main code does)
+ pending_tools.insert(
+ tool_use.id.clone(),
+ (tool_use.name.clone(), tool_use.input.clone()),
+ );
+
+ assert_eq!(pending_tools.len(), 1);
+ assert!(pending_tools.contains_key("toolu_123"));
+
+ // Simulate receiving a ToolResult block in a User message
+ let tool_result = ToolResultBlock {
+ tool_use_id: "toolu_123".to_string(),
+ content: Some(ToolResultContent::Text("hostname content".to_string())),
+ is_error: Some(false),
+ };
+
+ // Correlate the result (as the main code does)
+ if let Some((tool_name, _tool_input)) = pending_tools.remove(&tool_result.tool_use_id) {
+ let response = match &tool_result.content {
+ Some(ToolResultContent::Text(s)) => serde_json::Value::String(s.clone()),
+ Some(ToolResultContent::Blocks(blocks)) => {
+ serde_json::Value::Array(blocks.iter().cloned().collect())
+ }
+ None => serde_json::Value::Null,
+ };
+ tool_results.push((tool_name, tool_result.tool_use_id.clone(), response));
+ }
+
+ // Verify correlation worked
+ assert!(
+ pending_tools.is_empty(),
+ "Tool should be removed after correlation"
+ );
+ assert_eq!(tool_results.len(), 1);
+ assert_eq!(tool_results[0].0, "Read");
+ assert_eq!(tool_results[0].1, "toolu_123");
+ assert_eq!(
+ tool_results[0].2,
+ serde_json::Value::String("hostname content".to_string())
+ );
+}
+
+/// Test that unmatched tool results don't cause issues
+#[test]
+fn test_unmatched_tool_result() {
+ let mut pending_tools: HashMap<String, (String, serde_json::Value)> = HashMap::new();
+ let mut tool_results: Vec<(String, String)> = Vec::new();
+
+ // ToolResult without a matching ToolUse
+ let tool_result = ToolResultBlock {
+ tool_use_id: "toolu_unknown".to_string(),
+ content: Some(ToolResultContent::Text("some content".to_string())),
+ is_error: None,
+ };
+
+ // Try to correlate - should not find a match
+ if let Some((tool_name, _tool_input)) = pending_tools.remove(&tool_result.tool_use_id) {
+ tool_results.push((tool_name, tool_result.tool_use_id.clone()));
+ }
+
+ // No results should be added
+ assert!(tool_results.is_empty());
+}
+
+/// Test multiple tools in sequence
+#[test]
+fn test_multiple_tools_correlation() {
+ let mut pending_tools: HashMap<String, (String, serde_json::Value)> = HashMap::new();
+ let mut tool_results: Vec<String> = Vec::new();
+
+ // Add multiple tool uses
+ pending_tools.insert(
+ "toolu_1".to_string(),
+ ("Read".to_string(), serde_json::json!({})),
+ );
+ pending_tools.insert(
+ "toolu_2".to_string(),
+ ("Bash".to_string(), serde_json::json!({})),
+ );
+ pending_tools.insert(
+ "toolu_3".to_string(),
+ ("Grep".to_string(), serde_json::json!({})),
+ );
+
+ assert_eq!(pending_tools.len(), 3);
+
+ // Process results in different order
+ for tool_use_id in ["toolu_2", "toolu_1", "toolu_3"] {
+ if let Some((tool_name, _)) = pending_tools.remove(tool_use_id) {
+ tool_results.push(tool_name);
+ }
+ }
+
+ assert!(pending_tools.is_empty());
+ assert_eq!(tool_results, vec!["Bash", "Read", "Grep"]);
+}
+
+/// Test ContentBlock pattern matching
+#[test]
+fn test_content_block_matching() {
+ let blocks: Vec<ContentBlock> = vec![
+ ContentBlock::Text(claude_agent_sdk_rs::TextBlock {
+ text: "Some text".to_string(),
+ }),
+ ContentBlock::ToolUse(ToolUseBlock {
+ id: "tool_1".to_string(),
+ name: "Read".to_string(),
+ input: serde_json::json!({"file_path": "/test"}),
+ }),
+ ContentBlock::ToolResult(ToolResultBlock {
+ tool_use_id: "tool_1".to_string(),
+ content: Some(ToolResultContent::Text("result".to_string())),
+ is_error: None,
+ }),
+ ];
+
+ let mut tool_uses = Vec::new();
+ let mut tool_results = Vec::new();
+
+ for block in &blocks {
+ match block {
+ ContentBlock::ToolUse(tu) => {
+ tool_uses.push(tu.name.clone());
+ }
+ ContentBlock::ToolResult(tr) => {
+ tool_results.push(tr.tool_use_id.clone());
+ }
+ _ => {}
+ }
+ }
+
+ assert_eq!(tool_uses, vec!["Read"]);
+ assert_eq!(tool_results, vec!["tool_1"]);
+}
diff --git a/crates/notedeck_messages/src/ui/convo.rs b/crates/notedeck_messages/src/ui/convo.rs
@@ -431,7 +431,10 @@ fn self_chat_bubble(
ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
chat_bubble(ui, msg_type, true, bubble_fill, |ui| {
ui.with_layout(Layout::top_down(Align::Max), |ui| {
- ui.label(RichText::new(message).color(ui.visuals().text_color()));
+ ui.add(
+ egui::Label::new(RichText::new(message).color(ui.visuals().text_color()))
+ .selectable(true),
+ );
if msg_type == MessageType::Standalone || msg_type == MessageType::LastInSeries {
let timestamp_label =
@@ -474,7 +477,9 @@ fn other_chat_bubble(
ui.with_layout(
Layout::left_to_right(Align::Max).with_main_wrap(true),
|ui| {
- ui.label(RichText::new(message).color(text_color));
+ ui.add(
+ egui::Label::new(RichText::new(message).color(text_color)).selectable(true),
+ );
if msg_type == MessageType::Standalone || msg_type == MessageType::LastInSeries
{
ui.add_space(6.0);