notedeck

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

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:
Mcrates/notedeck_dave/src/auto_accept.rs | 66+++++++++++++++++++++++++++++++++---------------------------------
Mcrates/notedeck_dave/src/backend/claude.rs | 56+++++++++++++++++++++++++++++++++++---------------------
Acrates/notedeck_dave/src/git_status.rs | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 16++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 10+++++++++-
Mcrates/notedeck_dave/src/ui/dave.rs | 31+++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/ui/git_status_ui.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 10+++++++---
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)