From d5d9e614a17ca5d44c5e324f16af329931c4d56c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 13:09:50 -0300 Subject: [PATCH] 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. --- codex-rs/tui/src/app.rs | 5 + codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 25 +-- codex-rs/tui/src/bottom_pane/footer.rs | 123 +++++++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 4 +- ..._snapshot_uses_runtime_preview_values.snap | 2 +- ...up__tests__terminal_title_setup_basic.snap | 16 +- .../tui/src/bottom_pane/status_line_setup.rs | 77 ++++++--- codex-rs/tui/src/bottom_pane/title_setup.rs | 35 +++- codex-rs/tui/src/chatwidget.rs | 34 +++- .../tui/src/chatwidget/status_surfaces.rs | 126 ++++++++++++-- codex-rs/tui/src/chatwidget/tests/helpers.rs | 4 + .../src/chatwidget/tests/status_and_layout.rs | 37 ++++ codex-rs/tui/src/github_pr.rs | 160 ++++++++++++++++++ codex-rs/tui/src/hyperlink.rs | 61 +++++++ codex-rs/tui/src/lib.rs | 2 + 16 files changed, 636 insertions(+), 81 deletions(-) create mode 100644 codex-rs/tui/src/github_pr.rs create mode 100644 codex-rs/tui/src/hyperlink.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dd0827822d..677f9fa38d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0e71e1a432..db6b8d4eaa 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, }, + /// Async update of the current branch's GitHub PR for status surfaces. + GithubPullRequestUpdated { + cwd: PathBuf, + pull_request: Option, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 403a499bd5..fb7d4987db 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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>, + status_line_value: Option, status_line_enabled: bool, // Agent label injected into the footer's contextual row when multi-agent mode is active. active_agent_label: Option, @@ -1037,8 +1038,10 @@ impl ChatComposer { #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option { - 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::() @@ -3309,7 +3312,7 @@ impl ChatComposer { } } - pub(crate) fn set_status_line(&mut self, status_line: Option>) -> bool { + pub(crate) fn set_status_line(&mut self, status_line: Option) -> 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( diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 194f662b32..cd130f73f2 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -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, +} + +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, pub(crate) context_window_used_tokens: Option, - pub(crate) status_line_value: Option>, + pub(crate) status_line_value: Option, 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> { +pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option { if !shows_passive_footer_line(props) { return None; } @@ -648,10 +705,10 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option 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::(); + 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, }; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ef3898bafd..d39cdcfb85 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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>) { + pub(crate) fn set_status_line(&mut self, status_line: Option) { if self.composer.set_status_line(status_line) { self.request_redraw(); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap index 94d9cd450c..de15eae30c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap index 9a6d412874..0920dd7a94 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap @@ -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. diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index ed4f76c1d0..def513bc1d 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -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 { + 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::(); + 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); diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index b9899e32d6..ee3a153468 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -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(ids: impl Iterator) -> Option> where T: AsRef, @@ -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::().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::(); + 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, ]) ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b8b5d90275..574dff1766 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, + // CWD used to resolve the cached GitHub PR; change resets PR state. + status_line_github_pr_cwd: Option, + // 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, @@ -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>) { + pub(crate) fn set_status_line(&mut self, status_line: Option) { 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, + ) { + 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)); diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 078d316175..35c5b0ca22 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -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, +} + +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 { + 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, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d5c6a5e3b4..0739bee260 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 8ac21627cd..d9c001178d 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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; diff --git a/codex-rs/tui/src/github_pr.rs b/codex-rs/tui/src/github_pr.rs new file mode 100644 index 0000000000..a14dd214eb --- /dev/null +++ b/codex-rs/tui/src/github_pr.rs @@ -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 { + 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 { + resolve_gh_path_from_path(std::env::var_os("PATH").as_deref()) +} + +fn resolve_gh_path_from_path(path_env: Option<&OsStr>) -> Option { + 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 { + ["gh.exe", "gh.cmd", "gh.bat", "gh"].into_iter() +} + +#[cfg(not(windows))] +fn gh_executable_names() -> impl Iterator { + ["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 { + #[derive(Deserialize)] + struct GhPrView { + number: u64, + url: String, + } + + let view = serde_json::from_slice::(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)); + } +} diff --git a/codex-rs/tui/src/hyperlink.rs b/codex-rs/tui/src/hyperlink.rs new file mode 100644 index 0000000000..e77c7b6e9b --- /dev/null +++ b/codex-rs/tui/src/hyperlink.rs @@ -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"); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1e3b58452b..d427e98668 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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;