mirror of
https://github.com/openai/codex.git
synced 2026-04-17 11:14:48 +00:00
Compare commits
9 Commits
dh--feat--
...
github-pr-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0270583486 | ||
|
|
a91d956429 | ||
|
|
8703d06276 | ||
|
|
5c9723574b | ||
|
|
9751294912 | ||
|
|
bd31ba8e4f | ||
|
|
426304645a | ||
|
|
a6ea6a106d | ||
|
|
d5d9e614a1 |
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
226
codex-rs/tui/src/github_pr.rs
Normal file
226
codex-rs/tui/src/github_pr.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
82
codex-rs/tui/src/hyperlink.rs
Normal file
82
codex-rs/tui/src/hyperlink.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user