Compare commits

...

1 Commits

Author SHA1 Message Date
Felipe Coury
d5d9e614a1 feat(tui): add github pr status surfaces
Add `github-pr` as a selectable `/statusline` and `/title` item
when `gh` is available. Resolve the current branch PR asynchronously
with a short timeout and omit the value when unavailable.

Render status line PRs as underlined OSC 8 links so terminals with
hyperlink support can open the current branch PR directly.
2026-04-11 13:09:50 -03:00
16 changed files with 636 additions and 81 deletions

View File

@@ -5593,6 +5593,11 @@ impl App {
self.chat_widget.set_status_line_branch(cwd, branch);
self.refresh_status_line();
}
AppEvent::GithubPullRequestUpdated { cwd, pull_request } => {
self.chat_widget
.set_status_line_github_pr(cwd, pull_request);
self.refresh_status_line();
}
AppEvent::StatusLineSetupCancelled => {
self.chat_widget.cancel_status_line_setup();
}

View File

@@ -29,6 +29,7 @@ use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::github_pr::GithubPullRequest;
use crate::history_cell::HistoryCell;
use codex_config::types::ApprovalsReviewer;
@@ -579,6 +580,11 @@ pub(crate) enum AppEvent {
cwd: PathBuf,
branch: Option<String>,
},
/// Async update of the current branch's GitHub PR for status surfaces.
GithubPullRequestUpdated {
cwd: PathBuf,
pull_request: Option<GithubPullRequest>,
},
/// Apply a user-confirmed status-line item ordering/selection.
StatusLineSetup {
items: Vec<StatusLineItem>,

View File

@@ -118,7 +118,6 @@ use crate::bottom_pane::footer::mode_indicator_line;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::has_ctrl_or_alt;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::ui_consts::FOOTER_INDENT_COLS;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -146,6 +145,7 @@ use super::file_search_popup::FileSearchPopup;
use super::footer::CollaborationModeIndicator;
use super::footer::FooterMode;
use super::footer::FooterProps;
use super::footer::StatusLine;
use super::footer::SummaryLeft;
use super::footer::can_show_left_with_context;
use super::footer::context_window_line;
@@ -160,6 +160,7 @@ use super::footer::render_context_right;
use super::footer::render_footer_from_props;
use super::footer::render_footer_hint_items;
use super::footer::render_footer_line;
use super::footer::render_status_line;
use super::footer::reset_mode_after_activity;
use super::footer::single_line_footer_layout;
use super::footer::toggle_shortcut_mode;
@@ -334,7 +335,7 @@ pub(crate) struct ChatComposer {
audio_device_selection_enabled: bool,
windows_degraded_sandbox_active: bool,
is_zellij: bool,
status_line_value: Option<Line<'static>>,
status_line_value: Option<StatusLine>,
status_line_enabled: bool,
// Agent label injected into the footer's contextual row when multi-agent mode is active.
active_agent_label: Option<String>,
@@ -1037,8 +1038,10 @@ impl ChatComposer {
#[cfg(test)]
pub(crate) fn status_line_text(&self) -> Option<String> {
self.status_line_value.as_ref().map(|line| {
line.spans
self.status_line_value.as_ref().map(|status_line| {
status_line
.line()
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
@@ -3309,7 +3312,7 @@ impl ChatComposer {
}
}
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) -> bool {
pub(crate) fn set_status_line(&mut self, status_line: Option<StatusLine>) -> bool {
if self.status_line_value == status_line {
return false;
}
@@ -3508,13 +3511,14 @@ impl ChatComposer {
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
let status_line_active = uses_passive_footer_status_layout(&footer_props);
let combined_status_line = if status_line_active {
passive_footer_status_line(&footer_props).map(ratatui::prelude::Stylize::dim)
passive_footer_status_line(&footer_props).map(StatusLine::into_dimmed_line)
} else {
None
};
let mut truncated_status_line = if status_line_active {
combined_status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
line.clone()
.truncate_with_ellipsis_if_overflow(available_width)
})
} else {
None
@@ -3569,7 +3573,8 @@ impl ChatComposer {
&& let Some(max_left) = max_left_width_for_right(hint_rect, right_width)
&& left_width > max_left
&& let Some(line) = combined_status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize)
line.clone()
.truncate_with_ellipsis_if_overflow(max_left as usize)
})
{
left_width = line.width() as u16;
@@ -3621,7 +3626,7 @@ impl ChatComposer {
SummaryLeft::Default => {
if status_line_active {
if let Some(line) = truncated_status_line.clone() {
render_footer_line(hint_rect, buf, line);
render_status_line(hint_rect, buf, line);
} else {
render_footer_from_props(
hint_rect,
@@ -3658,7 +3663,7 @@ impl ChatComposer {
render_footer_hint_items(hint_rect, buf, items);
} else if status_line_active {
if let Some(line) = truncated_status_line {
render_footer_line(hint_rect, buf, line);
render_status_line(hint_rect, buf, line);
}
} else {
render_footer_from_props(

View File

@@ -41,8 +41,10 @@
//! In short: `single_line_footer_layout` chooses *what* best fits, and the two
//! render helpers choose whether to draw the chosen line or the default
//! `FooterProps` mapping.
use crate::hyperlink::mark_underlined_hyperlink;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::line_utils::prefix_lines;
use crate::status::format_tokens_compact;
use crate::ui_consts::FOOTER_INDENT_COLS;
@@ -55,6 +57,55 @@ use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct StatusLine {
line: Line<'static>,
hyperlink_url: Option<String>,
}
impl StatusLine {
pub(crate) fn new(line: Line<'static>) -> Self {
Self {
line,
hyperlink_url: None,
}
}
pub(crate) fn with_hyperlink(mut self, url: String) -> Self {
self.hyperlink_url = Some(url);
self
}
#[cfg(test)]
pub(crate) fn line(&self) -> &Line<'static> {
&self.line
}
pub(crate) fn into_dimmed_line(self) -> Self {
Self {
line: self.line.dim(),
hyperlink_url: self.hyperlink_url,
}
}
pub(crate) fn truncate_with_ellipsis_if_overflow(self, max_width: usize) -> Self {
Self {
line: truncate_line_with_ellipsis_if_overflow(self.line, max_width),
hyperlink_url: self.hyperlink_url,
}
}
pub(crate) fn width(&self) -> usize {
self.line.width()
}
fn mark_hyperlink(&self, buf: &mut Buffer, area: Rect) {
if let Some(url) = self.hyperlink_url.as_deref() {
mark_underlined_hyperlink(buf, area, url);
}
}
}
/// The rendering inputs for the footer area under the composer.
///
/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`,
@@ -76,7 +127,7 @@ pub(crate) struct FooterProps {
pub(crate) quit_shortcut_key: KeyBinding,
pub(crate) context_window_percent: Option<i64>,
pub(crate) context_window_used_tokens: Option<i64>,
pub(crate) status_line_value: Option<Line<'static>>,
pub(crate) status_line_value: Option<StatusLine>,
pub(crate) status_line_enabled: bool,
/// Active thread label shown when the footer is rendering contextual information instead of an
/// instructional hint.
@@ -219,6 +270,12 @@ pub(crate) fn render_footer_line(area: Rect, buf: &mut Buffer, line: Line<'stati
.render(area, buf);
}
pub(crate) fn render_status_line(area: Rect, buf: &mut Buffer, status_line: StatusLine) {
let line = status_line.line.clone();
render_footer_line(area, buf, line);
status_line.mark_hyperlink(buf, area);
}
/// Render footer content directly from `FooterProps`.
///
/// This is intentionally not part of the width-based collapse/fallback logic.
@@ -587,7 +644,7 @@ fn footer_from_props_lines(
// Passive footer context can come from the configurable status line, the
// active agent label, or both combined.
if let Some(status_line) = passive_footer_status_line(props) {
return vec![status_line.dim()];
return vec![status_line.into_dimmed_line().line];
}
match props.mode {
FooterMode::QuitShortcutReminder => {
@@ -635,7 +692,7 @@ fn footer_from_props_lines(
/// The returned line may contain the configured status line, the currently viewed agent label, or
/// both combined. Active instructional states such as quit reminders, shortcut overlays, and queue
/// prompts deliberately return `None` so those call-to-action hints stay visible.
pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'static>> {
pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<StatusLine> {
if !shows_passive_footer_line(props) {
return None;
}
@@ -648,10 +705,10 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'st
if let Some(active_agent_label) = props.active_agent_label.as_ref() {
if let Some(existing) = line.as_mut() {
existing.spans.push(" · ".into());
existing.spans.push(active_agent_label.clone().into());
existing.line.spans.push(" · ".into());
existing.line.spans.push(active_agent_label.clone().into());
} else {
line = Some(Line::from(active_agent_label.clone()));
line = Some(StatusLine::new(Line::from(active_agent_label.clone())));
}
}
@@ -1059,7 +1116,6 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
#[cfg(test)]
mod tests {
use super::*;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::test_backend::VT100Backend;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
@@ -1116,8 +1172,8 @@ mod tests {
) {
passive_status_line
.as_ref()
.map(|line| line.clone().dim())
.map(|line| truncate_line_with_ellipsis_if_overflow(line, available_width))
.map(|line| line.clone().into_dimmed_line())
.map(|line| line.truncate_with_ellipsis_if_overflow(available_width))
} else {
None
};
@@ -1162,10 +1218,8 @@ mod tests {
&& left_width > max_left
&& let Some(line) = passive_status_line
.as_ref()
.map(|line| line.clone().dim())
.map(|line| {
truncate_line_with_ellipsis_if_overflow(line, max_left as usize)
})
.map(|line| line.clone().into_dimmed_line())
.map(|line| line.truncate_with_ellipsis_if_overflow(max_left as usize))
{
left_width = line.width() as u16;
truncated_status_line = Some(line);
@@ -1177,8 +1231,8 @@ mod tests {
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
) {
if status_line_active {
if let Some(line) = truncated_status_line.clone() {
render_footer_line(area, f.buffer_mut(), line);
if let Some(line) = truncated_status_line {
render_status_line(area, f.buffer_mut(), line);
}
if can_show_left_and_context && let Some(line) = &right_line {
render_context_right(area, f.buffer_mut(), line);
@@ -1261,6 +1315,21 @@ mod tests {
terminal.backend().vt100().screen().contents()
}
#[test]
fn status_line_marks_underlined_segment_as_hyperlink() {
let area = Rect::new(0, 0, 24, 1);
let mut buf = Buffer::empty(area);
let status_line = StatusLine::new(Line::from("PR #123".underlined()))
.with_hyperlink("https://github.com/o/r/pull/123".to_string());
render_status_line(area, &mut buf, status_line);
let rendered = (0..area.width)
.map(|col| buf[(col, 0)].symbol())
.collect::<String>();
assert!(rendered.contains("\x1B]8;;https://github.com/o/r/pull/123\x07P"));
}
#[test]
fn footer_snapshots() {
snapshot_footer(
@@ -1504,7 +1573,9 @@ mod tests {
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_value: Some(StatusLine::new(Line::from(
"Status line content".to_string(),
))),
status_line_enabled: true,
active_agent_label: None,
};
@@ -1521,7 +1592,9 @@ mod tests {
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_value: Some(StatusLine::new(Line::from(
"Status line content".to_string(),
))),
status_line_enabled: true,
active_agent_label: None,
};
@@ -1538,7 +1611,9 @@ mod tests {
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_value: Some(StatusLine::new(Line::from(
"Status line content".to_string(),
))),
status_line_enabled: true,
active_agent_label: None,
};
@@ -1622,9 +1697,9 @@ mod tests {
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: Some(Line::from(
status_line_value: Some(StatusLine::new(Line::from(
"Status line content that should truncate before the mode indicator".to_string(),
)),
))),
status_line_enabled: true,
active_agent_label: None,
};
@@ -1663,7 +1738,9 @@ mod tests {
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: None,
context_window_used_tokens: None,
status_line_value: Some(Line::from("Status line content".to_string())),
status_line_value: Some(StatusLine::new(Line::from(
"Status line content".to_string(),
))),
status_line_enabled: true,
active_agent_label: Some("Robie [explorer]".to_string()),
};
@@ -1683,10 +1760,10 @@ mod tests {
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
context_window_percent: Some(50),
context_window_used_tokens: None,
status_line_value: Some(Line::from(
status_line_value: Some(StatusLine::new(Line::from(
"Status line content that is definitely too long to fit alongside the mode label"
.to_string(),
)),
))),
status_line_enabled: true,
active_agent_label: None,
};

View File

@@ -38,7 +38,6 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::time::Duration;
mod app_link_view;
@@ -86,6 +85,7 @@ mod skill_popup;
mod skills_toggle_view;
mod slash_commands;
pub(crate) use footer::CollaborationModeIndicator;
pub(crate) use footer::StatusLine;
pub(crate) use list_selection_view::ColumnWidthMode;
pub(crate) use list_selection_view::SelectionViewParams;
pub(crate) use list_selection_view::SideContentWidth;
@@ -1164,7 +1164,7 @@ impl BottomPane {
}
}
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) {
pub(crate) fn set_status_line(&mut self, status_line: Option<StatusLine>) {
if self.composer.set_status_line(status_line) {
self.request_redraw();
}

View File

@@ -13,9 +13,9 @@ expression: "render_lines(&view, 72)"
[x] git-branch Current Git branch (omitted when unavaila…
[ ] model-with-reasoning Current model name with reasoning level
[ ] project-root Project root directory (omitted when unav…
[ ] github-pr Current branch's GitHub PR (omitted when …
[ ] context-usage Visual meter of context window usage (omi…
[ ] five-hour-limit Remaining usage on 5-hour usage limit (om…
[ ] weekly-limit Remaining usage on weekly usage limit (om…
gpt-5-codex · ~/codex-rs · jif/statusline-preview
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc

View File

@@ -8,14 +8,14 @@ expression: "render_lines(&view, 84)"
Type to search
>
[x] project Project name (falls back to current directory name)
[x] spinner Animated task spinner (omitted while idle or when animations…
[x] status Compact session status text (Ready, Working, Thinking)
[x] thread Current thread title (omitted until available)
[ ] app-name Codex app name
[ ] git-branch Current Git branch (omitted when unavailable)
[ ] model Current model name
[ ] task-progress Latest task progress from update_plan (omitted until availab…
[x] project Project name (falls back to current directory name)
[x] spinner Animated task spinner (omitted while idle or when animations ar
[x] status Compact session status text (Ready, Working, Thinking)
[x] thread Current thread title (omitted until available)
[ ] app-name Codex app name
[ ] git-branch Current Git branch (omitted when unavailable)
[ ] github-pr Current branch's GitHub PR (omitted when unavailable)
[ ] model Current model name
my-project ⠋ Working | Investigate flaky test
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.

View File

@@ -62,6 +62,9 @@ pub(crate) enum StatusLineItem {
/// Current git branch name (if in a repository).
GitBranch,
/// Current branch's GitHub pull request (if available from gh).
GithubPr,
/// Visual meter of context window usage.
///
/// Also accepts legacy `context-remaining` and `context-used` config values.
@@ -112,6 +115,7 @@ impl StatusLineItem {
StatusLineItem::CurrentDir => "Current working directory",
StatusLineItem::ProjectRoot => "Project root directory (omitted when unavailable)",
StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)",
StatusLineItem::GithubPr => "Current branch's GitHub PR (omitted when unavailable)",
StatusLineItem::ContextUsage => {
"Visual meter of context window usage (omitted when unknown)"
}
@@ -137,24 +141,32 @@ impl StatusLineItem {
}
}
const SELECTABLE_STATUS_LINE_ITEMS: &[StatusLineItem] = &[
StatusLineItem::ModelName,
StatusLineItem::ModelWithReasoning,
StatusLineItem::CurrentDir,
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ContextUsage,
StatusLineItem::FiveHourLimit,
StatusLineItem::WeeklyLimit,
StatusLineItem::CodexVersion,
StatusLineItem::ContextWindowSize,
StatusLineItem::UsedTokens,
StatusLineItem::TotalInputTokens,
StatusLineItem::TotalOutputTokens,
StatusLineItem::SessionId,
StatusLineItem::FastMode,
StatusLineItem::ThreadTitle,
];
fn selectable_status_line_items(github_pr_available: bool) -> Vec<StatusLineItem> {
let mut items = vec![
StatusLineItem::ModelName,
StatusLineItem::ModelWithReasoning,
StatusLineItem::CurrentDir,
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
];
if github_pr_available {
items.push(StatusLineItem::GithubPr);
}
items.extend([
StatusLineItem::ContextUsage,
StatusLineItem::FiveHourLimit,
StatusLineItem::WeeklyLimit,
StatusLineItem::CodexVersion,
StatusLineItem::ContextWindowSize,
StatusLineItem::UsedTokens,
StatusLineItem::TotalInputTokens,
StatusLineItem::TotalOutputTokens,
StatusLineItem::SessionId,
StatusLineItem::FastMode,
StatusLineItem::ThreadTitle,
]);
items
}
/// Runtime values used to preview the current status-line selection.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
@@ -214,6 +226,7 @@ impl StatusLineSetupView {
pub(crate) fn new(
status_line_items: Option<&[String]>,
preview_data: StatusLinePreviewData,
github_pr_available: bool,
app_event_tx: AppEventSender,
) -> Self {
let mut used_ids = HashSet::new();
@@ -228,11 +241,13 @@ impl StatusLineSetupView {
if !used_ids.insert(item_id.clone()) {
continue;
}
items.push(Self::status_line_select_item(item, /*enabled*/ true));
if item != StatusLineItem::GithubPr || github_pr_available {
items.push(Self::status_line_select_item(item, /*enabled*/ true));
}
}
}
for item in SELECTABLE_STATUS_LINE_ITEMS.iter().cloned() {
for item in selectable_status_line_items(github_pr_available) {
let item_id = item.to_string();
if used_ids.contains(&item_id) {
continue;
@@ -430,12 +445,34 @@ mod tests {
),
(StatusLineItem::WeeklyLimit, "weekly 82%".to_string()),
]),
/*github_pr_available*/ true,
AppEventSender::new(tx_raw),
);
assert_snapshot!(render_lines(&view, /*width*/ 72));
}
#[test]
fn setup_view_hides_github_pr_when_gh_unavailable() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let view = StatusLineSetupView::new(
Some(&[
StatusLineItem::ModelName.to_string(),
StatusLineItem::GithubPr.to_string(),
]),
StatusLinePreviewData::from_iter([
(StatusLineItem::ModelName, "gpt-5-codex".to_string()),
(StatusLineItem::GithubPr, "PR #123".to_string()),
]),
/*github_pr_available*/ false,
AppEventSender::new(tx_raw),
);
let rendered = render_lines(&view, /*width*/ 72);
assert!(!rendered.contains("github-pr"));
assert!(!rendered.contains("PR #123"));
}
fn render_lines(view: &StatusLineSetupView, width: u16) -> String {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);

View File

@@ -44,6 +44,8 @@ pub(crate) enum TerminalTitleItem {
Thread,
/// Current git branch (if available).
GitBranch,
/// Current branch's GitHub pull request (if available from gh).
GithubPr,
/// Current model name.
Model,
/// Latest checklist task progress from `update_plan` (if available).
@@ -61,6 +63,7 @@ impl TerminalTitleItem {
TerminalTitleItem::Status => "Compact session status text (Ready, Working, Thinking)",
TerminalTitleItem::Thread => "Current thread title (omitted until available)",
TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)",
TerminalTitleItem::GithubPr => "Current branch's GitHub PR (omitted when unavailable)",
TerminalTitleItem::Model => "Current model name",
TerminalTitleItem::TaskProgress => {
"Latest task progress from update_plan (omitted until available)"
@@ -80,6 +83,7 @@ impl TerminalTitleItem {
TerminalTitleItem::Status => "Working",
TerminalTitleItem::Thread => "Investigate flaky test",
TerminalTitleItem::GitBranch => "feat/awesome-feature",
TerminalTitleItem::GithubPr => "PR #123",
TerminalTitleItem::Model => "gpt-5.2-codex",
TerminalTitleItem::TaskProgress => "Tasks 2/5",
}
@@ -103,6 +107,10 @@ impl TerminalTitleItem {
}
}
fn terminal_title_item_selectable(item: TerminalTitleItem, github_pr_available: bool) -> bool {
item != TerminalTitleItem::GithubPr || github_pr_available
}
fn parse_terminal_title_items<T>(ids: impl Iterator<Item = T>) -> Option<Vec<TerminalTitleItem>>
where
T: AsRef<str>,
@@ -128,11 +136,16 @@ impl TerminalTitleSetupView {
/// main TUI still warns about them when rendering the actual title, but the
/// picker itself only exposes the selectable items it can meaningfully
/// preview and persist.
pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self {
pub(crate) fn new(
title_items: Option<&[String]>,
github_pr_available: bool,
app_event_tx: AppEventSender,
) -> Self {
let selected_items = title_items
.into_iter()
.flatten()
.filter_map(|id| id.parse::<TerminalTitleItem>().ok())
.filter(|item| terminal_title_item_selectable(*item, github_pr_available))
.unique()
.collect_vec();
let selected_set = selected_items
@@ -144,6 +157,7 @@ impl TerminalTitleSetupView {
.map(|item| Self::title_select_item(item, /*enabled*/ true))
.chain(
TerminalTitleItem::iter()
.filter(|item| terminal_title_item_selectable(*item, github_pr_available))
.filter(|item| !selected_set.contains(item))
.map(|item| Self::title_select_item(item, /*enabled*/ false)),
)
@@ -280,13 +294,27 @@ mod tests {
"status".to_string(),
"thread".to_string(),
];
let view = TerminalTitleSetupView::new(Some(&selected), tx);
let view =
TerminalTitleSetupView::new(Some(&selected), /*github_pr_available*/ true, tx);
assert_snapshot!(
"terminal_title_setup_basic",
render_lines(&view, /*width*/ 84)
);
}
#[test]
fn hides_github_pr_when_gh_unavailable() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let selected = ["project".to_string(), "github-pr".to_string()];
let view =
TerminalTitleSetupView::new(Some(&selected), /*github_pr_available*/ false, tx);
let rendered = render_lines(&view, /*width*/ 84);
assert!(!rendered.contains("github-pr"));
assert!(!rendered.contains("PR #123"));
}
#[test]
fn parse_terminal_title_items_preserves_order() {
let items =
@@ -310,12 +338,13 @@ mod tests {
#[test]
fn parse_terminal_title_items_accepts_kebab_case_variants() {
let items = parse_terminal_title_items(["app-name", "git-branch"].into_iter());
let items = parse_terminal_title_items(["app-name", "git-branch", "github-pr"].into_iter());
assert_eq!(
items,
Some(vec![
TerminalTitleItem::AppName,
TerminalTitleItem::GitBranch,
TerminalTitleItem::GithubPr,
])
);
}

View File

@@ -47,11 +47,13 @@ use crate::app_server_approval_conversions::network_approval_context_to_core;
use crate::app_server_session::ThreadSessionState;
#[cfg(not(target_os = "linux"))]
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLine;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLinePreviewData;
use crate::bottom_pane::StatusLineSetupView;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::github_pr::GithubPullRequest;
use crate::legacy_core::DEFAULT_PROJECT_DOC_FILENAME;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::Constrained;
@@ -965,6 +967,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 GitHub PR for the current branch (None if unknown or unavailable).
status_line_github_pr: Option<GithubPullRequest>,
// CWD used to resolve the cached GitHub PR; change resets PR state.
status_line_github_pr_cwd: Option<PathBuf>,
// True while an async GitHub PR lookup is in flight.
status_line_github_pr_pending: bool,
// True once we've attempted a GitHub PR lookup for the current CWD.
status_line_github_pr_lookup_complete: bool,
external_editor_state: ExternalEditorState,
realtime_conversation: RealtimeConversationUiState,
last_rendered_user_message_event: Option<RenderedUserMessageEvent>,
@@ -1813,7 +1823,7 @@ impl ChatWidget {
}
/// Sets the currently rendered footer status-line value.
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) {
pub(crate) fn set_status_line(&mut self, status_line: Option<StatusLine>) {
self.bottom_pane.set_status_line(status_line);
}
@@ -1912,6 +1922,21 @@ impl ChatWidget {
self.refresh_status_surfaces();
}
pub(crate) fn set_status_line_github_pr(
&mut self,
cwd: PathBuf,
pull_request: Option<GithubPullRequest>,
) {
if self.status_line_github_pr_cwd.as_ref() != Some(&cwd) {
self.status_line_github_pr_pending = false;
return;
}
self.status_line_github_pr = pull_request;
self.status_line_github_pr_pending = false;
self.status_line_github_pr_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);
@@ -4871,6 +4896,10 @@ impl ChatWidget {
status_line_branch_cwd: None,
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
status_line_github_pr: None,
status_line_github_pr_cwd: None,
status_line_github_pr_pending: false,
status_line_github_pr_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_event: None,
@@ -7611,12 +7640,14 @@ impl ChatWidget {
fn open_status_line_setup(&mut self) {
let configured_status_line_items = self.configured_status_line_items();
let gh_available = crate::github_pr::gh_available();
let view = StatusLineSetupView::new(
Some(configured_status_line_items.as_slice()),
StatusLinePreviewData::from_iter(StatusLineItem::iter().filter_map(|item| {
self.status_line_value_for_item(&item)
.map(|value| (item, value))
})),
gh_available,
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));
@@ -7627,6 +7658,7 @@ impl ChatWidget {
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
let view = TerminalTitleSetupView::new(
Some(configured_terminal_title_items.as_slice()),
crate::github_pr::gh_available(),
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));

View File

@@ -51,6 +51,27 @@ impl StatusSurfaceSelections {
.terminal_title_items
.contains(&TerminalTitleItem::GitBranch)
}
fn uses_github_pr(&self) -> bool {
self.status_line_items.contains(&StatusLineItem::GithubPr)
|| self
.terminal_title_items
.contains(&TerminalTitleItem::GithubPr)
}
}
struct StatusLineSegment {
line: Line<'static>,
hyperlink_url: Option<String>,
}
impl StatusLineSegment {
fn text(value: String) -> Self {
Self {
line: Line::from(value),
hyperlink_url: None,
}
}
}
/// Cached project-root display name keyed by the cwd used for the last lookup.
@@ -124,13 +145,26 @@ impl ChatWidget {
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
} 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 uses_github_pr = selections.uses_github_pr();
if !uses_github_pr || !crate::github_pr::gh_available() {
self.status_line_github_pr = None;
self.status_line_github_pr_pending = false;
self.status_line_github_pr_lookup_complete = false;
return;
}
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);
self.sync_status_line_github_pr_state(&cwd);
if !self.status_line_github_pr_lookup_complete {
self.request_status_line_github_pr(cwd);
}
}
@@ -142,19 +176,30 @@ impl ChatWidget {
return;
}
let mut parts = Vec::new();
let mut spans = Vec::new();
let mut hyperlink_url = None;
for item in &selections.status_line_items {
if let Some(value) = self.status_line_value_for_item(item) {
parts.push(value);
if let Some(segment) = self.status_line_segment_for_item(item) {
if !spans.is_empty() {
spans.push(" · ".into());
}
if hyperlink_url.is_none() {
hyperlink_url.clone_from(&segment.hyperlink_url);
}
spans.extend(segment.line.spans);
}
}
let line = if parts.is_empty() {
let status_line = if spans.is_empty() {
None
} else {
Some(Line::from(parts.join(" · ")))
let status_line = StatusLine::new(Line::from(spans));
Some(match hyperlink_url {
Some(url) => status_line.with_hyperlink(url),
None => status_line,
})
};
self.set_status_line(line);
self.set_status_line(status_line);
}
/// Clears the terminal title Codex most recently wrote, if any.
@@ -264,12 +309,18 @@ impl ChatWidget {
pub(super) fn request_status_line_branch_refresh(&mut self) {
let selections = self.status_surface_selections();
if !selections.uses_git_branch() {
if !selections.uses_git_branch() && !selections.uses_github_pr() {
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
self.request_status_line_branch(cwd);
if selections.uses_git_branch() {
self.sync_status_line_branch_state(&cwd);
self.request_status_line_branch(cwd.clone());
}
if selections.uses_github_pr() && crate::github_pr::gh_available() {
self.sync_status_line_github_pr_state(&cwd);
self.request_status_line_github_pr(cwd);
}
}
/// Parses configured status-line ids into known items and collects unknown ids.
@@ -397,6 +448,20 @@ impl ChatWidget {
self.status_line_branch_lookup_complete = false;
}
fn sync_status_line_github_pr_state(&mut self, cwd: &Path) {
if self
.status_line_github_pr_cwd
.as_ref()
.is_some_and(|path| path == cwd)
{
return;
}
self.status_line_github_pr_cwd = Some(cwd.to_path_buf());
self.status_line_github_pr = None;
self.status_line_github_pr_pending = false;
self.status_line_github_pr_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
@@ -413,6 +478,18 @@ impl ChatWidget {
});
}
fn request_status_line_github_pr(&mut self, cwd: PathBuf) {
if self.status_line_github_pr_pending {
return;
}
self.status_line_github_pr_pending = true;
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let pull_request = crate::github_pr::lookup_current_branch_pull_request(&cwd).await;
tx.send(AppEvent::GithubPullRequestUpdated { cwd, pull_request });
});
}
/// Resolves a display string for one configured status-line item.
///
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
@@ -441,6 +518,10 @@ impl ChatWidget {
}
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
StatusLineItem::GitBranch => self.status_line_branch.clone(),
StatusLineItem::GithubPr => self
.status_line_github_pr
.as_ref()
.map(GithubPullRequest::label),
StatusLineItem::UsedTokens => {
let usage = self.status_line_total_usage();
let total = usage.tokens_in_context_window();
@@ -502,11 +583,27 @@ impl ChatWidget {
}
}
fn status_line_segment_for_item(&mut self, item: &StatusLineItem) -> Option<StatusLineSegment> {
match item {
StatusLineItem::GithubPr => {
self.status_line_github_pr
.as_ref()
.map(|pr| StatusLineSegment {
line: Line::from(pr.label().underlined()),
hyperlink_url: Some(pr.url.clone()),
})
}
_ => self
.status_line_value_for_item(item)
.map(StatusLineSegment::text),
}
}
/// Resolves one configured terminal-title item into a displayable segment.
///
/// Returning `None` means "omit this segment for now" so callers can keep
/// the configured order while hiding values that are not yet available.
fn terminal_title_value_for_item(
pub(super) fn terminal_title_value_for_item(
&mut self,
item: TerminalTitleItem,
now: Instant,
@@ -530,6 +627,9 @@ impl ChatWidget {
TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| {
Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32)
}),
TerminalTitleItem::GithubPr => self.status_line_github_pr.as_ref().map(|pr| {
Self::truncate_terminal_title_part(pr.label(), /*max_chars*/ 16)
}),
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
self.model_display_name().to_string(),
/*max_chars*/ 32,

View File

@@ -286,6 +286,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_github_pr: None,
status_line_github_pr_cwd: None,
status_line_github_pr_pending: false,
status_line_github_pr_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_event: None,

View File

@@ -911,6 +911,43 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() {
assert!(!chat.status_line_branch_lookup_complete);
}
#[tokio::test]
async fn github_pr_values_feed_status_line_and_terminal_title() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_github_pr = Some(GithubPullRequest {
number: 123,
url: "https://github.com/openai/codex/pull/123".to_string(),
});
assert_eq!(
chat.status_line_value_for_item(&StatusLineItem::GithubPr),
Some("PR #123".to_string())
);
assert_eq!(
chat.terminal_title_value_for_item(TerminalTitleItem::GithubPr, Instant::now()),
Some("PR #123".to_string())
);
}
#[tokio::test]
async fn github_pr_state_resets_when_no_surface_uses_it() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_github_pr = Some(GithubPullRequest {
number: 123,
url: "https://github.com/openai/codex/pull/123".to_string(),
});
chat.status_line_github_pr_pending = true;
chat.status_line_github_pr_lookup_complete = true;
chat.config.tui_status_line = Some(vec!["model_name".to_string()]);
chat.config.tui_terminal_title = Some(vec!["model".to_string()]);
chat.refresh_status_line();
assert_eq!(chat.status_line_github_pr, None);
assert!(!chat.status_line_github_pr_pending);
assert!(!chat.status_line_github_pr_lookup_complete);
}
#[tokio::test]
async fn status_line_branch_refreshes_after_turn_complete() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -0,0 +1,160 @@
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use serde::Deserialize;
use tokio::process::Command;
use tokio::time::Duration;
use tokio::time::timeout;
const GH_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GithubPullRequest {
pub(crate) number: u64,
pub(crate) url: String,
}
impl GithubPullRequest {
pub(crate) fn label(&self) -> String {
format!("PR #{}", self.number)
}
}
pub(crate) fn gh_available() -> bool {
resolve_gh_path().is_some()
}
pub(crate) async fn lookup_current_branch_pull_request(cwd: &Path) -> Option<GithubPullRequest> {
let gh_path = resolve_gh_path()?;
let mut command = Command::new(&gh_path);
command
.args(["pr", "view", "--json", "number,url"])
.current_dir(cwd)
.stdin(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let output = match timeout(GH_LOOKUP_TIMEOUT, command.output()).await {
Ok(Ok(output)) => output,
Ok(Err(_)) | Err(_) => return None,
};
if !output.status.success() {
return None;
}
parse_gh_pr_view_json(&output.stdout)
}
fn resolve_gh_path() -> Option<PathBuf> {
resolve_gh_path_from_path(std::env::var_os("PATH").as_deref())
}
fn resolve_gh_path_from_path(path_env: Option<&OsStr>) -> Option<PathBuf> {
path_env
.into_iter()
.flat_map(std::env::split_paths)
.flat_map(|dir| gh_executable_names().map(move |name| dir.join(name)))
.find(|path| is_executable_file(path))
}
#[cfg(windows)]
fn gh_executable_names() -> impl Iterator<Item = &'static str> {
["gh.exe", "gh.cmd", "gh.bat", "gh"].into_iter()
}
#[cfg(not(windows))]
fn gh_executable_names() -> impl Iterator<Item = &'static str> {
["gh"].into_iter()
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}
fn parse_gh_pr_view_json(bytes: &[u8]) -> Option<GithubPullRequest> {
#[derive(Deserialize)]
struct GhPrView {
number: u64,
url: String,
}
let view = serde_json::from_slice::<GhPrView>(bytes).ok()?;
(!view.url.trim().is_empty()).then_some(GithubPullRequest {
number: view.number,
url: view.url,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_valid_pr_view_json() {
assert_eq!(
parse_gh_pr_view_json(br#"{"number":123,"url":"https://github.com/o/r/pull/123"}"#),
Some(GithubPullRequest {
number: 123,
url: "https://github.com/o/r/pull/123".to_string(),
})
);
}
#[test]
fn rejects_incomplete_pr_view_json() {
assert_eq!(
parse_gh_pr_view_json(br#"{"url":"https://example.com"}"#),
None
);
assert_eq!(parse_gh_pr_view_json(br#"{"number":123}"#), None);
assert_eq!(parse_gh_pr_view_json(br#"{"number":123,"url":""}"#), None);
assert_eq!(parse_gh_pr_view_json(b"not json"), None);
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = path.metadata().expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("set permissions");
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) {}
#[cfg(windows)]
fn gh_test_file_name() -> &'static str {
"gh.exe"
}
#[cfg(not(windows))]
fn gh_test_file_name() -> &'static str {
"gh"
}
#[test]
fn resolves_gh_from_path_env() {
let temp = tempfile::tempdir().expect("tempdir");
let gh = temp.path().join(gh_test_file_name());
std::fs::write(&gh, "").expect("write gh");
make_executable(&gh);
let resolved = resolve_gh_path_from_path(Some(temp.path().as_os_str()));
assert_eq!(resolved, Some(gh));
}
}

View File

@@ -0,0 +1,61 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
let safe_url = sanitize_osc8_url(url);
if safe_url.is_empty() {
return;
}
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let cell = &mut buf[(x, y)];
if !cell.modifier.contains(Modifier::UNDERLINED) {
continue;
}
let symbol = cell.symbol().to_string();
if symbol.trim().is_empty() {
continue;
}
cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{symbol}\x1B]8;;\x07"));
}
}
}
fn sanitize_osc8_url(url: &str) -> String {
url.chars()
.filter(|&ch| ch != '\x1B' && ch != '\x07')
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
#[test]
fn strips_osc8_control_characters_from_url() {
assert_eq!(
sanitize_osc8_url("https://example.com/\x1B]8;;\x07injected"),
"https://example.com/]8;;injected"
);
}
#[test]
fn marks_only_underlined_cells() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
buf[(0, 0)].set_symbol("A").set_style("".underlined().style);
buf[(1, 0)].set_symbol("B");
mark_underlined_hyperlink(&mut buf, Rect::new(0, 0, 2, 1), "https://example.com");
assert!(
buf[(0, 0)]
.symbol()
.contains("\x1B]8;;https://example.com\x07A")
);
assert_eq!(buf[(1, 0)].symbol(), "B");
}
}

View File

@@ -119,7 +119,9 @@ mod external_editor;
mod file_search;
mod frames;
mod get_git_diff;
mod github_pr;
mod history_cell;
mod hyperlink;
pub(crate) mod insert_history;
pub use insert_history::insert_history_lines;
mod key_hint;