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
+11
View File
@@ -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<WorkspaceCommandRunner>,
/// Config is stored here so we can recreate ChatWidgets as needed.
pub(crate) config: Config,
pub(crate) state_db: Option<StateDbHandle>,
@@ -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,
+4
View File
@@ -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();
}
+1
View File
@@ -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,
+4
View File
@@ -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(),
+5
View File
@@ -827,6 +827,11 @@ pub(crate) enum AppEvent {
cwd: PathBuf,
branch: Option<String>,
},
/// 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<StatusLineItem>,
@@ -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<Line<'static>>,
status_line_hyperlink_url: Option<String>,
status_line_enabled: bool,
side_conversation_context_label: Option<String>,
// 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<String>) -> 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::<AppEvent>();
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;
+6
View File
@@ -1544,6 +1544,12 @@ impl BottomPane {
}
}
pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) {
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();
@@ -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
@@ -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::<StatusLineItem>(),
Ok(StatusLineItem::PullRequestNumber)
);
assert_eq!(
"branch-changes".parse::<StatusLineItem>(),
Ok(StatusLineItem::BranchChanges)
);
}
#[test]
fn parse_status_line_items_accepts_title_only_variants() {
let items = ["run-state", "task-progress"]
@@ -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!(
@@ -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,
+739
View File
@@ -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<StatusLinePullRequest>,
/// Additions and deletions between `HEAD` and the repository default branch merge base.
pub(crate) branch_change_stats: Option<GitBranchDiffStats>,
}
/// 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<String>,
parent: Option<GhRepoParent>,
}
#[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<String> {
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<GitBranchDiffStats> {
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<Vec<String>> {
let output = run_git_command(runner, cwd, &["remote"]).await.ok()?;
if !output.success() {
return None;
}
let mut remotes: Vec<String> = 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<DefaultBranch> {
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<DefaultBranch> {
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/<remote>/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<DefaultBranch> {
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<DefaultBranch> {
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<StatusLinePullRequest> {
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<StatusLinePullRequest> {
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<StatusLinePullRequest> {
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<String> {
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<Vec<String>> {
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<StatusLinePullRequest> {
let pull_request = serde_json::from_str::<GhPullRequestView>(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<StatusLinePullRequest> {
serde_json::from_str::<Vec<GhPullRequestApiItem>>(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<Vec<String>> {
let repo = serde_json::from_str::<GhRepoView>(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<WorkspaceCommandOutput, crate::workspace_command::WorkspaceCommandError> {
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<WorkspaceCommandOutput, crate::workspace_command::WorkspaceCommandError> {
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<String>,
output: WorkspaceCommandOutput,
}
struct FakeRunner {
responses: Mutex<VecDeque<FakeResponse>>,
seen: Mutex<Vec<Vec<String>>>,
}
impl FakeRunner {
fn new(responses: Vec<FakeResponse>) -> Self {
Self {
responses: Mutex::new(responses.into()),
seen: Mutex::new(Vec::new()),
}
}
fn saw(&self, argv: &[&str]) -> bool {
let argv: Vec<String> = 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<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>>
+ 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)
})
}
}
}
+46
View File
@@ -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<WorkspaceCommandRunner>,
pub(crate) initial_user_message: Option<UserMessage>,
pub(crate) enhanced_keys_supported: bool,
pub(crate) has_chatgpt_account: bool,
@@ -972,6 +979,8 @@ pub(crate) struct ChatWidget {
current_rollout_path: Option<PathBuf>,
// Current working directory (if known)
current_cwd: Option<PathBuf>,
// App-server-backed command runner for status-line workspace metadata lookups.
workspace_command_runner: Option<WorkspaceCommandRunner>,
// Instruction source files loaded for the current session, supplied by app-server.
instruction_source_paths: Vec<AbsolutePathBuf>,
// 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<StatusLineGitSummary>,
// CWD used to resolve the cached Git summary; change resets summary state.
status_line_git_summary_cwd: Option<PathBuf>,
// 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<GoalStatusIndicator>,
current_goal_status: Option<GoalStatusState>,
@@ -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<String>) {
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,
+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;
+2
View File
@@ -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 {
+19 -2
View File
@@ -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();
+1
View File
@@ -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;
+200
View File
@@ -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<dyn WorkspaceCommandExecutor>;
/// 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<String>,
/// Working directory for the command, if different from app-server's session cwd.
pub(crate) cwd: Option<PathBuf>,
/// Environment overrides where `None` removes a variable.
pub(crate) env: HashMap<String, Option<String>>,
/// 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<Item = impl Into<String>>) -> 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<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
/// Adds or replaces one environment variable override.
pub(crate) fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> 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<String>) -> 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<dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>> + 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<dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>> + 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,
})
})
}
}