notedeck

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

commit 8d9793cc4032395905e68754ac26ff3898fcd313
parent 4c5c4c7b68024246eac1f97d9ac084a765967aa6
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 12 Feb 2026 15:22:37 -0800

dave: add git status bar for session working directory

Shows branch name and file change counts (modified, added, deleted,
untracked) between the input box and chat area. Expands to show
individual file entries. Auto-refreshes every 5s and invalidates
after Bash/Write/Edit tool results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Acrates/notedeck_dave/src/git_status.rs | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 16++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 6++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 31+++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/git_status_ui.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 10+++++++---
6 files changed, 456 insertions(+), 3 deletions(-)

diff --git a/crates/notedeck_dave/src/git_status.rs b/crates/notedeck_dave/src/git_status.rs @@ -0,0 +1,243 @@ +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Instant; + +/// A single file entry from git status --short +#[derive(Debug, Clone)] +pub struct GitFileEntry { + /// Two-character status code (e.g., "M ", " M", "??", "A ") + pub status: String, + /// File path relative to repo root + pub path: String, +} + +/// Parsed result of git status --short --branch +#[derive(Debug, Clone)] +pub struct GitStatusData { + /// Current branch name (None if detached HEAD) + pub branch: Option<String>, + /// List of file entries + pub files: Vec<GitFileEntry>, + /// When this data was fetched + pub fetched_at: Instant, +} + +impl GitStatusData { + pub fn modified_count(&self) -> usize { + self.files + .iter() + .filter(|f| { + let b = f.status.as_bytes(); + (b[0] == b'M' || b[1] == b'M') && b[0] != b'?' && b[0] != b'A' && b[0] != b'D' + }) + .count() + } + + pub fn added_count(&self) -> usize { + self.files + .iter() + .filter(|f| f.status.starts_with('A')) + .count() + } + + pub fn deleted_count(&self) -> usize { + self.files + .iter() + .filter(|f| { + let b = f.status.as_bytes(); + b[0] == b'D' || b[1] == b'D' + }) + .count() + } + + pub fn untracked_count(&self) -> usize { + self.files + .iter() + .filter(|f| f.status.starts_with('?')) + .count() + } + + pub fn is_clean(&self) -> bool { + self.files.is_empty() + } +} + +#[derive(Debug, Clone)] +pub enum GitStatusError { + NotARepo, + CommandFailed(String), +} + +pub type GitStatusResult = Result<GitStatusData, GitStatusError>; + +/// Manages periodic git status checks for a session +pub struct GitStatusCache { + cwd: PathBuf, + current: Option<GitStatusResult>, + receiver: Option<mpsc::Receiver<GitStatusResult>>, + last_fetch: Option<Instant>, + /// Whether a fetch is currently in-flight + fetching: bool, + /// Whether the expanded file list is shown + pub expanded: bool, +} + +const REFRESH_INTERVAL_SECS: f64 = 5.0; + +impl GitStatusCache { + pub fn new(cwd: PathBuf) -> Self { + Self { + cwd, + current: None, + receiver: None, + last_fetch: None, + fetching: false, + expanded: false, + } + } + + /// Request a fresh git status (non-blocking, spawns thread) + pub fn request_refresh(&mut self) { + if self.fetching { + return; + } + let (tx, rx) = mpsc::channel(); + let cwd = self.cwd.clone(); + std::thread::spawn(move || { + let result = run_git_status(&cwd); + let _ = tx.send(result); + }); + self.receiver = Some(rx); + self.fetching = true; + self.last_fetch = Some(Instant::now()); + } + + /// Poll for results (call each frame) + pub fn poll(&mut self) { + if let Some(rx) = &self.receiver { + match rx.try_recv() { + Ok(result) => { + self.current = Some(result); + self.fetching = false; + self.receiver = None; + } + Err(mpsc::TryRecvError::Disconnected) => { + self.fetching = false; + self.receiver = None; + } + Err(mpsc::TryRecvError::Empty) => {} + } + } + } + + /// Check if auto-refresh is due and trigger if so + pub fn maybe_auto_refresh(&mut self) { + let should_refresh = match self.last_fetch { + None => true, + Some(t) => t.elapsed().as_secs_f64() >= REFRESH_INTERVAL_SECS, + }; + if should_refresh { + self.request_refresh(); + } + } + + pub fn current(&self) -> Option<&GitStatusResult> { + self.current.as_ref() + } + + /// Mark cache as stale so next poll triggers a refresh + pub fn invalidate(&mut self) { + self.last_fetch = None; + } +} + +fn parse_git_status(output: &str) -> GitStatusData { + let mut branch = None; + let mut files = Vec::new(); + + for line in output.lines() { + if let Some(rest) = line.strip_prefix("## ") { + // Branch line: "## main...origin/main" or "## HEAD (no branch)" + let branch_name = rest + .split("...") + .next() + .unwrap_or(rest) + .split(' ') + .next() + .unwrap_or(rest); + if branch_name != "HEAD" { + branch = Some(branch_name.to_string()); + } + } else if line.len() >= 3 { + // File entry: "XY path" where XY is 2-char status + let status = line[..2].to_string(); + let path = line[3..].to_string(); + files.push(GitFileEntry { status, path }); + } + } + + GitStatusData { + branch, + files, + fetched_at: Instant::now(), + } +} + +fn run_git_status(cwd: &Path) -> GitStatusResult { + let output = std::process::Command::new("git") + .args(["status", "--short", "--branch"]) + .current_dir(cwd) + .output() + .map_err(|e| GitStatusError::CommandFailed(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("not a git repository") { + return Err(GitStatusError::NotARepo); + } + return Err(GitStatusError::CommandFailed(stderr.into_owned())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_git_status(&stdout)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_clean_repo() { + let output = "## main...origin/main\n"; + let data = parse_git_status(output); + assert_eq!(data.branch.as_deref(), Some("main")); + assert!(data.is_clean()); + } + + #[test] + fn test_parse_dirty_repo() { + let output = "## dave...origin/dave\n M src/ui/dave.rs\n M src/session.rs\nA src/git_status.rs\n?? src/ui/git_status_ui.rs\n"; + let data = parse_git_status(output); + assert_eq!(data.branch.as_deref(), Some("dave")); + assert_eq!(data.files.len(), 4); + assert_eq!(data.modified_count(), 2); + assert_eq!(data.added_count(), 1); + assert_eq!(data.untracked_count(), 1); + assert_eq!(data.deleted_count(), 0); + } + + #[test] + fn test_parse_detached_head() { + let output = "## HEAD (no branch)\n M file.rs\n"; + let data = parse_git_status(output); + assert!(data.branch.is_none()); + assert_eq!(data.files.len(), 1); + } + + #[test] + fn test_parse_deleted_file() { + let output = "## main\n D deleted.rs\n"; + let data = parse_git_status(output); + assert_eq!(data.deleted_count(), 1); + } +} diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -5,6 +5,7 @@ mod backend; mod config; pub mod file_update; mod focus_queue; +pub(crate) mod git_status; pub mod ipc; pub(crate) mod mesh; mod messages; @@ -330,6 +331,13 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr DaveApiResponse::ToolResult(result) => { tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary); + // Invalidate git status after file-modifying tools. + // tool_name is a String from the Claude SDK, no enum available. + if matches!(result.tool_name.as_str(), "Bash" | "Write" | "Edit") { + if let Some(agentic) = &mut session.agentic { + agentic.git_status.invalidate(); + } + } session.chat.push(Message::ToolResult(result)); } @@ -889,6 +897,14 @@ impl notedeck::App for Dave { // Process incoming AI responses for all sessions let sessions_needing_send = self.process_events(ctx); + // Poll git status for all agentic sessions + for session in self.session_manager.iter_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.git_status.poll(); + agentic.git_status.maybe_auto_refresh(); + } + } + // Update all session statuses after processing events self.session_manager.update_all_statuses(); diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -4,6 +4,7 @@ use std::sync::mpsc::Receiver; use crate::agent_status::AgentStatus; use crate::config::AiMode; +use crate::git_status::GitStatusCache; use crate::messages::{ CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentStatus, }; @@ -52,6 +53,8 @@ pub struct AgenticSessionData { /// 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>, + /// Git status cache for this session's working directory + pub git_status: GitStatusCache, } impl AgenticSessionData { @@ -62,6 +65,8 @@ impl AgenticSessionData { let x = col as f32 * 150.0 - 225.0; // Center around origin let y = row as f32 * 150.0 - 75.0; + let git_status = GitStatusCache::new(cwd.clone()); + AgenticSessionData { pending_permissions: HashMap::new(), scene_position: egui::Vec2::new(x, y), @@ -75,6 +80,7 @@ impl AgenticSessionData { is_compacting: false, last_compaction: None, resume_session_id: None, + git_status, } } diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -1,10 +1,12 @@ use super::badge::{BadgeVariant, StatusBadge}; use super::diff; +use super::git_status_ui; use super::query_ui::query_call_ui; use super::top_buttons::top_buttons_ui; use crate::{ config::{AiMode, DaveSettings}, file_update::FileUpdate, + git_status::GitStatusCache, messages::{ AskUserQuestionInput, CompactionInfo, Message, PermissionRequest, PermissionResponse, PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus, ToolResult, @@ -42,6 +44,8 @@ pub struct DaveUi<'a> { auto_steal_focus: bool, /// AI interaction mode (Chat vs Agentic) ai_mode: AiMode, + /// Git status cache for current session (agentic only) + git_status: Option<&'a mut GitStatusCache>, } /// The response the app generates. The response contains an optional @@ -141,6 +145,7 @@ impl<'a> DaveUi<'a> { is_compacting: false, auto_steal_focus: false, ai_mode, + git_status: None, } } @@ -189,6 +194,13 @@ impl<'a> DaveUi<'a> { self } + /// Set the git status cache. Mutable because the UI toggles + /// expand/collapse and triggers refresh on button click. + pub fn git_status(mut self, cache: &'a mut GitStatusCache) -> Self { + self.git_status = Some(cache); + self + } + pub fn auto_steal_focus(mut self, auto_steal_focus: bool) -> Self { self.auto_steal_focus = auto_steal_focus; self @@ -240,6 +252,25 @@ impl<'a> DaveUi<'a> { .show(ui, |ui| self.inputbox(app_ctx.i18n, ui)) .inner; + if let Some(git_status) = &mut self.git_status { + // Explicitly reserve height so bottom_up layout + // keeps the chat ScrollArea from overlapping. + let h = if git_status.expanded { 200.0 } else { 30.0 }; + let w = ui.available_width(); + ui.allocate_ui(egui::vec2(w, h), |ui| { + egui::Frame::new() + .outer_margin(egui::Margin { + left: margin, + right: margin, + top: 0, + bottom: 4, + }) + .show(ui, |ui| { + git_status_ui::git_status_bar_ui(git_status, ui); + }); + }); + } + let chat_response = egui::ScrollArea::vertical() .id_salt("dave_chat_scroll") .stick_to_bottom(true) diff --git a/crates/notedeck_dave/src/ui/git_status_ui.rs b/crates/notedeck_dave/src/ui/git_status_ui.rs @@ -0,0 +1,153 @@ +use crate::git_status::GitStatusCache; +use egui::{Color32, RichText, Ui}; + +const MODIFIED_COLOR: Color32 = Color32::from_rgb(200, 170, 50); +const ADDED_COLOR: Color32 = Color32::from_rgb(60, 180, 60); +const DELETED_COLOR: Color32 = Color32::from_rgb(200, 60, 60); +const UNTRACKED_COLOR: Color32 = Color32::from_rgb(128, 128, 128); + +/// Render the git status bar. Call `cache.request_refresh()` externally if needed. +pub fn git_status_bar_ui(cache: &mut GitStatusCache, ui: &mut Ui) { + egui::Frame::new() + .fill(ui.visuals().faint_bg_color) + .inner_margin(egui::Margin::symmetric(8, 4)) + .corner_radius(6.0) + .show(ui, |ui| { + // vertical() forces top-down ordering inside this frame, + // preventing the parent's bottom_up layout from pushing the + // expanded file list above the header into the chat area. + ui.vertical(|ui| { + ui.horizontal(|ui| { + // Expand/collapse toggle + let arrow = if cache.expanded { "\u{25BC}" } else { "\u{25B6}" }; + if ui.small_button(arrow).clicked() { + cache.expanded = !cache.expanded; + } + + match cache.current() { + Some(Ok(data)) => { + // Branch name + if let Some(branch) = &data.branch { + ui.label( + RichText::new(format!("git: {}", branch)) + .monospace() + .size(11.0), + ); + } else { + ui.label( + RichText::new("git: detached").monospace().size(11.0), + ); + } + + if data.is_clean() { + ui.label(RichText::new("clean").weak().size(11.0)); + } else { + let m = data.modified_count(); + let a = data.added_count(); + let d = data.deleted_count(); + let u = data.untracked_count(); + if m > 0 { + ui.label( + RichText::new(format!("~{}", m)) + .color(MODIFIED_COLOR) + .monospace() + .size(11.0), + ); + } + if a > 0 { + ui.label( + RichText::new(format!("+{}", a)) + .color(ADDED_COLOR) + .monospace() + .size(11.0), + ); + } + if d > 0 { + ui.label( + RichText::new(format!("-{}", d)) + .color(DELETED_COLOR) + .monospace() + .size(11.0), + ); + } + if u > 0 { + ui.label( + RichText::new(format!("?{}", u)) + .color(UNTRACKED_COLOR) + .monospace() + .size(11.0), + ); + } + } + } + Some(Err(_)) => { + ui.label( + RichText::new("git: not available").weak().size(11.0), + ); + } + None => { + ui.spinner(); + ui.label( + RichText::new("checking git...").weak().size(11.0), + ); + } + } + + // Refresh button (right-aligned) + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .small_button("\u{21BB}") + .on_hover_text("Refresh git status") + .clicked() + { + cache.request_refresh(); + } + }, + ); + }); + + // Expanded file list + if cache.expanded { + if let Some(Ok(data)) = cache.current() { + if !data.files.is_empty() { + ui.add_space(4.0); + egui::ScrollArea::vertical() + .max_height(150.0) + .show(ui, |ui| { + for entry in &data.files { + ui.horizontal(|ui| { + let color = status_color(&entry.status); + ui.label( + RichText::new(&entry.status) + .monospace() + .size(11.0) + .color(color), + ); + ui.label( + RichText::new(&entry.path) + .monospace() + .size(11.0), + ); + }); + } + }); + } + } + } + }); + }); +} + +fn status_color(status: &str) -> Color32 { + if status.starts_with('?') { + UNTRACKED_COLOR + } else if status.contains('D') { + DELETED_COLOR + } else if status.contains('A') { + ADDED_COLOR + } else { + MODIFIED_COLOR + } +} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -3,6 +3,7 @@ pub mod badge; mod dave; pub mod diff; pub mod directory_picker; +mod git_status_ui; pub mod keybind_hint; pub mod keybindings; pub mod path_utils; @@ -230,7 +231,8 @@ pub fn scene_ui( .permission_message_state(agentic.permission_message_state) .question_answers(&mut agentic.question_answers) .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting); + .is_compacting(agentic.is_compacting) + .git_status(&mut agentic.git_status); } let response = ui_builder.ui(app_ctx, ui); @@ -352,7 +354,8 @@ pub fn desktop_ui( .permission_message_state(agentic.permission_message_state) .question_answers(&mut agentic.question_answers) .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting); + .is_compacting(agentic.is_compacting) + .git_status(&mut agentic.git_status); } ui_builder.ui(app_ctx, ui) @@ -411,7 +414,8 @@ pub fn narrow_ui( .permission_message_state(agentic.permission_message_state) .question_answers(&mut agentic.question_answers) .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting); + .is_compacting(agentic.is_compacting) + .git_status(&mut agentic.git_status); } (ui_builder.ui(app_ctx, ui), None)