From 033025081758e6857a2da6023a9d12640241394b Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sat, 6 Jun 2026 02:50:37 -0700 Subject: [PATCH] feat(runtime-api): expose git status metadata for agent view --- CHANGELOG.md | 9 +-- crates/tui/CHANGELOG.md | 9 +-- crates/tui/src/runtime_api.rs | 94 ++++++++++++++++++++++++++++++- docs/RUNTIME_API.md | 4 ++ docs/V0_9_0_RELEASE_ACCEPTANCE.md | 2 +- 5 files changed, 107 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b38d37228..e2b1b3bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,10 +136,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 preview backed by recent runtime thread summaries, plus a read-only `GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore points. The extension now renders those restore points read-only in its Agent - View, and thread summaries include read-only workspace and branch metadata so - the VS Code Agent View can show when a thread or agent lane is on another - branch. Agent View and restore-point data now auto-refresh on a configurable - read-only interval so branch/workspace changes become visible without a + View, and thread summaries include read-only workspace, branch, current Git + head, and dirty-state metadata so the VS Code Agent View can show when a + thread or agent lane is on another branch or has changed worktree state. Agent + View and restore-point data now auto-refresh on a configurable + read-only interval so branch/workspace/status changes become visible without a manual refresh. Agent View refreshes keep thread branch/workspace rows independent from restore-point loading, so a snapshot-listing failure no longer clears already-available thread metadata. This answers the VS Code GUI diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index b38d37228..e2b1b3bf0 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -136,10 +136,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 preview backed by recent runtime thread summaries, plus a read-only `GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore points. The extension now renders those restore points read-only in its Agent - View, and thread summaries include read-only workspace and branch metadata so - the VS Code Agent View can show when a thread or agent lane is on another - branch. Agent View and restore-point data now auto-refresh on a configurable - read-only interval so branch/workspace changes become visible without a + View, and thread summaries include read-only workspace, branch, current Git + head, and dirty-state metadata so the VS Code Agent View can show when a + thread or agent lane is on another branch or has changed worktree state. Agent + View and restore-point data now auto-refresh on a configurable + read-only interval so branch/workspace/status changes become visible without a manual refresh. Agent View refreshes keep thread branch/workspace rows independent from restore-point loading, so a snapshot-listing failure no longer clears already-available thread metadata. This answers the VS Code GUI diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 6fe5ffd1c..bf782495d 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -266,6 +266,8 @@ struct ThreadSummary { mode: String, workspace: PathBuf, branch: Option, + head: Option, + dirty: bool, archived: bool, updated_at: chrono::DateTime, latest_turn_id: Option, @@ -277,6 +279,8 @@ struct WorkspaceStatusResponse { workspace: PathBuf, git_repo: bool, branch: Option, + head: Option, + dirty: bool, staged: usize, unstaged: usize, untracked: usize, @@ -284,6 +288,13 @@ struct WorkspaceStatusResponse { behind: Option, } +#[derive(Debug, Default)] +struct WorkspaceGitMetadata { + branch: Option, + head: Option, + dirty: bool, +} + #[derive(Debug, Serialize)] struct SkillEntry { name: String, @@ -1241,13 +1252,16 @@ async fn list_threads_summary( } } + let workspace_git = collect_workspace_git_metadata(&thread.workspace); summaries.push(ThreadSummary { id: thread.id, title, preview, model: thread.model, mode: thread.mode, - branch: current_git_branch(&thread.workspace), + branch: workspace_git.branch, + head: workspace_git.head, + dirty: workspace_git.dirty, workspace: thread.workspace, archived: thread.archived, updated_at: thread.updated_at, @@ -2000,6 +2014,8 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo workspace: workspace.to_path_buf(), git_repo: false, branch: None, + head: None, + dirty: false, staged: 0, unstaged: 0, untracked: 0, @@ -2015,7 +2031,10 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo } status.git_repo = true; - status.branch = current_git_branch(workspace); + let metadata = collect_workspace_git_metadata(workspace); + status.branch = metadata.branch; + status.head = metadata.head; + status.dirty = metadata.dirty; if let Some(porcelain) = run_git(workspace, &["status", "--porcelain=v1"]) { for line in porcelain.lines() { @@ -2049,6 +2068,22 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo status } +fn collect_workspace_git_metadata(workspace: &std::path::Path) -> WorkspaceGitMetadata { + let Some(repo_check) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]) else { + return WorkspaceGitMetadata::default(); + }; + if repo_check.trim() != "true" { + return WorkspaceGitMetadata::default(); + } + + WorkspaceGitMetadata { + branch: current_git_branch(workspace), + head: current_git_head(workspace), + dirty: run_git(workspace, &["status", "--porcelain=v1"]) + .is_some_and(|porcelain| !porcelain.trim().is_empty()), + } +} + fn run_git(workspace: &std::path::Path, args: &[&str]) -> Option { let output = crate::dependencies::Git::output(args, workspace).ok()?; if !output.status.success() { @@ -2075,6 +2110,12 @@ fn current_git_branch(workspace: &std::path::Path) -> Option { (!short_hash.is_empty()).then(|| format!("detached@{short_hash}")) } +fn current_git_head(workspace: &std::path::Path) -> Option { + let head = run_git(workspace, &["rev-parse", "--short", "HEAD"])?; + let head = head.trim(); + (!head.is_empty()).then(|| head.to_string()) +} + fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf { // Canonicalize the workspace once so the symlink-containment check below // compares like-for-like. If the workspace can't be canonicalized at all @@ -2459,6 +2500,43 @@ mod tests { Ok(()) } + #[test] + fn workspace_status_reports_head_and_dirty_counts() -> Result<()> { + let tmp = tempfile::tempdir()?; + let repo = tmp.path().join("repo"); + fs::create_dir_all(&repo)?; + run_test_git(&repo, &["init", "-b", "main"])?; + fs::write(repo.join("tracked.txt"), "clean\n")?; + run_test_git(&repo, &["add", "tracked.txt"])?; + run_test_git( + &repo, + &[ + "-c", + "user.name=CodeWhale Test", + "-c", + "user.email=codewhale@example.invalid", + "commit", + "-m", + "init", + ], + )?; + + let clean = collect_workspace_status(&repo); + assert!(clean.git_repo); + assert_eq!(clean.branch.as_deref(), Some("main")); + assert!(clean.head.as_deref().is_some_and(|head| !head.is_empty())); + assert!(!clean.dirty); + + fs::write(repo.join("tracked.txt"), "dirty\n")?; + fs::write(repo.join("untracked.txt"), "new\n")?; + + let dirty = collect_workspace_status(&repo); + assert!(dirty.dirty); + assert_eq!(dirty.unstaged, 1); + assert_eq!(dirty.untracked, 1); + Ok(()) + } + #[test] fn session_detail_tool_use_preserves_caller_metadata() { let detail = session_to_detail(saved_session_with_blocks(vec![ @@ -2949,6 +3027,10 @@ mod tests { .as_str() .context("missing git thread id")? .to_string(); + fs::write( + repo.join("dirty.txt"), + "worktree changed after thread spawn\n", + )?; let plain_thread: serde_json::Value = client .post(format!("http://{addr}/v1/threads")) @@ -2979,6 +3061,12 @@ mod tests { .find(|item| item["id"] == git_thread_id) .context("missing git workspace summary")?; assert_eq!(git_summary["branch"], "feature/agent"); + assert!( + git_summary["head"] + .as_str() + .is_some_and(|head| !head.is_empty()) + ); + assert_eq!(git_summary["dirty"], true); assert_eq!(git_summary["workspace"], repo.to_string_lossy().as_ref()); let plain_summary = summaries @@ -2986,6 +3074,8 @@ mod tests { .find(|item| item["id"] == plain_thread_id) .context("missing plain workspace summary")?; assert_eq!(plain_summary["branch"], serde_json::Value::Null); + assert_eq!(plain_summary["head"], serde_json::Value::Null); + assert_eq!(plain_summary["dirty"], false); assert_eq!( plain_summary["workspace"], non_git.to_string_lossy().as_ref() diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index ac4e3e1ef..454cb1894 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -191,6 +191,8 @@ workspace metadata: "model": "deepseek-v4-pro", "mode": "agent", "branch": "feature/runtime-api", + "head": "abc1234", + "dirty": false, "workspace": "/Users/you/projects/codewhale", "archived": false, "updated_at": "2026-06-06T05:43:00Z", @@ -201,6 +203,8 @@ workspace metadata: `branch` is resolved from the thread workspace at request time and may be `null` when the workspace is not a Git repository or the branch cannot be read. +`head` is the current short Git commit for that workspace when available. +`dirty` is true when the workspace has staged, unstaged, or untracked changes. `workspace` is included so editor clients can show when an agent lane is working outside the current VS Code folder. diff --git a/docs/V0_9_0_RELEASE_ACCEPTANCE.md b/docs/V0_9_0_RELEASE_ACCEPTANCE.md index c4da6efe7..dfcd95e51 100644 --- a/docs/V0_9_0_RELEASE_ACCEPTANCE.md +++ b/docs/V0_9_0_RELEASE_ACCEPTANCE.md @@ -53,7 +53,7 @@ config source, result, and follow-up issue or PR. | Transcript tool-collapse smoke or explicit defer | UX steward | ship | #2776 (`c76ec4752`) landed dense successful tool-run collapse with guardrails for failed/running/shell/patch/review/diff cells; focused widget coverage includes `chat_widget_collapses_dense_tool_runs_by_default`, `chat_widget_expands_dense_tool_runs_on_demand`, and `chat_widget_expanded_mode_leaves_dense_tool_runs_visible`. | | Sidebar detail popovers smoke or explicit defer | UX steward | ship | #2778 (`3cb49233e`) added row-level hover metadata and wrapping detail popovers for truncated Work/Tasks/Agents rows; #2806 (`19f5c7aa6`) preserved current sub-agent progress in the sidebar hover text. Focused coverage includes `sidebar_hover_rows_mark_source_text_diff_as_truncated` and `subagent_hover_text_preserves_full_agent_id_and_progress`. | | Plan review/handoff artifact smoke | Plan steward | ship | #2770 (`7ac8063b6`) added rich PlanArtifact sections through the transcript/Plan prompt path; focused coverage includes `plan_update_cell_renders_rich_artifact_metadata` and `plan_prompt_renders_rich_plan_artifact_sections`. | -| VS Code Agent View branch/workspace visibility smoke | GUI steward | ship | #2825 (`1bacaf763`) added `workspace` / `branch` metadata to `/v1/threads/summary`; #2832 (`50b773f1d`) added read-only auto-refresh so branch/workspace changes can appear without manual refresh. | +| VS Code Agent View branch/workspace visibility smoke | GUI steward | ship | #2825 (`1bacaf763`) added `workspace` / `branch` metadata to `/v1/threads/summary`; #2832 (`50b773f1d`) added read-only auto-refresh so branch/workspace changes can appear without manual refresh. The current stewardship slice extends the same read-only metadata with current Git `head` and `dirty` worktree state for editor/agent-lane visibility. | ## v0.9.0 Feature Gates