Compare commits

...

9 Commits

Author SHA1 Message Date
Felipe Coury
0270583486 fix(tui): allow clearing hidden github pr title item
Treat an empty `/title` confirmation as an intentional clear when `github-pr` is hidden because `gh` is unavailable. Preserve hidden title items only when visible selections remain.
2026-04-11 18:37:09 -03:00
Felipe Coury
a91d956429 fix(tui): allow clearing hidden github pr status item
Treat an empty `/statusline` confirmation as an intentional clear even
when `github-pr` is hidden because `gh` is unavailable. Preserve hidden
items only when visible selections remain.
2026-04-11 18:16:29 -03:00
Felipe Coury
8703d06276 fix(tui): require absolute github cli path
Ignore relative and empty `PATH` entries when resolving `gh`, then cache
only a canonical executable path. This prevents PR lookups from executing
a repo-relative binary after changing the command cwd.
2026-04-11 17:16:37 -03:00
Felipe Coury
5c9723574b fix(tui): cache github cli availability
Cache the resolved `gh` executable path for the life of the process so
status line and terminal title refreshes do not repeatedly scan `PATH`.
This keeps animated title refreshes cheap when `github-pr` is configured.
2026-04-11 16:56:00 -03:00
Felipe Coury
9751294912 fix(tui): annotate github pr test argument
Add the required argument comment for the `pull_request` parameter in
the stale GitHub PR lookup regression test so the argument-comment lint
passes in CI.
2026-04-11 14:23:52 -03:00
Felipe Coury
bd31ba8e4f fix(tui): ignore stale github pr lookup state
Leave the current GitHub PR pending flag untouched when an async lookup
result arrives for an old cwd. This prevents late stale events from
allowing duplicate lookups for the active status surface cwd.
2026-04-11 14:13:31 -03:00
Felipe Coury
426304645a fix(tui): preserve hidden github pr setup items
Keep configured `github-pr` status line and terminal title entries when
`gh` is unavailable and the setup picker hides the option. This avoids
silently deleting the setting when users confirm unrelated picker edits.
2026-04-11 14:09:39 -03:00
Felipe Coury
a6ea6a106d docs(tui): document github pr status surfaces
Clarify the PR lookup, hyperlink, and cwd-cache contracts for the new TUI status-surface integration.

Add reviewer-facing rustdoc around the best-effort GitHub CLI boundary and the single-link footer status-line model.
2026-04-11 13:38:03 -03:00
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 1018 additions and 96 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,78 @@ use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
/// Render-ready status-line content for the footer.
///
/// The footer sometimes needs to preserve metadata that is not part of
/// `ratatui::text::Line`, such as the URL for an OSC 8 hyperlink. `StatusLine`
/// keeps that metadata traveling with the visible line through dimming,
/// truncation, and passive-footer composition. The hyperlink model is
/// intentionally narrow: one optional URL applies to underlined cells in the
/// rendered status-line area.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct StatusLine {
line: Line<'static>,
hyperlink_url: Option<String>,
}
impl StatusLine {
/// Creates footer status-line content without a hyperlink.
pub(crate) fn new(line: Line<'static>) -> Self {
Self {
line,
hyperlink_url: None,
}
}
/// Attaches one hyperlink URL to the status-line's underlined cells.
///
/// This should only be used when the line contains a deliberately underlined
/// segment. If a caller underlines multiple unrelated segments, the footer
/// will mark all of them with this same URL.
pub(crate) fn with_hyperlink(mut self, url: String) -> Self {
self.hyperlink_url = Some(url);
self
}
#[cfg(test)]
/// Returns the visible line for assertions.
pub(crate) fn line(&self) -> &Line<'static> {
&self.line
}
/// Dims the visible text while preserving hyperlink metadata.
pub(crate) fn into_dimmed_line(self) -> Self {
Self {
line: self.line.dim(),
hyperlink_url: self.hyperlink_url,
}
}
/// Truncates the visible line to `max_width` while preserving hyperlink metadata.
///
/// The hyperlink is still applied after rendering, so truncating a linked
/// label makes the visible truncated label clickable. Callers should avoid
/// using this to combine multiple independent links because only one URL is
/// retained.
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,
}
}
/// Returns the display width of the visible status line.
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 +150,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 +293,18 @@ pub(crate) fn render_footer_line(area: Rect, buf: &mut Buffer, line: Line<'stati
.render(area, buf);
}
/// Renders a footer status line and applies its hyperlink metadata afterward.
///
/// Hyperlinks are applied after `Paragraph` has written to the buffer because
/// the OSC 8 escape sequence is terminal output metadata, not display width.
/// Rendering the escape sequence as normal text would corrupt layout
/// measurement and snapshot expectations.
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 +673,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 +721,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 +734,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 +1145,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 +1201,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 +1247,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 +1260,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 +1344,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 +1602,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 +1621,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 +1640,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 +1726,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 +1767,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 +1789,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

