commit 84c67067311bf0c5aa673d2797602fc9cb5a3459
parent f3f9cefb00ed60259e36162069262ca470a97e26
Author: William Casarin <jb55@jb55.com>
Date: Wed, 28 Jan 2026 21:18:27 -0800
refactor(dave): extract inline code into separate modules
Extract large inline functions from claude.rs and dave.rs into focused modules:
- backend/tool_summary.rs: tool result formatting functions
- backend/session_info.rs: session info parsing
- ui/pill.rs: pill-style label components
- ui/query_ui.rs: query call display
- ui/top_buttons.rs: top button bar (pfp, settings)
Reduces claude.rs by ~180 lines and dave.rs by ~185 lines.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
10 files changed, 435 insertions(+), 377 deletions(-)
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -1,8 +1,12 @@
+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::file_update::FileUpdate;
use crate::messages::{
CompactionInfo, DaveApiResponse, PendingPermission, PermissionRequest, PermissionResponse,
- SessionInfo, SubagentInfo, SubagentStatus, ToolResult,
+ SubagentInfo, SubagentStatus, ToolResult,
};
use crate::tools::Tool;
use crate::Message;
@@ -653,185 +657,3 @@ impl AiBackend for ClaudeBackend {
}
}
}
-
-/// Extract string content from a tool response, handling various JSON structures
-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
-fn format_tool_summary(
- tool_name: &str,
- input: &serde_json::Value,
- response: &serde_json::Value,
-) -> String {
- match tool_name {
- "Read" => {
- 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)
- }
- "Write" => {
- 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)
- }
- "Bash" => {
- 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)
- }
- }
- "Grep" => {
- let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?");
- format!("'{}'", pattern)
- }
- "Glob" => {
- let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?");
- format!("'{}'", pattern)
- }
- "Edit" => {
- 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()
- }
- "Task" => {
- 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)
- }
- _ => String::new(),
- }
-}
-
-/// Parse a System message into SessionInfo
-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(),
- }
-}
-
-/// Truncate output to a maximum size, keeping the end (most recent) content
-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/mod.rs b/crates/notedeck_dave/src/backend/mod.rs
@@ -1,5 +1,7 @@
mod claude;
mod openai;
+mod session_info;
+mod tool_summary;
mod traits;
pub use claude::ClaudeBackend;
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/session.rs b/crates/notedeck_dave/src/session.rs
@@ -4,7 +4,7 @@ use std::sync::mpsc::Receiver;
use crate::agent_status::AgentStatus;
use crate::messages::{
- CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentInfo, SubagentStatus,
+ CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentStatus,
};
use crate::{DaveApiResponse, Message};
use claude_agent_sdk_rs::PermissionMode;
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -1,5 +1,7 @@
use super::badge::{BadgeVariant, StatusBadge};
use super::diff;
+use super::query_ui::query_call_ui;
+use super::top_buttons::top_buttons_ui;
use crate::{
config::DaveSettings,
file_update::FileUpdate,
@@ -8,14 +10,12 @@ use crate::{
PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus, ToolResult,
},
session::PermissionMessageState,
- tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse},
+ 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::{app_images, 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;
@@ -675,7 +675,11 @@ impl<'a> DaveUi<'a> {
});
}
- fn search_call_ui(ctx: &mut AppContext, query_call: &QueryCall, ui: &mut egui::Ui) {
+ 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);
@@ -926,189 +930,3 @@ impl<'a> DaveUi<'a> {
});
}
}
-
-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 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);
- });
-}
-
-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);
- }
-
- // 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 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/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -4,9 +4,12 @@ mod dave;
pub mod diff;
pub mod keybind_hint;
pub mod keybindings;
+mod pill;
+mod query_ui;
pub mod scene;
pub mod session_list;
mod settings;
+mod top_buttons;
pub use ask_question::{ask_user_question_summary_ui, ask_user_question_ui};
pub use dave::{DaveAction, DaveResponse, DaveUi};
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/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())
+}