mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
c2fed01550
commit
cc16995cc6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user