@@ -11,7 +11,7 @@
//!
//! - Model information (name, reasoning level)
//! - Directory paths (current dir, project root)
//! - Git information (branch name)
//! - Git information (branch name and, when `gh` is available, pull request)
//! - Context usage (meter, window size)
//! - Usage limits (5-hour, weekly)
//! - Session info (thread title, ID, tokens used)
@@ -41,7 +41,8 @@ use crate::render::renderable::Renderable;
/// storage (e.g., `ModelWithReasoning` becomes `model-with-reasoning`).
///
/// Some items are conditionally displayed based on availability:
/// - Git-related items only show when in a git repository
/// - Git-related items only show when repository data is available
/// - GitHub PR selection is offered only when the `gh` executable is available
/// - Context/limit items only show when data is available from the API
/// - Session ID only shows after a session has started
#[derive(EnumIter, EnumString, Display, Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
@@ -62,6 +63,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 +116,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 +142,77 @@ 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,
];
/// Returns setup items, omitting GitHub PR when the CLI integration is unavailable.
///
/// Existing config may still contain `github-pr`; this only controls whether
/// the setup picker advertises the item in the current environment.
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
}
fn hidden_configured_status_line_items(
status_line_items: Option<&[String]>,
github_pr_available: bool,
) -> Vec<(usize, StatusLineItem)> {
if github_pr_available {
return Vec::new();
}
let mut hidden_items = Vec::new();
for (index, id) in status_line_items.into_iter().flatten().enumerate() {
let Ok(item) = id.parse::<StatusLineItem>() else {
continue;
};
if item == StatusLineItem::GithubPr
&& !hidden_items
.iter()
.any(|(_, hidden_item)| hidden_item == &item)
{
hidden_items.push((index, item));
}
}
hidden_items
}
fn restore_hidden_status_line_items(
mut items: Vec<StatusLineItem>,
hidden_items: &[(usize, StatusLineItem)],
) -> Vec<StatusLineItem> {
if items.is_empty() {
return items;
}
for (index, item) in hidden_items {
if items.contains(item) {
continue;
}
items.insert((*index).min(items.len()), item.clone());
}
items
}
/// Runtime values used to preview the current status-line selection.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
@@ -203,21 +261,22 @@ pub(crate) struct StatusLineSetupView {
impl StatusLineSetupView {
/// Creates a new status line setup view.
///
/// # Arguments
///
/// * `status_line_items` - Currently configured item IDs (in display order),
/// or `None` to start with all items disabled
/// * `app_event_tx` - Event sender for dispatching configuration changes
///
/// Items from `status_line_items` are shown first (in order) and marked as
/// enabled. Remaining items are appended and marked as disabled.
/// enabled. Remaining selectable items are appended and marked as disabled.
/// `preview_data` supplies live values for the preview row, while
/// `github_pr_available` gates whether the optional `github-pr` item is
/// shown at all. Passing `true` without a working `gh` binary would let the
/// user persist an item that cannot produce a value in this environment.
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();
let mut items = Vec::new();
let hidden_configured_items =
hidden_configured_status_line_items(status_line_items, github_pr_available);
if let Some(selected_items) = status_line_items.as_ref() {
for id in *selected_items {
@@ -228,11 +287,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;
@@ -253,12 +314,13 @@ impl StatusLineSetupView {
.items(items)
.enable_ordering()
.on_preview(move |items| preview_data.line_for_items(items))
.on_confirm(|ids, app_event| {
.on_confirm(move |ids, app_event| {
let items = ids
.iter()
.map(|id| id.parse::<StatusLineItem>())
.collect::<Result<Vec<_>, _>>()
.unwrap_or_default();
let items = restore_hidden_status_line_items(items, &hidden_configured_items);
app_event.send(AppEvent::StatusLineSetup { items });
})
.on_cancel(|app_event| {
@@ -308,6 +370,9 @@ impl Renderable for StatusLineSetupView {
mod tests {
use super::*;
use crate::app_event_sender::AppEventSender;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
@@ -430,12 +495,85 @@ 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"));
}
#[tokio::test]
async fn confirm_preserves_hidden_github_pr_when_gh_unavailable() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let mut view = StatusLineSetupView::new(
Some(&[
StatusLineItem::ModelName.to_string(),
StatusLineItem::GithubPr.to_string(),
StatusLineItem::CurrentDir.to_string(),
]),
StatusLinePreviewData::default(),
/*github_pr_available*/ false,
AppEventSender::new(tx_raw),
);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let Some(AppEvent::StatusLineSetup { items }) = rx.recv().await else {
panic!("expected status line setup event");
};
assert_eq!(
items,
vec![
StatusLineItem::ModelName,
StatusLineItem::GithubPr,
StatusLineItem::CurrentDir,
]
);
}
#[tokio::test]
async fn confirm_empty_selection_does_not_restore_hidden_github_pr() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let mut view = StatusLineSetupView::new(
Some(&[
StatusLineItem::ModelName.to_string(),
StatusLineItem::GithubPr.to_string(),
]),
StatusLinePreviewData::default(),
/*github_pr_available*/ false,
AppEventSender::new(tx_raw),
);
view.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let Some(AppEvent::StatusLineSetup { items }) = rx.recv().await else {
panic!("expected status line setup event");
};
assert_eq!(items, Vec::<StatusLineItem>::new());
}
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,57 @@ impl TerminalTitleItem {
}
}
/// Returns whether an item should be offered by the setup picker in this environment.
///
/// Existing config can still include `github-pr` and the renderer will simply
/// omit it when no value is available. The picker is more conservative: it does
/// not advertise the item unless the GitHub CLI integration can at least be
/// attempted.
fn terminal_title_item_selectable(item: TerminalTitleItem, github_pr_available: bool) -> bool {
item != TerminalTitleItem::GithubPr || github_pr_available
}
fn hidden_configured_terminal_title_items(
title_items: Option<&[String]>,
github_pr_available: bool,
) -> Vec<(usize, TerminalTitleItem)> {
if github_pr_available {
return Vec::new();
}
let mut hidden_items = Vec::new();
for (index, id) in title_items.into_iter().flatten().enumerate() {
let Ok(item) = id.parse::<TerminalTitleItem>() else {
continue;
};
if item == TerminalTitleItem::GithubPr
&& !hidden_items
.iter()
.any(|(_, hidden_item)| hidden_item == &item)
{
hidden_items.push((index, item));
}
}
hidden_items
}
fn restore_hidden_terminal_title_items(
mut items: Vec<TerminalTitleItem>,
hidden_items: &[(usize, TerminalTitleItem)],
) -> Vec<TerminalTitleItem> {
if items.is_empty() {
return items;
}
for (index, item) in hidden_items {
if items.contains(item) {
continue;
}
items.insert((*index).min(items.len()), *item);
}
items
}
fn parse_terminal_title_items<T>(ids: impl Iterator<Item = T>) -> Option<Vec<TerminalTitleItem>>
where
T: AsRef<str>,
@@ -128,11 +183,18 @@ 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 hidden_configured_items =
hidden_configured_terminal_title_items(title_items, github_pr_available);
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 +206,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)),
)
@@ -192,10 +255,11 @@ impl TerminalTitleSetupView {
};
app_event.send(AppEvent::TerminalTitleSetupPreview { items });
})
.on_confirm(|ids, app_event| {
.on_confirm(move |ids, app_event| {
let Some(items) = parse_terminal_title_items(ids.iter().map(String::as_str)) else {
return;
};
let items = restore_hidden_terminal_title_items(items, &hidden_configured_items);
app_event.send(AppEvent::TerminalTitleSetup { items });
})
.on_cancel(|app_event| {
@@ -243,6 +307,9 @@ impl Renderable for TerminalTitleSetupView {
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
@@ -280,13 +347,76 @@ 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"));
}
#[tokio::test]
async fn confirm_preserves_hidden_github_pr_when_gh_unavailable() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let selected = [
"project".to_string(),
"github-pr".to_string(),
"model".to_string(),
];
let mut view =
TerminalTitleSetupView::new(Some(&selected), /*github_pr_available*/ false, tx);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let Some(AppEvent::TerminalTitleSetup { items }) = rx.recv().await else {
panic!("expected terminal title setup event");
};
assert_eq!(
items,
vec![
TerminalTitleItem::Project,
TerminalTitleItem::GithubPr,
TerminalTitleItem::Model,
]
);
}
#[tokio::test]
async fn confirm_empty_selection_does_not_restore_hidden_github_pr() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let selected = ["project".to_string(), "github-pr".to_string()];
let mut view =
TerminalTitleSetupView::new(Some(&selected), /*github_pr_available*/ false, tx);
view.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
loop {
let Some(event) = rx.recv().await else {
panic!("expected terminal title setup event");
};
if let AppEvent::TerminalTitleSetup { items } = event {
assert_eq!(items, Vec::<TerminalTitleItem>::new());
break;
}
}
}
#[test]
fn parse_terminal_title_items_preserves_order() {
let items =
@@ -310,12 +440,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,27 @@ impl ChatWidget {
self.refresh_status_surfaces();
}
/// Stores async GitHub PR lookup results for the current status-surface cwd.
///
/// Results are dropped when they target an out-of-date cwd to avoid
/// rendering a pull request from a previous project after the session cwd
/// changes. `None` is a completed lookup result as well as "not found", so
/// callers must update the completion flag before refreshing or the widget
/// would immediately schedule the same best-effort `gh` lookup again.
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) {
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 +4902,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 +7646,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 +7664,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

@@ -30,13 +30,15 @@ pub(super) enum TerminalTitleStatusKind {
Thinking,
}
#[derive(Debug)]
/// Parsed status-surface configuration for one refresh pass.
///
/// The status line and terminal title share some expensive or stateful inputs
/// (notably git branch lookup and invalid-item warnings). This snapshot lets one
/// refresh pass compute those shared concerns once, then render both surfaces
/// from the same selection set.
/// (notably git branch/PR lookups and invalid-item warnings). This snapshot lets
/// one refresh pass compute those shared concerns once, then render both
/// surfaces from the same selection set. A caller that parsed each surface
/// independently could schedule duplicate external lookups or warn twice about
/// the same invalid config value.
#[derive(Debug)]
struct StatusSurfaceSelections {
status_line_items: Vec<StatusLineItem>,
invalid_status_line_items: Vec<String>,
@@ -51,6 +53,33 @@ 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)
}
}
/// One rendered status-line segment plus optional metadata for the final line.
///
/// Most segments are plain text. The GitHub PR segment carries a hyperlink URL
/// and underlined visible text; when the full status line is assembled, the
/// first available hyperlink is preserved on the resulting footer `StatusLine`.
/// This matches the current single-link footer contract.
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.
@@ -119,18 +148,37 @@ impl ChatWidget {
}
}
/// Synchronizes cached branch/PR state needed by either status surface.
///
/// Values that are no longer selected are cleared so stale context does not
/// leak back into previews after config changes. Selected values are looked
/// up relative to the active cwd and guarded by pending/completed flags so a
/// refresh burst schedules at most one lookup of each kind.
fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) {
if !selections.uses_git_branch() {
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 +190,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.
@@ -262,14 +321,26 @@ impl ChatWidget {
self.refresh_terminal_title_from_selections(&selections);
}
/// Forces a fresh repository-context lookup for selected status surfaces.
///
/// This is called when the active cwd may have changed outside the normal
/// refresh path. It refreshes whichever repository-backed items are selected
/// (`git-branch`, `github-pr`, or both) and keeps the existing cwd guard so
/// late completions cannot update a different project.
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 +468,25 @@ impl ChatWidget {
self.status_line_branch_lookup_complete = false;
}
/// Resets GitHub PR cache state when the status-surface cwd changes.
///
/// PR discovery is branch/repository-sensitive and `gh pr view` infers both
/// from `cwd`. Keeping a completed lookup across cwd changes would make the
/// footer or terminal title show a PR from the previous project.
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 +503,24 @@ impl ChatWidget {
});
}
/// Starts an async GitHub PR lookup unless one is already running.
///
/// The completion event carries the lookup cwd so `ChatWidget` can reject
/// stale results after directory changes. Lookup failures intentionally
/// render as absence rather than user-visible errors because the item is
/// ambient status context, not a command the user explicitly invoked.
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 +549,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 +614,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 +658,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,54 @@ 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 stale_github_pr_update_preserves_current_pending_lookup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_github_pr_cwd = Some(PathBuf::from("/repo/current"));
chat.status_line_github_pr_pending = true;
chat.set_status_line_github_pr(PathBuf::from("/repo/old"), /*pull_request*/ None);
assert!(chat.status_line_github_pr_pending);
}
#[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,226 @@
//! Best-effort GitHub pull request discovery for TUI status surfaces.
//!
//! The status line and terminal title only need compact, renderable metadata
//! for the pull request associated with the current branch. This module keeps
//! that boundary narrow: it shells out to the GitHub CLI, parses the small
//! `gh pr view --json number,url` response, and turns every lookup failure into
//! `None`.
//!
//! Discovery is intentionally non-interactive. Lookups must not prompt for
//! authentication, block the UI indefinitely, or emit command output into the
//! terminal. Callers cache results by cwd and carry the cwd through async update
//! events so stale completions can be ignored after the session changes
//! directories.
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::OnceLock;
use serde::Deserialize;
use tokio::process::Command;
use tokio::time::Duration;
use tokio::time::timeout;
const GH_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
static GH_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
/// Compact GitHub pull request metadata that can be rendered in status surfaces.
///
/// The number is used for the visible `PR #123` label and the URL is used for
/// terminal hyperlinks. Instances are created only from a successful `gh pr
/// view` response with a non-empty URL; callers should avoid constructing one
/// from partially trusted data because an empty URL would render as visible text
/// without a useful destination.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GithubPullRequest {
/// Pull request number within its repository.
pub(crate) number: u64,
/// Browser URL returned by `gh`.
pub(crate) url: String,
}
impl GithubPullRequest {
/// Returns the compact label shared by the footer and terminal title.
pub(crate) fn label(&self) -> String {
format!("PR #{}", self.number)
}
}
/// Returns whether a usable GitHub CLI executable is present on `PATH`.
///
/// This only checks that `gh` can be resolved to an executable file. It does not
/// prove the user is authenticated, that the current directory is a GitHub
/// repository, or that the current branch has a pull request. Treating this as a
/// guarantee would make setup UIs advertise a value that later cannot render.
pub(crate) fn gh_available() -> bool {
resolve_gh_path().is_some()
}
/// Looks up the pull request associated with the branch checked out at `cwd`.
///
/// The lookup is cwd-relative because `gh pr view` infers repository and branch
/// context from the working directory. It uses a short timeout, null stdin, and
/// suppressed stderr so status-surface refreshes remain best-effort background
/// work. `None` means any of: `gh` is missing, auth/repository/branch context is
/// unavailable, the command failed or timed out, the JSON was not the expected
/// shape, or the PR URL was empty.
///
/// A caller that forgets to key the result by the same `cwd` can display a PR
/// from a previous session directory after the user changes projects, so update
/// events should carry `cwd` back to the owner that requested the lookup.
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> {
GH_PATH
.get_or_init(|| resolve_gh_path_from_path(std::env::var_os("PATH").as_deref()))
.clone()
}
fn resolve_gh_path_from_path(path_env: Option<&OsStr>) -> Option<PathBuf> {
path_env
.into_iter()
.flat_map(std::env::split_paths)
.filter(|dir| dir.is_absolute())
.flat_map(|dir| gh_executable_names().map(move |name| dir.join(name)))
.find(|path| is_executable_file(path))
.and_then(|path| path.canonicalize().ok())
}
#[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.canonicalize().expect("canonical gh path"))
);
}
#[test]
fn ignores_relative_path_entries() {
let resolved = resolve_gh_path_from_path(Some(OsStr::new("relative/bin")));
assert_eq!(resolved, None);
}
#[test]
fn ignores_empty_path_entries() {
let resolved = resolve_gh_path_from_path(Some(OsStr::new("")));
assert_eq!(resolved, None);
}
}

View File

@@ -0,0 +1,82 @@
//! OSC 8 hyperlink helpers for rendered TUI buffers.
//!
//! Ratatui renders styled text into cells before the terminal backend writes the
//! final escape stream. These helpers attach OSC 8 escapes after rendering by
//! rewriting selected cell symbols in-place. The current contract is deliberately
//! style-based: callers underline the visible text that should become clickable,
//! then provide one URL for that rendered region.
//!
//! The helper sanitizes the URL for OSC 8 delimiters but does not validate that
//! the string is a browser URL. Callers remain responsible for passing a URL
//! that makes sense for the visible label.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
/// Marks underlined, non-blank cells in `area` as an OSC 8 hyperlink to `url`.
///
/// This is intended for compact footer/status rendering where the clickable
/// range has already been signaled by underline styling. Non-underlined and
/// blank cells are skipped so separators, padding, and unrelated status text do
/// not become part of the link. If future callers need multiple links in one
/// area, they should split rendering by area or extend this API; passing a broad
/// area with unrelated underlined text would incorrectly point every underlined
/// cell at the same destination.
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;