Skip to content

Commit 22bf912

Browse files
committed
Add workspace git stats to the TUI status line
Surface tracked workspace additions and deletions as a compact workspace-changes statusline item. The lookup uses the existing app-server workspace command runner and async AppEvent refresh path so remote workspaces and the TUI footer stay non-blocking. The default status line now includes workspace-changes, but clean or unavailable repositories omit the segment so the footer stays compact. Tests: just fmt; cargo test -p codex-tui branch_summary; cargo test -p codex-tui status_line_workspace_changes; cargo test -p codex-tui status_line_setup; just fix -p codex-tui
1 parent 18439b7 commit 22bf912

10 files changed

Lines changed: 261 additions & 13 deletions

File tree

codex-rs/tui/src/app/event_dispatch.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1937,6 +1937,11 @@ impl App {
19371937
self.chat_widget.set_status_line_git_summary(cwd, summary);
19381938
self.refresh_status_line();
19391939
}
1940+
AppEvent::StatusLineWorkspaceChangesUpdated { cwd, stats } => {
1941+
self.chat_widget
1942+
.set_status_line_workspace_changes(cwd, stats);
1943+
self.refresh_status_line();
1944+
}
19401945
AppEvent::StatusLineSetupCancelled => {
19411946
self.chat_widget.cancel_status_line_setup();
19421947
}

