From cc16995cc6cb5b2d2e2767227c036a3ec76fc671 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 4 May 2026 16:11:15 -0300 Subject: [PATCH] feat(tui): add PR summary statusline items (#20892) ## Why? The Codex App already exposes branch and PR context in its branch-details UI. This brings the same context into the CLI footer as opt-in statusline items, so users can choose the extra signal without making the default footer busier. ## What? Add optional `pull-request-number` and `branch-changes` items to the configurable TUI status line. - `pull-request-number` shows the open PR for the current checkout and renders as a clickable terminal hyperlink when OSC 8 links are supported. - `branch-changes` shows committed additions/deletions against the repository default branch, or `No changes` when the branch has no committed diff. CleanShot 2026-05-03 at 20 44 15 ## Architecture This follows the same client/app-server split as the Codex App: the TUI owns presentation, caching, and optional rendering, while workspace-sensitive `git` and `gh` discovery runs through app-server. The new TUI-local `workspace_command` layer sends bounded, non-interactive `command/exec` requests to the active app-server. That makes the implementation remote-friendly: the TUI does not decide whether commands run in an embedded local workspace or a remote workspace, and it does not bypass app-server sandbox or permission policy. The branch summary logic stays internal to `codex-tui` because this PR only needs TUI statusline behavior. The command boundary is still isolated behind `WorkspaceCommandExecutor`, so the lookup code can be lifted or reused later without changing statusline rendering. ## How? - Add a TUI `WorkspaceCommandExecutor` abstraction backed by app-server `command/exec`. - Add branch summary probes for: - current branch name, - open PR metadata, - committed branch diff stats against the default branch. - Prefer remote-tracking default branch refs for diff stats, avoiding stale or absent local `main` branches. - Resolve PRs with `gh pr view` first, then fall back to commit-associated PR lookup across parent/fork repos. - Add `/statusline` picker entries, preview values, rendering, and OSC 8 clickable PR links. - Keep all probes best-effort so missing `git`, missing `gh`, auth failures, or non-git directories hide optional items instead of surfacing footer errors. ## Validation - `cargo test -p codex-tui branch_summary -- --nocapture` - Snapshot coverage for the `/statusline` preview/setup rendering paths - Hyperlink rendering coverage for clickable PR statusline cells --- codex-rs/tui/src/app.rs | 11 + codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 + codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 49 ++ codex-rs/tui/src/bottom_pane/mod.rs | 6 + ..._snapshot_uses_runtime_preview_values.snap | 2 +- .../tui/src/bottom_pane/status_line_setup.rs | 26 + .../tui/src/bottom_pane/status_line_style.rs | 28 +- .../src/bottom_pane/status_surface_preview.rs | 6 + codex-rs/tui/src/branch_summary.rs | 739 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 46 ++ .../tui/src/chatwidget/status_surfaces.rs | 104 ++- codex-rs/tui/src/chatwidget/tests/helpers.rs | 5 + .../tui/src/chatwidget/tests/plan_mode.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 99 +++ codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 21 +- codex-rs/tui/src/onboarding/mod.rs | 1 + codex-rs/tui/src/workspace_command.rs | 200 +++++ 22 files changed, 1351 insertions(+), 10 deletions(-) create mode 100644 codex-rs/tui/src/branch_summary.rs create mode 100644 codex-rs/tui/src/workspace_command.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2f3599da6..66c5dfd18 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -76,6 +76,8 @@ use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; +use crate::workspace_command::AppServerWorkspaceCommandRunner; +use crate::workspace_command::WorkspaceCommandRunner; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; @@ -432,6 +434,7 @@ pub(crate) struct App { pub(crate) session_telemetry: SessionTelemetry, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, + workspace_command_runner: Option, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) state_db: Option, @@ -574,6 +577,7 @@ impl App { config: cfg, frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), + workspace_command_runner: self.workspace_command_runner.clone(), initial_user_message, enhanced_keys_supported: self.enhanced_keys_supported, has_chatgpt_account: self.chat_widget.has_chatgpt_account(), @@ -712,6 +716,9 @@ impl App { let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let workspace_command_runner: WorkspaceCommandRunner = Arc::new( + AppServerWorkspaceCommandRunner::new(app_server.request_handle()), + ); let runtime_model_provider_base_url = resolve_runtime_model_provider_base_url(&config.model_provider).await; @@ -734,6 +741,7 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), initial_user_message: crate::chatwidget::create_initial_user_message( initial_prompt.clone(), initial_images.clone(), @@ -769,6 +777,7 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), initial_user_message: crate::chatwidget::create_initial_user_message( initial_prompt.clone(), initial_images.clone(), @@ -809,6 +818,7 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), + workspace_command_runner: Some(workspace_command_runner.clone()), initial_user_message: crate::chatwidget::create_initial_user_message( initial_prompt.clone(), initial_images.clone(), @@ -856,6 +866,7 @@ See the Codex keymap documentation for supported actions and examples." session_telemetry: session_telemetry.clone(), app_event_tx, chat_widget, + workspace_command_runner: Some(workspace_command_runner), config, state_db, active_profile, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 8686377a5..aefab7a1b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1848,6 +1848,10 @@ impl App { self.chat_widget.set_status_line_branch(cwd, branch); self.refresh_status_line(); } + AppEvent::StatusLineGitSummaryUpdated { cwd, summary } => { + self.chat_widget.set_status_line_git_summary(cwd, summary); + self.refresh_status_line(); + } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 3d3956c65..f34e22203 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -19,6 +19,7 @@ pub(super) async fn make_test_app() -> App { session_telemetry, app_event_tx, chat_widget, + workspace_command_runner: None, config, state_db: None, active_profile: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 787afc7a1..949aa581a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -424,6 +424,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm config, frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), + workspace_command_runner: None, initial_user_message: create_initial_user_message( Some(initial_prompt.clone()), Vec::new(), @@ -3770,6 +3771,7 @@ async fn make_test_app() -> App { session_telemetry, app_event_tx, chat_widget, + workspace_command_runner: None, config, state_db: None, active_profile: None, @@ -3832,6 +3834,7 @@ async fn make_test_app_with_channels() -> ( session_telemetry, app_event_tx, chat_widget, + workspace_command_runner: None, config, state_db: None, active_profile: None, @@ -4733,6 +4736,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { config: app.config.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), + workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: app.enhanced_keys_supported, has_chatgpt_account: app.chat_widget.has_chatgpt_account(), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bc688e32c..b9e438000 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -827,6 +827,11 @@ pub(crate) enum AppEvent { cwd: PathBuf, branch: Option, }, + /// Async update of Git summary fields for status line rendering. + StatusLineGitSummaryUpdated { + cwd: PathBuf, + summary: crate::chatwidget::StatusLineGitSummary, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4275c6743..4433d79cf 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -190,6 +190,7 @@ use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; use crate::keymap::primary_binding; +use crate::onboarding::mark_underlined_hyperlink; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; @@ -396,6 +397,7 @@ pub(crate) struct ChatComposer { side_conversation_active: bool, is_zellij: bool, status_line_value: Option>, + status_line_hyperlink_url: Option, status_line_enabled: bool, side_conversation_context_label: Option, // Agent label injected into the footer's contextual row when multi-agent mode is active. @@ -580,6 +582,7 @@ impl ChatComposer { Some(codex_terminal_detection::Multiplexer::Zellij {}) ), status_line_value: None, + status_line_hyperlink_url: None, status_line_enabled: false, side_conversation_context_label: None, active_agent_label: None, @@ -4037,6 +4040,14 @@ impl ChatComposer { true } + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) -> bool { + if self.status_line_hyperlink_url == url { + return false; + } + self.status_line_hyperlink_url = url; + true + } + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { if self.status_line_enabled == enabled { return false; @@ -4441,6 +4452,11 @@ impl ChatComposer { if show_right && let Some(line) = &right_line { render_context_right(hint_rect, buf, line); } + if status_line_active + && let Some(url) = self.status_line_hyperlink_url.as_deref() + { + mark_underlined_hyperlink(buf, hint_rect, url); + } } } } @@ -5022,6 +5038,39 @@ mod tests { ); } + #[test] + fn status_line_hyperlink_marks_pr_number_cells() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + let url = "https://github.com/openai/codex/pull/20252"; + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from(Span::styled( + "PR #20252", + Style::default().cyan().underlined(), + )))); + composer.set_status_line_hyperlink(Some(url.to_string())); + + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let marked_cells = (area.top()..area.bottom()) + .flat_map(|y| (area.left()..area.right()).map(move |x| (x, y))) + .filter(|&(x, y)| buf[(x, y)].symbol().contains(url)) + .count(); + assert_eq!( + marked_cells, + "PR #20252".chars().filter(|ch| !ch.is_whitespace()).count() + ); + } + #[test] fn esc_exits_empty_shell_mode() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 1f2e4b495..c839ddd4a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1544,6 +1544,12 @@ impl BottomPane { } } + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { + if self.composer.set_status_line_hyperlink(url) { + self.request_redraw(); + } + } + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) { if self.composer.set_status_line_enabled(enabled) { self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap index d29d964d8..db86cf8a7 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap @@ -15,7 +15,7 @@ expression: "render_lines(&view, 72)" [x] git-branch Current Git branch (omitted when unavaila… [ ] model-with-reasoning Current model name with reasoning level [ ] project-name Project name (omitted when unavailable) - [ ] run-state Compact session run-state text (Ready, Wo… + [ ] pull-request-number Open pull request number for the current … gpt-5-codex · ~/codex-rs · jif/statusline-preview Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 5dd79f35e..5d0ba8718 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -71,6 +71,12 @@ pub(crate) enum StatusLineItem { /// Current git branch name (if in a repository). GitBranch, + /// Open pull request number for the current branch. + PullRequestNumber, + + /// Committed branch diff stats relative to the default branch. + BranchChanges, + /// Compact runtime run-state text. #[strum(to_string = "run-state", serialize = "status")] Status, @@ -127,6 +133,12 @@ impl StatusLineItem { StatusLineItem::CurrentDir => "Current working directory", StatusLineItem::ProjectRoot => "Project name (omitted when unavailable)", StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)", + StatusLineItem::PullRequestNumber => { + "Open pull request number for the current branch (omitted when unavailable)" + } + StatusLineItem::BranchChanges => { + "Committed branch changes against the default branch (omitted when unavailable)" + } StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)", StatusLineItem::ContextRemaining => { "Percentage of context window remaining (omitted when unknown)" @@ -165,6 +177,8 @@ impl StatusLineItem { StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir, StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot, StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch, + StatusLineItem::PullRequestNumber => StatusSurfacePreviewItem::PullRequestNumber, + StatusLineItem::BranchChanges => StatusSurfacePreviewItem::BranchChanges, StatusLineItem::Status => StatusSurfacePreviewItem::Status, StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining, StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed, @@ -409,6 +423,18 @@ mod tests { ); } + #[test] + fn git_summary_items_are_selectable_ids() { + assert_eq!( + "pull-request-number".parse::(), + Ok(StatusLineItem::PullRequestNumber) + ); + assert_eq!( + "branch-changes".parse::(), + Ok(StatusLineItem::BranchChanges) + ); + } + #[test] fn parse_status_line_items_accepts_title_only_variants() { let items = ["run-state", "task-progress"] diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 1449256a6..dddd02db0 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -32,7 +32,9 @@ impl StatusLineAccent { match item { StatusLineItem::ModelName | StatusLineItem::ModelWithReasoning => Self::Model, StatusLineItem::CurrentDir | StatusLineItem::ProjectRoot => Self::Path, - StatusLineItem::GitBranch => Self::Branch, + StatusLineItem::GitBranch + | StatusLineItem::PullRequestNumber + | StatusLineItem::BranchChanges => Self::Branch, StatusLineItem::Status => Self::State, StatusLineItem::ContextRemaining | StatusLineItem::ContextUsed @@ -106,6 +108,11 @@ where } else { Style::default().dim() }; + let style = if item == StatusLineItem::PullRequestNumber { + style.underlined() + } else { + style + }; spans.push(Span::styled(text, style)); } @@ -256,6 +263,25 @@ mod tests { assert!(line.spans[2].style.add_modifier.contains(Modifier::DIM)); } + #[test] + fn pull_request_number_uses_link_style() { + let line = status_line_from_segments_with_resolver( + [(StatusLineItem::PullRequestNumber, "PR #20252".to_string())], + /*use_theme_colors*/ false, + |_| None, + ) + .expect("status line"); + + assert_eq!(line.spans[0].style.fg, None); + assert!(line.spans[0].style.add_modifier.contains(Modifier::DIM)); + assert!( + line.spans[0] + .style + .add_modifier + .contains(Modifier::UNDERLINED) + ); + } + #[test] fn status_line_segments_return_none_when_empty() { assert_eq!( diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 084ff1056..581d424aa 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -14,6 +14,8 @@ pub(crate) enum StatusSurfacePreviewItem { Status, ThreadTitle, GitBranch, + PullRequestNumber, + BranchChanges, ContextRemaining, ContextUsed, FiveHourLimit, @@ -40,6 +42,8 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::Status => "Working", StatusSurfacePreviewItem::ThreadTitle => "thread title", StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature", + StatusSurfacePreviewItem::PullRequestNumber => "PR #123", + StatusSurfacePreviewItem::BranchChanges => "+12 -3", StatusSurfacePreviewItem::ContextRemaining => "Context 0% left", StatusSurfacePreviewItem::ContextUsed => "Context 0% used", StatusSurfacePreviewItem::FiveHourLimit => "5h 0%", @@ -66,6 +70,8 @@ impl StatusSurfacePreviewItem { Self::Status, Self::ThreadTitle, Self::GitBranch, + Self::PullRequestNumber, + Self::BranchChanges, Self::ContextRemaining, Self::ContextUsed, Self::FiveHourLimit, diff --git a/codex-rs/tui/src/branch_summary.rs b/codex-rs/tui/src/branch_summary.rs new file mode 100644 index 000000000..4698dc96e --- /dev/null +++ b/codex-rs/tui/src/branch_summary.rs @@ -0,0 +1,739 @@ +//! Branch and pull-request metadata for TUI status-line items. +//! +//! This module owns the git and GitHub probes behind the TUI `git-branch`, `pull-request-number`, +//! and `branch-changes` status-line items. It deliberately talks only to a +//! `WorkspaceCommandExecutor`, not to `tokio::process::Command`, so the same lookup logic works +//! when the TUI is connected to either an embedded or remote app-server. +//! +//! All lookups are best-effort. A failed command, missing `git` or `gh`, unauthenticated GitHub +//! CLI, non-git directory, or ambiguous repository state should result in absent optional metadata +//! rather than a user-visible error. The status line can then render whichever pieces are available +//! without blocking the rest of the UI. + +#[cfg(test)] +use std::collections::VecDeque; +use std::path::Path; + +use serde::Deserialize; + +use crate::workspace_command::WorkspaceCommand; +#[cfg(test)] +use crate::workspace_command::WorkspaceCommandError; +use crate::workspace_command::WorkspaceCommandExecutor; +use crate::workspace_command::WorkspaceCommandOutput; + +/// Additions and deletions between `HEAD` and a branch comparison base. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GitBranchDiffStats { + /// Total added lines in committed changes on the current branch. + pub(crate) additions: u64, + /// Total deleted lines in committed changes on the current branch. + pub(crate) deletions: u64, +} + +/// Combined git metadata cached by the status line for one working directory. +/// +/// A summary may contain only one of the fields when the other probe fails. Renderers should treat +/// missing fields as omitted optional UI rather than as a hard lookup failure. +#[derive(Clone, Debug, Default)] +pub(crate) struct StatusLineGitSummary { + /// Open pull request associated with the current branch or HEAD commit. + pub(crate) pull_request: Option, + /// Additions and deletions between `HEAD` and the repository default branch merge base. + pub(crate) branch_change_stats: Option, +} + +/// Open GitHub pull request shown by the `pull-request-number` status-line item. +/// +/// The URL is kept with the number so clickable renderers can open the same PR represented by the +/// compact label. Callers should only construct this for open PRs; closed or merged PRs are filtered +/// out by this module. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StatusLinePullRequest { + /// GitHub pull request number. + pub(crate) number: u64, + /// Browser URL for the pull request. + pub(crate) url: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DefaultBranch { + /// Git ref used for merge-base comparison. + /// + /// This may be a remote-tracking ref such as `refs/remotes/origin/main`, which avoids + /// comparing against a stale or absent local `main` branch. + merge_ref: String, +} + +#[derive(Deserialize)] +struct GhPullRequestView { + number: u64, + url: String, + state: String, +} + +#[derive(Deserialize)] +struct GhPullRequestApiItem { + number: u64, + #[serde(rename = "html_url")] + url: String, + state: String, +} + +#[derive(Deserialize)] +struct GhRepoView { + #[serde(rename = "nameWithOwner")] + name_with_owner: Option, + parent: Option, +} + +#[derive(Deserialize)] +struct GhRepoParent { + #[serde(rename = "nameWithOwner")] + name_with_owner: String, +} + +/// Returns the checked-out branch name for one status-line working directory. +/// +/// Detached HEADs, non-git directories, and command failures return `None` so the renderer can +/// omit the branch item without surfacing a background lookup error. +pub(crate) async fn current_branch_name( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let output = run_git_command(runner, cwd, &["branch", "--show-current"]) + .await + .ok()?; + if !output.success() { + return None; + } + + Some(output.stdout.trim().to_string()).filter(|name| !name.is_empty()) +} + +/// Resolves PR and branch-change metadata for one status-line working directory. +/// +/// The PR and diff-stat probes run concurrently because each is independent and both are optional. +/// The returned summary is suitable for caching by `cwd`; callers should discard it if the active +/// status-line cwd changes before the async lookup completes. +pub(crate) async fn status_line_git_summary( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> StatusLineGitSummary { + let (pull_request, branch_change_stats) = tokio::join!( + open_pull_request(runner, cwd), + branch_diff_stats_to_default_branch(runner, cwd), + ); + StatusLineGitSummary { + pull_request, + branch_change_stats, + } +} + +/// Counts committed line changes between `HEAD` and the repository default branch. +/// +/// The comparison base is the merge base with a verified default-branch ref. Uncommitted working +/// tree edits are intentionally ignored because the status-line item summarizes the checked-out +/// branch, not the current dirty worktree. +async fn branch_diff_stats_to_default_branch( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let git_dir = run_git_command(runner, cwd, &["rev-parse", "--git-dir"]) + .await + .ok()?; + if !git_dir.success() { + return None; + } + + let default_branch = get_default_branch(runner, cwd).await?; + let merge_base = run_git_command( + runner, + cwd, + &["merge-base", "HEAD", &default_branch.merge_ref], + ) + .await + .ok()?; + if !merge_base.success() { + return None; + } + let merge_base = merge_base.stdout.trim(); + if merge_base.is_empty() { + return None; + } + + let range = format!("{merge_base}..HEAD"); + let numstat = run_git_command(runner, cwd, &["diff", "--numstat", &range]) + .await + .ok()?; + if !numstat.success() { + return None; + } + + let mut additions = 0_u64; + let mut deletions = 0_u64; + for line in numstat.stdout.lines() { + let mut columns = line.split('\t'); + additions += columns + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + deletions += columns + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + } + + Some(GitBranchDiffStats { + additions, + deletions, + }) +} + +/// Returns git remotes in the order used for default-branch discovery. +/// +/// `origin` is prioritized because most repositories use it as the canonical upstream. Other +/// remotes are still tried so fork or enterprise layouts with a differently named upstream can +/// produce branch-change stats when their remote HEAD is configured. +async fn get_git_remotes(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option> { + let output = run_git_command(runner, cwd, &["remote"]).await.ok()?; + if !output.success() { + return None; + } + + let mut remotes: Vec = output.stdout.lines().map(str::to_string).collect(); + if let Some(pos) = remotes.iter().position(|remote| remote == "origin") { + let origin = remotes.remove(pos); + remotes.insert(0, origin); + } + Some(remotes) +} + +/// Resolves the default branch ref that should be used for branch-change comparisons. +/// +/// The lookup prefers remote-tracking refs over local branches so feature-only clones and stale +/// local `main` branches do not inflate the status-line diff. When no remote default is available, +/// local `main` or `master` is used as a last resort. +async fn get_default_branch( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let remotes = get_git_remotes(runner, cwd).await.unwrap_or_default(); + for remote in remotes { + if let Some(branch) = + get_remote_default_branch_from_symbolic_ref(runner, cwd, &remote).await + { + return Some(branch); + } + + if let Some(branch) = get_remote_default_branch_from_remote_show(runner, cwd, &remote).await + { + return Some(branch); + } + } + + get_default_branch_local(runner, cwd).await +} + +/// Resolves a remote's symbolic HEAD into a concrete remote-tracking ref. +/// +/// The returned ref is verified before use. Without that check, a symbolic `origin/HEAD` left over +/// from an old fetch could point at a ref that no longer exists, causing the later merge-base probe +/// to fail in a less obvious place. +async fn get_remote_default_branch_from_symbolic_ref( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + remote: &str, +) -> Option { + let remote_head = format!("refs/remotes/{remote}/HEAD"); + let output = run_git_command(runner, cwd, &["symbolic-ref", "--quiet", &remote_head]) + .await + .ok()?; + if !output.success() { + return None; + } + + let trimmed = output.stdout.trim(); + let remote_ref_prefix = format!("refs/remotes/{remote}/"); + trimmed.strip_prefix(&remote_ref_prefix)?; + if !git_ref_exists(runner, cwd, trimmed).await { + return None; + } + + Some(DefaultBranch { + merge_ref: trimmed.to_string(), + }) +} + +/// Parses `git remote show` output to discover a remote's default branch ref. +/// +/// This is a fallback for repositories where `refs/remotes//HEAD` is not configured but +/// `git remote show` can still report the upstream HEAD branch. The concrete remote-tracking ref +/// must already exist locally before it is accepted. +async fn get_remote_default_branch_from_remote_show( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + remote: &str, +) -> Option { + let output = run_git_command(runner, cwd, &["remote", "show", remote]) + .await + .ok()?; + if !output.success() { + return None; + } + + for line in output.stdout.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("HEAD branch:") else { + continue; + }; + let name = rest.trim(); + let remote_ref = format!("refs/remotes/{remote}/{name}"); + if !name.is_empty() && git_ref_exists(runner, cwd, &remote_ref).await { + return Some(DefaultBranch { + merge_ref: remote_ref, + }); + } + } + + None +} + +/// Falls back to local `main` or `master` when no remote default branch can be found. +async fn get_default_branch_local( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + for candidate in ["main", "master"] { + let local_ref = format!("refs/heads/{candidate}"); + if git_ref_exists(runner, cwd, &local_ref).await { + return Some(DefaultBranch { + merge_ref: local_ref, + }); + } + } + + None +} + +/// Checks whether a git ref exists in the status-line working directory. +async fn git_ref_exists( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + reference: &str, +) -> bool { + run_git_command( + runner, + cwd, + &["rev-parse", "--verify", "--quiet", reference], + ) + .await + .is_ok_and(|output| output.success()) +} + +/// Resolves the open PR associated with the current checkout. +/// +/// Branch-based lookup is attempted first because it is cheap and mirrors `gh pr view`. Commit-based +/// lookup is used as a fallback so fork workflows can still find a PR opened against the upstream +/// repository even when `gh` infers the fork from the current checkout. +async fn open_pull_request( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + if let Some(pull_request) = open_pull_request_for_current_branch(runner, cwd).await { + return Some(pull_request); + } + + open_pull_request_for_head_commit(runner, cwd).await +} + +/// Uses GitHub CLI's current-branch PR lookup. +async fn open_pull_request_for_current_branch( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let output = run_gh_command(runner, cwd, &["pr", "view", "--json", "number,url,state"]) + .await + .ok()?; + if !output.success() { + return None; + } + pull_request_from_view_output(&output.stdout) +} + +/// Looks up open PRs for `HEAD` across the upstream/fork repository search order. +async fn open_pull_request_for_head_commit( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option { + let head_sha = current_head_sha(runner, cwd).await?; + for repo in gh_repo_search_order(runner, cwd).await? { + let endpoint = format!("repos/{repo}/commits/{head_sha}/pulls"); + let output = run_gh_command( + runner, + cwd, + &[ + "api", + "-H", + "Accept: application/vnd.github+json", + &endpoint, + ], + ) + .await + .ok()?; + if output.success() + && let Some(pull_request) = pull_request_from_api_output(&output.stdout) + { + return Some(pull_request); + } + } + + None +} + +/// Returns the current `HEAD` SHA for commit-based PR lookup. +async fn current_head_sha(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option { + let output = run_git_command(runner, cwd, &["rev-parse", "HEAD"]) + .await + .ok()?; + if !output.success() { + return None; + } + + Some(output.stdout.trim().to_string()).filter(|sha| !sha.is_empty()) +} + +/// Returns repositories to query for commit-associated PRs, with parent before fork. +async fn gh_repo_search_order( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Option> { + let output = run_gh_command( + runner, + cwd, + &["repo", "view", "--json", "nameWithOwner,parent"], + ) + .await + .ok()?; + if !output.success() { + return None; + } + + repo_search_order_from_output(&output.stdout) +} + +/// Parses `gh pr view --json number,url,state` output for an open PR. +fn pull_request_from_view_output(stdout: &str) -> Option { + let pull_request = serde_json::from_str::(stdout).ok()?; + pull_request + .state + .eq_ignore_ascii_case("open") + .then_some(StatusLinePullRequest { + number: pull_request.number, + url: pull_request.url, + }) +} + +/// Parses the GitHub REST commit-to-PR response and returns the first open PR. +fn pull_request_from_api_output(stdout: &str) -> Option { + serde_json::from_str::>(stdout) + .ok()? + .into_iter() + .find(|pull_request| pull_request.state.eq_ignore_ascii_case("open")) + .map(|pull_request| StatusLinePullRequest { + number: pull_request.number, + url: pull_request.url, + }) +} + +/// Parses `gh repo view` output into the repository search order for fallback PR lookup. +/// +/// Parent-first ordering matches upstream PR workflows: a branch may be checked out from a fork +/// while the open PR lives on the parent repository. +fn repo_search_order_from_output(stdout: &str) -> Option> { + let repo = serde_json::from_str::(stdout).ok()?; + let mut repos = Vec::new(); + if let Some(parent) = repo.parent { + repos.push(parent.name_with_owner); + } + if let Some(name_with_owner) = repo.name_with_owner + && !repos.iter().any(|repo| repo == &name_with_owner) + { + repos.push(name_with_owner); + } + if repos.is_empty() { + return None; + } + + Some(repos) +} + +/// Runs a git command through the workspace-command abstraction. +async fn run_git_command( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push("git".to_string()); + argv.extend(args.iter().map(|arg| (*arg).to_string())); + runner + .run( + WorkspaceCommand::new(argv) + .cwd(cwd.to_path_buf()) + .env("GIT_OPTIONAL_LOCKS", "0"), + ) + .await +} + +/// Runs a GitHub CLI command through the workspace-command abstraction. +/// +/// Prompting is disabled because status-line probes are background UI work. A command that needs +/// authentication or user input should fail and leave the optional PR item hidden. +async fn run_gh_command( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push("gh".to_string()); + argv.extend(args.iter().map(|arg| (*arg).to_string())); + runner + .run( + WorkspaceCommand::new(argv) + .cwd(cwd.to_path_buf()) + .env("GH_PROMPT_DISABLED", "1") + .env("GIT_TERMINAL_PROMPT", "0"), + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace_command::WorkspaceCommand; + use pretty_assertions::assert_eq; + use std::future::Future; + use std::pin::Pin; + use std::sync::Mutex; + + #[tokio::test] + async fn branch_diff_stats_prefers_remote_default_ref_over_stale_local_branch() { + let runner = FakeRunner::new(vec![ + response( + &["git", "rev-parse", "--git-dir"], + /*exit_code*/ 0, + ".git\n", + ), + response(&["git", "remote"], /*exit_code*/ 0, "origin\n"), + response( + &["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + /*exit_code*/ 0, + "refs/remotes/origin/main\n", + ), + response( + &[ + "git", + "rev-parse", + "--verify", + "--quiet", + "refs/remotes/origin/main", + ], + /*exit_code*/ 0, + "remote-main-sha\n", + ), + response( + &["git", "merge-base", "HEAD", "refs/remotes/origin/main"], + /*exit_code*/ 0, + "base-sha\n", + ), + response( + &["git", "diff", "--numstat", "base-sha..HEAD"], + /*exit_code*/ 0, + "1\t0\tfile\n", + ), + ]); + + let stats = branch_diff_stats_to_default_branch(&runner, Path::new("/repo")) + .await + .expect("branch diff stats"); + + assert_eq!( + stats, + GitBranchDiffStats { + additions: 1, + deletions: 0, + } + ); + assert!(runner.saw(&["git", "merge-base", "HEAD", "refs/remotes/origin/main"])); + } + + #[tokio::test] + async fn open_pull_request_uses_current_branch_view_first() { + let runner = FakeRunner::new(vec![response( + &["gh", "pr", "view", "--json", "number,url,state"], + /*exit_code*/ 0, + r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"OPEN"}"#, + )]); + + let pull_request = open_pull_request(&runner, Path::new("/repo")) + .await + .expect("pull request"); + + assert_eq!( + pull_request, + StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + } + ); + assert!(!runner.saw(&["git", "rev-parse", "HEAD"])); + } + + #[tokio::test] + async fn open_pull_request_falls_back_to_parent_repo_commit_lookup() { + let runner = FakeRunner::new(vec![ + response( + &["gh", "pr", "view", "--json", "number,url,state"], + /*exit_code*/ 1, + "", + ), + response( + &["git", "rev-parse", "HEAD"], + /*exit_code*/ 0, + "head-sha\n", + ), + response( + &["gh", "repo", "view", "--json", "nameWithOwner,parent"], + /*exit_code*/ 0, + r#"{"nameWithOwner":"fcoury/codex","parent":{"nameWithOwner":"openai/codex"}}"#, + ), + response( + &[ + "gh", + "api", + "-H", + "Accept: application/vnd.github+json", + "repos/openai/codex/commits/head-sha/pulls", + ], + /*exit_code*/ 0, + r#"[{"number":20252,"html_url":"https://github.com/openai/codex/pull/20252","state":"open"}]"#, + ), + ]); + + let pull_request = open_pull_request(&runner, Path::new("/repo")) + .await + .expect("pull request"); + + assert_eq!( + pull_request, + StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + } + ); + assert!(runner.saw(&[ + "gh", + "api", + "-H", + "Accept: application/vnd.github+json", + "repos/openai/codex/commits/head-sha/pulls", + ])); + } + + #[test] + fn status_line_pr_view_parser_requires_open_pr() { + assert_eq!( + pull_request_from_view_output( + r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"OPEN"}"# + ), + Some(StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + }) + ); + + assert_eq!( + pull_request_from_view_output( + r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"MERGED"}"# + ), + None + ); + } + + #[test] + fn status_line_pr_fallback_searches_parent_repo_first() { + assert_eq!( + repo_search_order_from_output( + r#"{"nameWithOwner":"fcoury/codex","parent":{"nameWithOwner":"openai/codex"}}"# + ), + Some(vec!["openai/codex".to_string(), "fcoury/codex".to_string()]) + ); + } + + fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse { + FakeResponse { + argv: argv.iter().map(|arg| (*arg).to_string()).collect(), + output: WorkspaceCommandOutput { + exit_code, + stdout: stdout.to_string(), + stderr: String::new(), + }, + } + } + + struct FakeResponse { + argv: Vec, + output: WorkspaceCommandOutput, + } + + struct FakeRunner { + responses: Mutex>, + seen: Mutex>>, + } + + impl FakeRunner { + fn new(responses: Vec) -> Self { + Self { + responses: Mutex::new(responses.into()), + seen: Mutex::new(Vec::new()), + } + } + + fn saw(&self, argv: &[&str]) -> bool { + let argv: Vec = argv.iter().map(|arg| (*arg).to_string()).collect(); + self.seen + .lock() + .expect("seen lock") + .iter() + .any(|seen| seen == &argv) + } + } + + impl WorkspaceCommandExecutor for FakeRunner { + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + self.seen + .lock() + .expect("seen lock") + .push(command.argv.clone()); + Box::pin(async move { + let mut responses = self.responses.lock().expect("responses lock"); + let index = responses + .iter() + .position(|response| response.argv == command.argv) + .unwrap_or_else(|| panic!("missing fake response for {:?}", command.argv)); + let response = responses.remove(index).expect("fake response"); + Ok(response.output) + }) + } + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d06c67456..6d15d64aa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -349,11 +349,13 @@ use self::status_surfaces::TerminalTitleStatusKind; mod user_messages; use self::user_messages::PendingSteerCompareKey; use self::user_messages::UserMessageDisplay; +pub(crate) use crate::branch_summary::StatusLineGitSummary; use crate::streaming::chunking::AdaptiveChunkingPolicy; use crate::streaming::commit_tick::CommitTickScope; use crate::streaming::commit_tick::run_commit_tick; use crate::streaming::controller::PlanStreamController; use crate::streaming::controller::StreamController; +use crate::workspace_command::WorkspaceCommandRunner; use chrono::Local; use codex_app_server_protocol::AskForApproval; @@ -554,6 +556,11 @@ pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, + /// App-server-backed runner used by status surfaces for workspace metadata probes. + /// + /// Tests that do not exercise git status-line refreshes may leave this unset. Production TUI + /// construction provides a runner for the active app-server session. + pub(crate) workspace_command_runner: Option, pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) has_chatgpt_account: bool, @@ -972,6 +979,8 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, // Current working directory (if known) current_cwd: Option, + // App-server-backed command runner for status-line workspace metadata lookups. + workspace_command_runner: Option, // Instruction source files loaded for the current session, supplied by app-server. instruction_source_paths: Vec, // Runtime network proxy bind addresses from SessionConfigured. @@ -1004,6 +1013,14 @@ pub(crate) struct ChatWidget { status_line_branch_pending: bool, // True once we've attempted a branch lookup for the current CWD. status_line_branch_lookup_complete: bool, + // Cached PR and branch-change summary for the active status-line cwd. + status_line_git_summary: Option, + // CWD used to resolve the cached Git summary; change resets summary state. + status_line_git_summary_cwd: Option, + // True while an async Git summary lookup is in flight. + status_line_git_summary_pending: bool, + // True once we've attempted a Git summary lookup for the current CWD. + status_line_git_summary_lookup_complete: bool, // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, @@ -1831,6 +1848,11 @@ impl ChatWidget { self.bottom_pane.set_status_line(status_line); } + /// Sets the terminal hyperlink target for the currently rendered footer status line. + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { + self.bottom_pane.set_status_line_hyperlink(url); + } + /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. /// /// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the @@ -1929,6 +1951,22 @@ impl ChatWidget { self.refresh_status_surfaces(); } + /// Stores async Git summary lookup results for the current status-line cwd. + pub(crate) fn set_status_line_git_summary( + &mut self, + cwd: PathBuf, + summary: StatusLineGitSummary, + ) { + if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) { + self.status_line_git_summary_pending = false; + return; + } + self.status_line_git_summary = Some(summary); + self.status_line_git_summary_pending = false; + self.status_line_git_summary_lookup_complete = true; + self.refresh_status_surfaces(); + } + fn collect_runtime_metrics_delta(&mut self) { if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { self.apply_runtime_metrics_delta(delta); @@ -2499,6 +2537,7 @@ impl ChatWidget { self.needs_final_message_separator = false; self.had_work_activity = false; self.request_status_line_branch_refresh(); + self.request_status_line_git_summary_refresh(); } // Mark task stopped and request redraw now that all content is in history. self.pending_status_indicator_restore = false; @@ -2960,6 +2999,7 @@ impl ChatWidget { self.plan_stream_controller = None; self.pending_status_indicator_restore = false; self.request_status_line_branch_refresh(); + self.request_status_line_git_summary_refresh(); self.maybe_show_pending_rate_limit_prompt(); } @@ -4768,6 +4808,7 @@ impl ChatWidget { config, frame_requester, app_event_tx, + workspace_command_runner, initial_user_message, enhanced_keys_supported, has_chatgpt_account, @@ -4960,6 +5001,7 @@ impl ChatWidget { feedback, current_rollout_path: None, current_cwd, + workspace_command_runner, instruction_source_paths: Vec::new(), session_network_proxy: None, status_line_invalid_items_warned, @@ -4973,6 +5015,10 @@ impl ChatWidget { status_line_branch_cwd: None, status_line_branch_pending: false, status_line_branch_lookup_complete: false, + status_line_git_summary: None, + status_line_git_summary_cwd: None, + status_line_git_summary_pending: false, + status_line_git_summary_lookup_complete: false, current_goal_status_indicator: None, current_goal_status: None, goal_status_active_turn_started_at: None, diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index c78bfa760..699b45e05 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -5,6 +5,7 @@ use super::*; use crate::bottom_pane::status_line_from_segments; +use crate::branch_summary; use crate::status::format_tokens_compact; /// Items shown in the terminal title when the user has not configured a @@ -59,6 +60,14 @@ impl StatusSurfaceSelections { .terminal_title_items .contains(&TerminalTitleItem::GitBranch) } + + fn uses_git_summary(&self) -> bool { + self.status_line_items + .contains(&StatusLineItem::PullRequestNumber) + || self + .status_line_items + .contains(&StatusLineItem::BranchChanges) + } } /// Cached project-root display name keyed by the cwd used for the last lookup. @@ -132,13 +141,24 @@ impl ChatWidget { self.status_line_branch = None; self.status_line_branch_pending = false; self.status_line_branch_lookup_complete = false; - return; + } else { + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_branch_state(&cwd); + if !self.status_line_branch_lookup_complete { + self.request_status_line_branch(cwd); + } } - let cwd = self.status_line_cwd().to_path_buf(); - self.sync_status_line_branch_state(&cwd); - if !self.status_line_branch_lookup_complete { - self.request_status_line_branch(cwd); + if !selections.uses_git_summary() { + self.status_line_git_summary = None; + self.status_line_git_summary_pending = false; + self.status_line_git_summary_lookup_complete = false; + } else { + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_git_summary_state(&cwd); + if !self.status_line_git_summary_lookup_complete { + self.request_status_line_git_summary(cwd); + } } } @@ -147,6 +167,7 @@ impl ChatWidget { self.bottom_pane.set_status_line_enabled(enabled); if !enabled { self.set_status_line(/*status_line*/ None); + self.set_status_line_hyperlink(/*url*/ None); return; } @@ -161,6 +182,12 @@ impl ChatWidget { segments, self.config.tui_status_line_use_colors, )); + let hyperlink_url = selections + .status_line_items + .contains(&StatusLineItem::PullRequestNumber) + .then(|| self.status_line_pull_request_url()) + .flatten(); + self.set_status_line_hyperlink(hyperlink_url); } /// Clears the terminal title Codex most recently wrote, if any. @@ -348,6 +375,16 @@ impl ChatWidget { self.request_status_line_branch(cwd); } + pub(super) fn request_status_line_git_summary_refresh(&mut self) { + let selections = self.status_surface_selections(); + if !selections.uses_git_summary() { + return; + } + let cwd = self.status_line_cwd().to_path_buf(); + self.sync_status_line_git_summary_state(&cwd); + self.request_status_line_git_summary(cwd); + } + /// Parses configured status-line ids into known items and collects unknown ids. /// /// Unknown ids are deduplicated in insertion order for warning messages. @@ -473,6 +510,16 @@ impl ChatWidget { self.status_line_branch_lookup_complete = false; } + fn sync_status_line_git_summary_state(&mut self, cwd: &Path) { + if self.status_line_git_summary_cwd.as_deref() == Some(cwd) { + return; + } + self.status_line_git_summary_cwd = Some(cwd.to_path_buf()); + self.status_line_git_summary = None; + self.status_line_git_summary_pending = false; + self.status_line_git_summary_lookup_complete = false; + } + /// Starts an async git-branch lookup unless one is already running. /// /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject @@ -481,14 +528,34 @@ impl ChatWidget { if self.status_line_branch_pending { return; } + let Some(runner) = self.workspace_command_runner.clone() else { + self.status_line_branch_lookup_complete = true; + return; + }; self.status_line_branch_pending = true; let tx = self.app_event_tx.clone(); tokio::spawn(async move { - let branch = current_branch_name(&cwd).await; + let branch = branch_summary::current_branch_name(runner.as_ref(), &cwd).await; tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); }); } + fn request_status_line_git_summary(&mut self, cwd: PathBuf) { + if self.status_line_git_summary_pending { + return; + } + let Some(runner) = self.workspace_command_runner.clone() else { + self.status_line_git_summary_lookup_complete = true; + return; + }; + self.status_line_git_summary_pending = true; + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let summary = branch_summary::status_line_git_summary(runner.as_ref(), &cwd).await; + tx.send(AppEvent::StatusLineGitSummaryUpdated { cwd, summary }); + }); + } + /// Resolves a display string for one configured status-line item. /// /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on @@ -506,6 +573,22 @@ impl ChatWidget { } StatusLineItem::ProjectRoot => self.status_line_project_root_name(), StatusLineItem::GitBranch => self.status_line_branch.clone(), + StatusLineItem::PullRequestNumber => self + .status_line_git_summary + .as_ref() + .and_then(|summary| summary.pull_request.as_ref()) + .map(|pull_request| format!("PR #{}", pull_request.number)), + StatusLineItem::BranchChanges => self + .status_line_git_summary + .as_ref() + .and_then(|summary| summary.branch_change_stats.as_ref()) + .map(|stats| { + if stats.additions == 0 && stats.deletions == 0 { + "No changes".to_string() + } else { + format!("+{} -{}", stats.additions, stats.deletions) + } + }), StatusLineItem::Status => Some(self.run_state_status_text()), StatusLineItem::UsedTokens => { let usage = self.status_line_total_usage(); @@ -572,6 +655,13 @@ impl ChatWidget { } } + fn status_line_pull_request_url(&self) -> Option { + self.status_line_git_summary + .as_ref() + .and_then(|summary| summary.pull_request.as_ref()) + .map(|pull_request| pull_request.url.clone()) + } + pub(super) fn status_surface_preview_value_for_item( &mut self, item: StatusSurfacePreviewItem, @@ -585,6 +675,8 @@ impl ChatWidget { StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir, StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle, StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch, + StatusSurfacePreviewItem::PullRequestNumber => StatusLineItem::PullRequestNumber, + StatusSurfacePreviewItem::BranchChanges => StatusLineItem::BranchChanges, StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining, StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed, StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 04f7e3d90..2631e5065 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -302,6 +302,7 @@ pub(super) async fn make_chatwidget_manual( feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, current_cwd: None, + workspace_command_runner: None, instruction_source_paths: Vec::new(), session_network_proxy: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -315,6 +316,10 @@ pub(super) async fn make_chatwidget_manual( status_line_branch_cwd: None, status_line_branch_pending: false, status_line_branch_lookup_complete: false, + status_line_git_summary: None, + status_line_git_summary_cwd: None, + status_line_git_summary_pending: false, + status_line_git_summary_lookup_complete: false, current_goal_status_indicator: None, current_goal_status: None, goal_status_active_turn_started_at: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index b6afdf0f7..97cc8d7fc 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1536,6 +1536,7 @@ async fn make_startup_chat_with_cli_overrides( config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), + workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index f575349dd..a2c4f11d5 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -72,6 +72,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), + workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 73f0d3b7a..440b7f9ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -131,6 +131,71 @@ async fn token_usage_update_uses_runtime_context_window() { "expected /status to avoid raw config context window, got: {context_line}" ); } + +#[tokio::test] +async fn status_line_git_summary_items_render_values() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_git_summary = Some(StatusLineGitSummary { + pull_request: Some(crate::branch_summary::StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + }), + branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats { + additions: 143, + deletions: 22, + }), + }); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::PullRequestNumber), + Some("PR #20252".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::BranchChanges), + Some("+143 -22".to_string()) + ); +} + +#[tokio::test] +async fn status_line_branch_changes_render_no_changes() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_git_summary = Some(StatusLineGitSummary { + pull_request: None, + branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats { + additions: 0, + deletions: 0, + }), + }); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::BranchChanges), + Some("No changes".to_string()) + ); +} + +#[tokio::test] +async fn stale_status_line_git_summary_update_is_ignored() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_git_summary_cwd = Some(PathBuf::from("/expected")); + chat.status_line_git_summary_pending = true; + + chat.set_status_line_git_summary( + PathBuf::from("/other"), + StatusLineGitSummary { + pull_request: Some(crate::branch_summary::StatusLinePullRequest { + number: 20_252, + url: "https://github.com/openai/codex/pull/20252".to_string(), + }), + branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats { + additions: 143, + deletions: 22, + }), + }, + ); + + assert!(chat.status_line_git_summary.is_none()); + assert!(!chat.status_line_git_summary_pending); +} #[tokio::test] async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); @@ -142,6 +207,7 @@ async fn helpers_are_available_and_do_not_panic() { config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, + workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, @@ -1310,6 +1376,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() { #[tokio::test] async fn status_line_branch_refreshes_after_turn_complete() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + install_noop_workspace_command_runner(&mut chat); chat.config.tui_status_line = Some(vec!["git-branch".to_string()]); chat.status_line_branch_lookup_complete = true; chat.status_line_branch_pending = false; @@ -1322,6 +1389,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { #[tokio::test] async fn status_line_branch_refreshes_after_interrupt() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + install_noop_workspace_command_runner(&mut chat); chat.config.tui_status_line = Some(vec!["git-branch".to_string()]); chat.status_line_branch_lookup_complete = true; chat.status_line_branch_pending = false; @@ -1331,6 +1399,37 @@ async fn status_line_branch_refreshes_after_interrupt() { assert!(chat.status_line_branch_pending); } +fn install_noop_workspace_command_runner(chat: &mut ChatWidget) { + chat.workspace_command_runner = Some(std::sync::Arc::new(NoopWorkspaceCommandRunner)); +} + +struct NoopWorkspaceCommandRunner; + +impl crate::workspace_command::WorkspaceCommandExecutor for NoopWorkspaceCommandRunner { + fn run( + &self, + _command: crate::workspace_command::WorkspaceCommand, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + crate::workspace_command::WorkspaceCommandOutput, + crate::workspace_command::WorkspaceCommandError, + >, + > + Send + + '_, + >, + > { + Box::pin(async { + Ok(crate::workspace_command::WorkspaceCommandOutput { + exit_code: 1, + stdout: String::new(), + stderr: String::new(), + }) + }) + } +} + #[tokio::test] async fn interrupted_turn_clears_visible_running_hook() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5128d492f..85aeca9bc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -103,6 +103,7 @@ mod audio_device { } } mod bottom_pane; +mod branch_summary; mod chatwidget; mod cli; mod clipboard_copy; @@ -188,6 +189,7 @@ mod version; #[cfg(not(target_os = "linux"))] mod voice; mod width; +mod workspace_command; #[cfg(target_os = "linux")] #[allow(dead_code)] mod voice { diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 6f226b5cf..91362bf5a 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -61,6 +61,24 @@ use crate::tui::FrameRequester; /// row boundary, which breaks normal terminal URL detection for long URLs that /// wrap across multiple rows. pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { + mark_hyperlink_cells(buf, area, url, |cell| { + cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED) + }); +} + +/// Marks any underlined buffer cells as an OSC 8 hyperlink. +pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { + mark_hyperlink_cells(buf, area, url, |cell| { + cell.modifier.contains(Modifier::UNDERLINED) + }); +} + +fn mark_hyperlink_cells( + buf: &mut Buffer, + area: Rect, + url: &str, + should_mark: impl Fn(&ratatui::buffer::Cell) -> bool, +) { // Sanitize: strip any characters that could break out of the OSC 8 // sequence (ESC or BEL) to prevent terminal escape injection from a // malformed or compromised upstream URL. @@ -75,8 +93,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { for y in area.top()..area.bottom() { for x in area.left()..area.right() { let cell = &mut buf[(x, y)]; - // Only mark cells that carry the URL's distinctive style. - if cell.fg != Color::Cyan || !cell.modifier.contains(Modifier::UNDERLINED) { + if !should_mark(cell) { continue; } let sym = cell.symbol().to_string(); diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index 63ebdc692..016d086c5 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -2,5 +2,6 @@ mod auth; mod keys; pub(crate) mod onboarding_screen; mod trust_directory; +pub(crate) use auth::mark_underlined_hyperlink; pub(crate) use auth::mark_url_hyperlink; mod welcome; diff --git a/codex-rs/tui/src/workspace_command.rs b/codex-rs/tui/src/workspace_command.rs new file mode 100644 index 000000000..f0267699a --- /dev/null +++ b/codex-rs/tui/src/workspace_command.rs @@ -0,0 +1,200 @@ +//! App-server-backed workspace command execution for TUI-owned background lookups. +//! +//! This module is the TUI boundary for short, non-interactive commands that need to run wherever +//! the active workspace lives. Callers describe a command in terms of argv, cwd, environment +//! overrides, timeout, and output cap; the runner translates that request to app-server +//! `command/exec`. Keeping this as a TUI-local abstraction lets status surfaces avoid knowing +//! whether the current app-server is embedded or remote. +//! +//! Commands sent through this path are best-effort metadata probes. They should not prompt for +//! stdin, should tolerate failure by omitting optional UI, and should keep output bounded so a +//! status-line refresh cannot grow into an unbounded background process. + +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::RequestId; +use uuid::Uuid; + +/// Shared handle for running workspace commands from TUI components. +pub(crate) type WorkspaceCommandRunner = Arc; + +/// Describes a bounded non-interactive command to execute in the active workspace. +/// +/// The command is intentionally argv-based rather than shell-based so callers do not need to quote +/// user or repository data. `cwd` is interpreted by app-server relative to the workspace rules for +/// the active session, which is what makes the same request shape work for embedded and remote +/// app-server instances. +#[derive(Clone, Debug)] +pub(crate) struct WorkspaceCommand { + /// Program and arguments to execute without shell interpolation. + pub(crate) argv: Vec, + /// Working directory for the command, if different from app-server's session cwd. + pub(crate) cwd: Option, + /// Environment overrides where `None` removes a variable. + pub(crate) env: HashMap>, + /// Maximum wall-clock duration before app-server cancels the command. + pub(crate) timeout: Duration, + /// Maximum captured stdout/stderr bytes returned by app-server. + pub(crate) output_bytes_cap: usize, +} + +impl WorkspaceCommand { + /// Creates a workspace command with conservative defaults for status-style metadata probes. + pub(crate) fn new(argv: impl IntoIterator>) -> Self { + Self { + argv: argv.into_iter().map(Into::into).collect(), + cwd: None, + env: HashMap::new(), + timeout: Duration::from_secs(5), + output_bytes_cap: 64 * 1024, + } + } + + /// Sets the command working directory. + pub(crate) fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + /// Adds or replaces one environment variable override. + pub(crate) fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.insert(key.into(), Some(value.into())); + self + } +} + +/// Captured result from a completed workspace command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceCommandOutput { + /// Process exit status code reported by app-server. + pub(crate) exit_code: i32, + /// Captured stdout after app-server output capping. + pub(crate) stdout: String, + /// Captured stderr after app-server output capping. + pub(crate) stderr: String, +} + +impl WorkspaceCommandOutput { + /// Returns whether the process exited successfully. + pub(crate) fn success(&self) -> bool { + self.exit_code == 0 + } +} + +/// Transport or protocol failure before a command result was available. +/// +/// Non-zero process exits are represented as `WorkspaceCommandOutput` so callers can distinguish +/// a normal probe miss from an app-server request failure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceCommandError { + message: String, +} + +impl WorkspaceCommandError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl std::fmt::Display for WorkspaceCommandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for WorkspaceCommandError {} + +/// Executes non-interactive workspace commands through the active TUI app-server session. +/// +/// Implementations decide where the workspace lives. Callers provide argv/cwd/env and should not +/// branch on local versus remote execution. +pub(crate) trait WorkspaceCommandExecutor: Send + Sync { + /// Runs a workspace command and returns captured output or an app-server request error. + /// + /// Callers should treat errors as infrastructure failures and should treat successful output + /// with a non-zero exit code as ordinary command failure. Returning a future instead of using + /// `async_trait` keeps the trait object-safe while matching the repo's native async trait + /// conventions. + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box> + Send + '_>, + >; +} + +/// Workspace command runner that forwards every request to the active app-server. +#[derive(Clone)] +pub(crate) struct AppServerWorkspaceCommandRunner { + request_handle: AppServerRequestHandle, +} + +impl AppServerWorkspaceCommandRunner { + /// Creates a runner from an app-server request handle owned by the current TUI session. + pub(crate) fn new(request_handle: AppServerRequestHandle) -> Self { + Self { request_handle } + } +} + +impl WorkspaceCommandExecutor for AppServerWorkspaceCommandRunner { + /// Sends the command as a one-off app-server `command/exec` request. + /// + /// The request is non-tty, does not stream stdin/stdout/stderr, and uses the caller's timeout + /// and output cap. It leaves sandbox and permission profile selection to app-server so the same + /// runner follows the active session's embedded or remote execution policy. + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box> + Send + '_>, + > { + Box::pin(async move { + let timeout_ms = i64::try_from(command.timeout.as_millis()).unwrap_or(i64::MAX); + let env = if command.env.is_empty() { + None + } else { + Some(command.env) + }; + let response: CommandExecResponse = self + .request_handle + .request_typed(ClientRequest::OneOffCommandExec { + request_id: RequestId::String(format!("workspace-command-{}", Uuid::new_v4())), + params: CommandExecParams { + command: command.argv, + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(command.output_bytes_cap), + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(timeout_ms), + cwd: command.cwd, + env, + size: None, + sandbox_policy: None, + permission_profile: None, + }, + }) + .await + .map_err(|err| WorkspaceCommandError::new(err.to_string()))?; + + Ok(WorkspaceCommandOutput { + exit_code: response.exit_code, + stdout: response.stdout, + stderr: response.stderr, + }) + }) + } +}