notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
A.beads/PRIME.md | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.lock | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MCargo.toml | 1+
Mcrates/notedeck_chrome/Cargo.toml | 2+-
Mcrates/notedeck_chrome/src/chrome.rs | 6+++++-
Mcrates/notedeck_columns/Cargo.toml | 2+-
Mcrates/notedeck_dave/Cargo.toml | 17++++++++++++++++-
Mcrates/notedeck_dave/README.md | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Acrates/notedeck_dave/src/agent_status.rs | 39+++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/auto_accept.rs | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/backend/claude.rs | 691+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/backend/mod.rs | 9+++++++++
Acrates/notedeck_dave/src/backend/openai.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/backend/session_info.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/backend/tool_summary.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/backend/traits.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/bin/notedeck-spawn.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/config.rs | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/notedeck_dave/src/file_update.rs | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/focus_queue.rs | 539+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ipc.rs | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 1020++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/notedeck_dave/src/messages.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Acrates/notedeck_dave/src/session_discovery.rs | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/tools.rs | 25+++++++++++++++++++++++--
Acrates/notedeck_dave/src/ui/ask_question.rs | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/badge.rs | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 952+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Acrates/notedeck_dave/src/ui/diff.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/directory_picker.rs | 345+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/keybind_hint.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/keybindings.rs | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 719+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/path_utils.rs | 11+++++++++++
Acrates/notedeck_dave/src/ui/pill.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/query_ui.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/scene.rs | 431+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/session_list.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Acrates/notedeck_dave/src/ui/session_picker.rs | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/settings.rs | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/top_buttons.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/update.rs | 872+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/tests/claude_integration.rs | 556+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/tests/tool_result_integration.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_messages/src/ui/convo.rs | 9+++++++--
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(&current.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(&current.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);