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.

<img width="1257" height="261" alt="CleanShot 2026-05-03 at 20 44 15"
src="https://github.com/user-attachments/assets/10b4380b-c3e9-4729-9ee1-3f742068fa47"
/>

## 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
This commit is contained in:
Felipe Coury
2026-05-04 16:11:15 -03:00
committed by GitHub
Unverified
parent c2fed01550
commit cc16995cc6
22 changed files with 1351 additions and 10 deletions
+98 -6
View File
@@ -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<String> {
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,
@@ -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,
@@ -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::<AppEvent>().0),
workspace_command_runner: None,
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
@@ -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::<AppEvent>().0),
workspace_command_runner: None,
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
@@ -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::<AppEvent>();
@@ -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;