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
parent c2fed01550
commit cc16995cc6
22 changed files with 1351 additions and 10 deletions
@@ -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,