commit 6148633d984ade53bce1ab47422e5df1242009ea
parent 2180a8c2833046f7708b6dd1f4920cbf438959f6
Author: William Casarin <jb55@jb55.com>
Date: Fri, 13 Feb 2026 12:30:19 -0800
Merge branch 'dave': git status bar, auto-accept improvements, nip05 validation
- Add git status bar for session working directory
- Redesign git status bar to match UI style
- Hide git status expand button when repo is clean
- Auto-accept read-only gh CLI commands
- Add word boundary matching to auto-accept and add bd command
- Fix tool result and subagent completion detection
- Add debug logging for compact_boundary data
- Treat compaction as done state for session status
- Add async nip05 validation for verified checkmark
Diffstat:
8 files changed, 541 insertions(+), 58 deletions(-)
diff --git a/crates/notedeck_dave/src/auto_accept.rs b/crates/notedeck_dave/src/auto_accept.rs
@@ -36,9 +36,11 @@ impl AutoAcceptRule {
return false;
};
let command_trimmed = command.trim();
- prefixes
- .iter()
- .any(|prefix| command_trimmed.starts_with(prefix))
+ prefixes.iter().any(|prefix| {
+ command_trimmed.starts_with(prefix)
+ && (command_trimmed.len() == prefix.len()
+ || command_trimmed.as_bytes()[prefix.len()].is_ascii_whitespace())
+ })
}
AutoAcceptRule::ReadOnlyTool { tools } => tools.iter().any(|t| t == tool_name),
}
@@ -67,39 +69,22 @@ impl Default for AutoAcceptRules {
"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(),
+ "grep".into(),
+ "rg".into(),
+ "find".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(),
+ "cat".into(),
+ "head".into(),
+ "tail".into(),
+ "wc".into(),
+ "file".into(),
+ "stat".into(),
+ "which".into(),
+ "type".into(),
"pwd".into(),
"tree".into(),
- "tree ".into(),
- "tree\t".into(),
- "du ".into(),
- "du\t".into(),
- "df ".into(),
- "df\t".into(),
+ "du".into(),
+ "df".into(),
// Git read-only commands
"git status".into(),
"git log".into(),
@@ -110,6 +95,21 @@ impl Default for AutoAcceptRules {
"git rev-parse".into(),
"git ls-files".into(),
"git describe".into(),
+ // GitHub CLI (read-only)
+ "gh pr view".into(),
+ "gh pr list".into(),
+ "gh pr diff".into(),
+ "gh pr checks".into(),
+ "gh pr status".into(),
+ "gh issue view".into(),
+ "gh issue list".into(),
+ "gh issue status".into(),
+ "gh repo view".into(),
+ "gh search".into(),
+ "gh release list".into(),
+ "gh release view".into(),
+ // Beads issue tracker
+ "bd".into(),
],
},
AutoAcceptRule::ReadOnlyTool {
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -467,28 +467,41 @@ async fn session_actor(
stream_done = true;
}
ClaudeMessage::User(user_msg) => {
- if let Some(content_blocks) = &user_msg.content {
- for block in content_blocks {
- if let ContentBlock::ToolResult(tool_result_block) = block {
- let tool_use_id = &tool_result_block.tool_use_id;
- if let Some((tool_name, tool_input)) = pending_tools.remove(tool_use_id) {
- let result_value = tool_result_content_to_value(&tool_result_block.content);
-
- // Check if this is a Task tool completion
- if tool_name == "Task" {
- let result_text = extract_response_content(&result_value)
- .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, &result_value);
- let tool_result = ToolResult { tool_name, summary };
- let _ = response_tx.send(DaveApiResponse::ToolResult(tool_result));
- ctx.request_repaint();
+ // Tool results are nested in extra["message"]["content"]
+ // since the SDK's UserMessage.content field doesn't
+ // capture the inner message's content array.
+ let content_blocks: Vec<ContentBlock> = user_msg
+ .extra
+ .get("message")
+ .and_then(|m| m.get("content"))
+ .and_then(|c| c.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| serde_json::from_value::<ContentBlock>(v.clone()).ok())
+ .collect()
+ })
+ .unwrap_or_default();
+
+ for block in &content_blocks {
+ if let ContentBlock::ToolResult(tool_result_block) = block {
+ let tool_use_id = &tool_result_block.tool_use_id;
+ if let Some((tool_name, tool_input)) = pending_tools.remove(tool_use_id) {
+ let result_value = tool_result_content_to_value(&tool_result_block.content);
+
+ // Check if this is a Task tool completion
+ if tool_name == "Task" {
+ let result_text = extract_response_content(&result_value)
+ .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, &result_value);
+ let tool_result = ToolResult { tool_name, summary };
+ let _ = response_tx.send(DaveApiResponse::ToolResult(tool_result));
+ ctx.request_repaint();
}
}
}
@@ -510,6 +523,7 @@ async fn session_actor(
// status: null means compaction finished (handled by compact_boundary)
} else if system_msg.subtype == "compact_boundary" {
// Compaction completed - extract token savings info
+ tracing::debug!("compact_boundary data: {:?}", system_msg.data);
let pre_tokens = system_msg.data.get("pre_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
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,
}
}
@@ -277,7 +283,9 @@ impl ChatSession {
// Check if the last meaningful message was from assistant
for msg in self.chat.iter().rev() {
match msg {
- Message::Assistant(_) => return AgentStatus::Done,
+ Message::Assistant(_) | Message::CompactionComplete(_) => {
+ return AgentStatus::Done
+ }
Message::User(_) => return AgentStatus::Idle, // Waiting for response
Message::Error(_) => return AgentStatus::Error,
_ => continue,
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 { 24.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: 4,
+ bottom: 0,
+ })
+ .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,167 @@
+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);
+
+/// Snapshot of git status data extracted from the cache to avoid
+/// borrow conflicts when mutating `cache.expanded`.
+struct StatusSnapshot {
+ branch: Option<String>,
+ modified: usize,
+ added: usize,
+ deleted: usize,
+ untracked: usize,
+ is_clean: bool,
+ files: Vec<(String, String)>, // (status, path)
+}
+
+impl StatusSnapshot {
+ fn from_cache(cache: &GitStatusCache) -> Option<Result<Self, ()>> {
+ match cache.current() {
+ Some(Ok(data)) => Some(Ok(StatusSnapshot {
+ branch: data.branch.clone(),
+ modified: data.modified_count(),
+ added: data.added_count(),
+ deleted: data.deleted_count(),
+ untracked: data.untracked_count(),
+ is_clean: data.is_clean(),
+ files: data
+ .files
+ .iter()
+ .map(|f| (f.status.clone(), f.path.clone()))
+ .collect(),
+ })),
+ Some(Err(_)) => Some(Err(())),
+ None => None,
+ }
+ }
+}
+
+/// Render the git status bar.
+pub fn git_status_bar_ui(cache: &mut GitStatusCache, ui: &mut Ui) {
+ // Snapshot data so we can freely mutate cache.expanded below
+ let snapshot = StatusSnapshot::from_cache(cache);
+
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ ui.spacing_mut().item_spacing.x = 6.0;
+
+ match &snapshot {
+ Some(Ok(snap)) => {
+ // Show expand arrow only when dirty
+ if !snap.is_clean {
+ let arrow = if cache.expanded {
+ "\u{25BC}"
+ } else {
+ "\u{25B6}"
+ };
+ if ui
+ .add(
+ egui::Label::new(RichText::new(arrow).weak().monospace().size(9.0))
+ .sense(egui::Sense::click()),
+ )
+ .clicked()
+ {
+ cache.expanded = !cache.expanded;
+ }
+ }
+
+ // Branch name
+ let branch_text = snap.branch.as_deref().unwrap_or("detached");
+ ui.label(RichText::new(branch_text).weak().monospace().size(11.0));
+
+ if snap.is_clean {
+ ui.label(RichText::new("clean").weak().size(11.0));
+ } else {
+ count_label(ui, "~", snap.modified, MODIFIED_COLOR);
+ count_label(ui, "+", snap.added, ADDED_COLOR);
+ count_label(ui, "-", snap.deleted, DELETED_COLOR);
+ count_label(ui, "?", snap.untracked, UNTRACKED_COLOR);
+ }
+ }
+ 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
+ .add(
+ egui::Label::new(RichText::new("\u{21BB}").weak().size(12.0))
+ .sense(egui::Sense::click()),
+ )
+ .on_hover_text("Refresh git status")
+ .clicked()
+ {
+ cache.request_refresh();
+ }
+ });
+ });
+
+ // Expanded file list
+ if cache.expanded {
+ if let Some(Ok(snap)) = &snapshot {
+ if !snap.files.is_empty() {
+ ui.add_space(4.0);
+
+ egui::Frame::new()
+ .fill(ui.visuals().extreme_bg_color)
+ .inner_margin(egui::Margin::symmetric(8, 4))
+ .corner_radius(4.0)
+ .show(ui, |ui| {
+ egui::ScrollArea::vertical()
+ .max_height(150.0)
+ .show(ui, |ui| {
+ for (status, path) in &snap.files {
+ ui.horizontal(|ui| {
+ ui.spacing_mut().item_spacing.x = 8.0;
+ let color = status_color(status);
+ ui.label(
+ RichText::new(status)
+ .monospace()
+ .size(11.0)
+ .color(color),
+ );
+ ui.label(
+ RichText::new(path).monospace().size(11.0).weak(),
+ );
+ });
+ }
+ });
+ });
+ }
+ }
+ }
+ });
+}
+
+fn count_label(ui: &mut Ui, prefix: &str, count: usize, color: Color32) {
+ if count > 0 {
+ ui.label(
+ RichText::new(format!("{}{}", prefix, count))
+ .color(color)
+ .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)