codex-rs/tui/src/app_event.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,11 @@ pub(crate) enum AppEvent {
881881
cwd: PathBuf,
882882
summary: crate::chatwidget::StatusLineGitSummary,
883883
},
884+
/// Async update of tracked workspace diff stats for status line rendering.
885+
StatusLineWorkspaceChangesUpdated {
886+
cwd: PathBuf,
887+
stats: Option<crate::chatwidget::GitWorkspaceDiffStats>,
888+
},
884889
/// Apply a user-confirmed status-line item ordering/selection.
885890
StatusLineSetup {
886891
items: Vec<StatusLineItem>,

codex-rs/tui/src/bottom_pane/status_line_setup.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
//!
1212
//! - Model information (name, reasoning level)
1313
//! - Directory paths (current dir, project root)
14-
//! - Git information (branch name)
14+
//! - Git information (branch name, workspace changes)
1515
//! - Context usage (remaining %, used %, window size)
1616
//! - Usage limits (5-hour, weekly)
1717
//! - Session info (thread title, ID, tokens used)
@@ -77,6 +77,14 @@ pub(crate) enum StatusLineItem {
7777
/// Committed branch diff stats relative to the default branch.
7878
BranchChanges,
7979

80+
/// Current tracked workspace diff stats relative to HEAD.
81+
#[strum(
82+
to_string = "workspace-changes",
83+
serialize = "git-status",
84+
serialize = "workspace-diff"
85+
)]
86+
WorkspaceChanges,
87+
8088
/// Compact runtime run-state text.
8189
#[strum(to_string = "run-state", serialize = "status")]
8290
Status,
@@ -148,6 +156,9 @@ impl StatusLineItem {
148156
StatusLineItem::BranchChanges => {
149157
"Committed branch changes against the default branch (omitted when unavailable)"
150158
}
159+
StatusLineItem::WorkspaceChanges => {
160+
"Tracked workspace changes against HEAD (omitted when clean or unavailable)"
161+
}
151162
StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)",
152163
StatusLineItem::ContextRemaining => {
153164
"Percentage of context window remaining (omitted when unknown)"
@@ -191,6 +202,7 @@ impl StatusLineItem {
191202
StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch,
192203
StatusLineItem::PullRequestNumber => StatusSurfacePreviewItem::PullRequestNumber,
193204
StatusLineItem::BranchChanges => StatusSurfacePreviewItem::BranchChanges,
205+
StatusLineItem::WorkspaceChanges => StatusSurfacePreviewItem::WorkspaceChanges,
194206
StatusLineItem::Status => StatusSurfacePreviewItem::Status,
195207
StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining,
196208
StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed,
@@ -448,6 +460,18 @@ mod tests {
448460
"branch-changes".parse::<StatusLineItem>(),
449461
Ok(StatusLineItem::BranchChanges)
450462
);
463+
assert_eq!(
464+
"workspace-changes".parse::<StatusLineItem>(),
465+
Ok(StatusLineItem::WorkspaceChanges)
466+
);
467+
assert_eq!(
468+
"git-status".parse::<StatusLineItem>(),
469+
Ok(StatusLineItem::WorkspaceChanges)
470+
);
471+
assert_eq!(
472+
StatusLineItem::WorkspaceChanges.to_string(),
473+
"workspace-changes"
474+
);
451475
}
452476

453477
#[test]

codex-rs/tui/src/bottom_pane/status_line_style.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ impl StatusLineAccent {
3434
StatusLineItem::CurrentDir | StatusLineItem::ProjectRoot => Self::Path,
3535
StatusLineItem::GitBranch
3636
| StatusLineItem::PullRequestNumber
37-
| StatusLineItem::BranchChanges => Self::Branch,
37+
| StatusLineItem::BranchChanges
38+
| StatusLineItem::WorkspaceChanges => Self::Branch,
3839
StatusLineItem::Status => Self::State,
3940
StatusLineItem::ContextRemaining
4041
| StatusLineItem::ContextUsed

codex-rs/tui/src/bottom_pane/status_surface_preview.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub(crate) enum StatusSurfacePreviewItem {
1616
GitBranch,
1717
PullRequestNumber,
1818
BranchChanges,
19+
WorkspaceChanges,
1920
ContextRemaining,
2021
ContextUsed,
2122
FiveHourLimit,
@@ -47,6 +48,7 @@ impl StatusSurfacePreviewItem {
4748
StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature",
4849
StatusSurfacePreviewItem::PullRequestNumber => "PR #123",
4950
StatusSurfacePreviewItem::BranchChanges => "+12 -3",
51+
StatusSurfacePreviewItem::WorkspaceChanges => "+830/-281",
5052
StatusSurfacePreviewItem::ContextRemaining => "Context 0% left",
5153
StatusSurfacePreviewItem::ContextUsed => "Context 0% used",
5254
StatusSurfacePreviewItem::FiveHourLimit => "5h 0%",
@@ -78,6 +80,7 @@ impl StatusSurfacePreviewItem {
7880
Self::GitBranch,
7981
Self::PullRequestNumber,
8082
Self::BranchChanges,
83+
Self::WorkspaceChanges,
8184
Self::ContextRemaining,
8285
Self::ContextUsed,
8386
Self::FiveHourLimit,

codex-rs/tui/src/branch_summary.rs

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ pub(crate) struct GitBranchDiffStats {
3131
pub(crate) deletions: u64,
3232
}
3333

34+
/// Additions and deletions in the current tracked workspace relative to `HEAD`.
35+
#[derive(Clone, Debug, Eq, PartialEq)]
36+
pub(crate) struct GitWorkspaceDiffStats {
37+
/// Total added lines in the current workspace.
38+
pub(crate) additions: u64,
39+
/// Total deleted lines in the current workspace.
40+
pub(crate) deletions: u64,
41+
}
42+
3443
/// Combined git metadata cached by the status line for one working directory.
3544
///
3645
/// A summary may contain only one of the fields when the other probe fails. Renderers should treat
@@ -130,6 +139,36 @@ pub(crate) async fn status_line_git_summary(
130139
}
131140
}
132141

142+
/// Counts tracked workspace line changes relative to `HEAD`.
143+
///
144+
/// Untracked files are intentionally ignored for this compact status-line probe. Counting their
145+
/// lines requires full-file reads or expensive `--no-index` diffs, which is too heavy for a
146+
/// background UI refresh.
147+
pub(crate) async fn workspace_diff_stats(
148+
runner: &dyn WorkspaceCommandExecutor,
149+
cwd: &Path,
150+
) -> Option<GitWorkspaceDiffStats> {
151+
let git_dir = run_git_command(runner, cwd, &["rev-parse", "--git-dir"])
152+
.await
153+
.ok()?;
154+
if !git_dir.success() {
155+
return None;
156+
}
157+
158+
let numstat = run_git_command(runner, cwd, &["diff", "--numstat", "HEAD", "--"])
159+
.await
160+
.ok()?;
161+
if !numstat.success() {
162+
return None;
163+
}
164+
165+
let (additions, deletions) = parse_numstat_totals(&numstat.stdout);
166+
Some(GitWorkspaceDiffStats {
167+
additions,
168+
deletions,
169+
})
170+
}
171+
133172
/// Counts committed line changes between `HEAD` and the repository default branch.
134173
///
135174
/// The comparison base is the merge base with a verified default-branch ref. Uncommitted working
@@ -170,23 +209,26 @@ async fn branch_diff_stats_to_default_branch(
170209
return None;
171210
}
172211

173-
let mut additions = 0_u64;
174-
let mut deletions = 0_u64;
175-
for line in numstat.stdout.lines() {
212+
let (additions, deletions) = parse_numstat_totals(&numstat.stdout);
213+
214+
Some(GitBranchDiffStats {
215+
additions,
216+
deletions,
217+
})
218+
}
219+
220+
fn parse_numstat_totals(stdout: &str) -> (u64, u64) {
221+
stdout.lines().fold((0_u64, 0_u64), |mut totals, line| {
176222
let mut columns = line.split('\t');
177-
additions += columns
223+
totals.0 += columns
178224
.next()
179225
.and_then(|value| value.parse().ok())
180226
.unwrap_or(0);
181-
deletions += columns
227+
totals.1 += columns
182228
.next()
183229
.and_then(|value| value.parse().ok())
184230
.unwrap_or(0);
185-
}
186-
187-
Some(GitBranchDiffStats {
188-
additions,
189-
deletions,
231+
totals
190232
})
191233
}
192234

@@ -568,6 +610,49 @@ mod tests {
568610
assert!(runner.saw(&["git", "merge-base", "HEAD", "refs/remotes/origin/main"]));
569611
}
570612

613+
#[tokio::test]
614+
async fn workspace_diff_stats_counts_tracked_changes_against_head() {
615+
let runner = FakeRunner::new(vec![
616+
response(
617+
&["git", "rev-parse", "--git-dir"],
618+
/*exit_code*/ 0,
619+
".git\n",
620+
),
621+
response(
622+
&["git", "diff", "--numstat", "HEAD", "--"],
623+
/*exit_code*/ 0,
624+
"10\t2\tsrc/main.rs\n-\t-\tassets/logo.png\n3\t0\tREADME.md\n",
625+
),
626+
]);
627+
628+
let stats = workspace_diff_stats(&runner, Path::new("/repo"))
629+
.await
630+
.expect("workspace diff stats");
631+
632+
assert_eq!(
633+
stats,
634+
GitWorkspaceDiffStats {
635+
additions: 13,
636+
deletions: 2,
637+
}
638+
);
639+
}
640+
641+
#[tokio::test]
642+
async fn workspace_diff_stats_omits_non_git_directories() {
643+
let runner = FakeRunner::new(vec![response(
644+
&["git", "rev-parse", "--git-dir"],
645+
/*exit_code*/ 128,
646+
"",
647+
)]);
648+
649+
assert_eq!(
650+
workspace_diff_stats(&runner, Path::new("/repo")).await,
651+
None
652+
);
653+
assert!(!runner.saw(&["git", "diff", "--numstat", "HEAD", "--"]));
654+
}
655+
571656
#[tokio::test]
572657
async fn open_pull_request_uses_current_branch_view_first() {
573658
let runner = FakeRunner::new(vec![response(

codex-rs/tui/src/chatwidget.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ use self::user_messages::PendingSteerCompareKey;
363363
use self::user_messages::UserMessageDisplay;
364364
mod warnings;
365365
use self::warnings::WarningDisplayState;
366+
pub(crate) use crate::branch_summary::GitWorkspaceDiffStats;
366367
pub(crate) use crate::branch_summary::StatusLineGitSummary;
367368
use crate::streaming::chunking::AdaptiveChunkingPolicy;
368369
use crate::streaming::commit_tick::CommitTickScope;
@@ -388,7 +389,8 @@ use unicode_segmentation::UnicodeSegmentation;
388389
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
389390
const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
390391
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
391-
const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"];
392+
const DEFAULT_STATUS_LINE_ITEMS: [&str; 3] =
393+
["model-with-reasoning", "current-dir", "workspace-changes"];
392394
// Track information about an in-flight exec command.
393395
struct RunningCommand {
394396
command: Vec<String>,
@@ -1012,6 +1014,14 @@ pub(crate) struct ChatWidget {
10121014
status_line_git_summary_pending: bool,
10131015
// True once we've attempted a Git summary lookup for the current CWD.
10141016
status_line_git_summary_lookup_complete: bool,
1017+
// Cached tracked workspace diff stats for the active status-line cwd.
1018+
status_line_workspace_changes: Option<GitWorkspaceDiffStats>,
1019+
// CWD used to resolve the cached workspace diff stats; change resets workspace state.
1020+
status_line_workspace_changes_cwd: Option<PathBuf>,
1021+
// True while an async workspace diff stats lookup is in flight.
1022+
status_line_workspace_changes_pending: bool,
1023+
// True once we've attempted a workspace diff stats lookup for the current CWD.
1024+
status_line_workspace_changes_lookup_complete: bool,
10151025
// Current thread-goal status shown in the status line when plan mode is inactive.
10161026
current_goal_status_indicator: Option<GoalStatusIndicator>,
10171027
current_goal_status: Option<GoalStatusState>,
@@ -2018,6 +2028,22 @@ impl ChatWidget {
20182028
self.refresh_status_surfaces();
20192029
}
20202030

2031+
/// Stores async workspace diff stats for the current status-line cwd.
2032+
pub(crate) fn set_status_line_workspace_changes(
2033+
&mut self,
2034+
cwd: PathBuf,
2035+
stats: Option<GitWorkspaceDiffStats>,
2036+
) {
2037+
if self.status_line_workspace_changes_cwd.as_ref() != Some(&cwd) {
2038+
self.status_line_workspace_changes_pending = false;
2039+
return;
2040+
}
2041+
self.status_line_workspace_changes = stats;
2042+
self.status_line_workspace_changes_pending = false;
2043+
self.status_line_workspace_changes_lookup_complete = true;
2044+
self.refresh_status_surfaces();
2045+
}
2046+
20212047
fn collect_runtime_metrics_delta(&mut self) {
20222048
if let Some(delta) = self.session_telemetry.runtime_metrics_summary() {
20232049
self.apply_runtime_metrics_delta(delta);
@@ -2601,6 +2627,7 @@ impl ChatWidget {
26012627
self.had_work_activity = false;
26022628
self.request_status_line_branch_refresh();
26032629
self.request_status_line_git_summary_refresh();
2630+
self.request_status_line_workspace_changes_refresh();
26042631
}
26052632
// Mark task stopped and request redraw now that all content is in history.
26062633
self.pending_status_indicator_restore = false;
@@ -3114,6 +3141,7 @@ impl ChatWidget {
31143141
self.pending_status_indicator_restore = false;
31153142
self.request_status_line_branch_refresh();
31163143
self.request_status_line_git_summary_refresh();
3144+
self.request_status_line_workspace_changes_refresh();
31173145
self.maybe_show_pending_rate_limit_prompt();
31183146
}
31193147

@@ -5202,6 +5230,10 @@ impl ChatWidget {
52025230
status_line_git_summary_cwd: None,
52035231
status_line_git_summary_pending: false,
52045232
status_line_git_summary_lookup_complete: false,
5233+
status_line_workspace_changes: None,
5234+
status_line_workspace_changes_cwd: None,
5235+
status_line_workspace_changes_pending: false,
5236+
status_line_workspace_changes_lookup_complete: false,
52055237
current_goal_status_indicator: None,
52065238
current_goal_status: None,
52075239
goal_status_active_turn_started_at: None,

0 commit comments

Comments
 (0)