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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user