mirror of
https://github.com/openai/codex.git
synced 2026-04-18 03:34:50 +00:00
Compare commits
1 Commits
dev/shaqay
...
fcoury/git
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,55 @@ use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct StatusLine {
|
||||
line: Line<'static>,
|
||||
hyperlink_url: Option<String>,
|
||||
}
|
||||
|
||||
impl StatusLine {
|
||||
pub(crate) fn new(line: Line<'static>) -> Self {
|
||||
Self {
|
||||
line,
|
||||
hyperlink_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_hyperlink(mut self, url: String) -> Self {
|
||||
self.hyperlink_url = Some(url);
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn line(&self) -> &Line<'static> {
|
||||
&self.line
|
||||
}
|
||||
|
||||
pub(crate) fn into_dimmed_line(self) -> Self {
|
||||
Self {
|
||||
line: self.line.dim(),
|
||||
hyperlink_url: self.hyperlink_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_with_ellipsis_if_overflow(self, max_width: usize) -> Self {
|
||||
Self {
|
||||
line: truncate_line_with_ellipsis_if_overflow(self.line, max_width),
|
||||
hyperlink_url: self.hyperlink_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.line.width()
|
||||
}
|
||||
|
||||
fn mark_hyperlink(&self, buf: &mut Buffer, area: Rect) {
|
||||
if let Some(url) = self.hyperlink_url.as_deref() {
|
||||
mark_underlined_hyperlink(buf, area, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rendering inputs for the footer area under the composer.
|
||||
///
|
||||
/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`,
|
||||
@@ -76,7 +127,7 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) quit_shortcut_key: KeyBinding,
|
||||
pub(crate) context_window_percent: Option<i64>,
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
pub(crate) status_line_value: Option<Line<'static>>,
|
||||
pub(crate) status_line_value: Option<StatusLine>,
|
||||
pub(crate) status_line_enabled: bool,
|
||||
/// Active thread label shown when the footer is rendering contextual information instead of an
|
||||
/// instructional hint.
|
||||
@@ -219,6 +270,12 @@ pub(crate) fn render_footer_line(area: Rect, buf: &mut Buffer, line: Line<'stati
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn render_status_line(area: Rect, buf: &mut Buffer, status_line: StatusLine) {
|
||||
let line = status_line.line.clone();
|
||||
render_footer_line(area, buf, line);
|
||||
status_line.mark_hyperlink(buf, area);
|
||||
}
|
||||
|
||||
/// Render footer content directly from `FooterProps`.
|
||||
///
|
||||
/// This is intentionally not part of the width-based collapse/fallback logic.
|
||||
@@ -587,7 +644,7 @@ fn footer_from_props_lines(
|
||||
// Passive footer context can come from the configurable status line, the
|
||||
// active agent label, or both combined.
|
||||
if let Some(status_line) = passive_footer_status_line(props) {
|
||||
return vec![status_line.dim()];
|
||||
return vec![status_line.into_dimmed_line().line];
|
||||
}
|
||||
match props.mode {
|
||||
FooterMode::QuitShortcutReminder => {
|
||||
@@ -635,7 +692,7 @@ fn footer_from_props_lines(
|
||||
/// The returned line may contain the configured status line, the currently viewed agent label, or
|
||||
/// both combined. Active instructional states such as quit reminders, shortcut overlays, and queue
|
||||
/// prompts deliberately return `None` so those call-to-action hints stay visible.
|
||||
pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'static>> {
|
||||
pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<StatusLine> {
|
||||
if !shows_passive_footer_line(props) {
|
||||
return None;
|
||||
}
|
||||
@@ -648,10 +705,10 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<Line<'st
|
||||
|
||||
if let Some(active_agent_label) = props.active_agent_label.as_ref() {
|
||||
if let Some(existing) = line.as_mut() {
|
||||
existing.spans.push(" · ".into());
|
||||
existing.spans.push(active_agent_label.clone().into());
|
||||
existing.line.spans.push(" · ".into());
|
||||
existing.line.spans.push(active_agent_label.clone().into());
|
||||
} else {
|
||||
line = Some(Line::from(active_agent_label.clone()));
|
||||
line = Some(StatusLine::new(Line::from(active_agent_label.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1059,7 +1116,6 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -1116,8 +1172,8 @@ mod tests {
|
||||
) {
|
||||
passive_status_line
|
||||
.as_ref()
|
||||
.map(|line| line.clone().dim())
|
||||
.map(|line| truncate_line_with_ellipsis_if_overflow(line, available_width))
|
||||
.map(|line| line.clone().into_dimmed_line())
|
||||
.map(|line| line.truncate_with_ellipsis_if_overflow(available_width))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1162,10 +1218,8 @@ mod tests {
|
||||
&& left_width > max_left
|
||||
&& let Some(line) = passive_status_line
|
||||
.as_ref()
|
||||
.map(|line| line.clone().dim())
|
||||
.map(|line| {
|
||||
truncate_line_with_ellipsis_if_overflow(line, max_left as usize)
|
||||
})
|
||||
.map(|line| line.clone().into_dimmed_line())
|
||||
.map(|line| line.truncate_with_ellipsis_if_overflow(max_left as usize))
|
||||
{
|
||||
left_width = line.width() as u16;
|
||||
truncated_status_line = Some(line);
|
||||
@@ -1177,8 +1231,8 @@ mod tests {
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
) {
|
||||
if status_line_active {
|
||||
if let Some(line) = truncated_status_line.clone() {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
if let Some(line) = truncated_status_line {
|
||||
render_status_line(area, f.buffer_mut(), line);
|
||||
}
|
||||
if can_show_left_and_context && let Some(line) = &right_line {
|
||||
render_context_right(area, f.buffer_mut(), line);
|
||||
@@ -1261,6 +1315,21 @@ mod tests {
|
||||
terminal.backend().vt100().screen().contents()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_marks_underlined_segment_as_hyperlink() {
|
||||
let area = Rect::new(0, 0, 24, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let status_line = StatusLine::new(Line::from("PR #123".underlined()))
|
||||
.with_hyperlink("https://github.com/o/r/pull/123".to_string());
|
||||
|
||||
render_status_line(area, &mut buf, status_line);
|
||||
|
||||
let rendered = (0..area.width)
|
||||
.map(|col| buf[(col, 0)].symbol())
|
||||
.collect::<String>();
|
||||
assert!(rendered.contains("\x1B]8;;https://github.com/o/r/pull/123\x07P"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_snapshots() {
|
||||
snapshot_footer(
|
||||
@@ -1504,7 +1573,9 @@ mod tests {
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_value: Some(StatusLine::new(Line::from(
|
||||
"Status line content".to_string(),
|
||||
))),
|
||||
status_line_enabled: true,
|
||||
active_agent_label: None,
|
||||
};
|
||||
@@ -1521,7 +1592,9 @@ mod tests {
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_value: Some(StatusLine::new(Line::from(
|
||||
"Status line content".to_string(),
|
||||
))),
|
||||
status_line_enabled: true,
|
||||
active_agent_label: None,
|
||||
};
|
||||
@@ -1538,7 +1611,9 @@ mod tests {
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_value: Some(StatusLine::new(Line::from(
|
||||
"Status line content".to_string(),
|
||||
))),
|
||||
status_line_enabled: true,
|
||||
active_agent_label: None,
|
||||
};
|
||||
@@ -1622,9 +1697,9 @@ mod tests {
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(50),
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from(
|
||||
status_line_value: Some(StatusLine::new(Line::from(
|
||||
"Status line content that should truncate before the mode indicator".to_string(),
|
||||
)),
|
||||
))),
|
||||
status_line_enabled: true,
|
||||
active_agent_label: None,
|
||||
};
|
||||
@@ -1663,7 +1738,9 @@ mod tests {
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_value: Some(StatusLine::new(Line::from(
|
||||
"Status line content".to_string(),
|
||||
))),
|
||||
status_line_enabled: true,
|
||||
active_agent_label: Some("Robie [explorer]".to_string()),
|
||||
};
|
||||
@@ -1683,10 +1760,10 @@ mod tests {
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(50),
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from(
|
||||
status_line_value: Some(StatusLine::new(Line::from(
|
||||
"Status line content that is definitely too long to fit alongside the mode label"
|
||||
.to_string(),
|
||||
)),
|
||||
))),
|
||||
status_line_enabled: true,
|
||||
active_agent_label: None,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -62,6 +62,9 @@ pub(crate) enum StatusLineItem {
|
||||
/// Current git branch name (if in a repository).
|
||||
GitBranch,
|
||||
|
||||
/// Current branch's GitHub pull request (if available from gh).
|
||||
GithubPr,
|
||||
|
||||
/// Visual meter of context window usage.
|
||||
///
|
||||
/// Also accepts legacy `context-remaining` and `context-used` config values.
|
||||
@@ -112,6 +115,7 @@ impl StatusLineItem {
|
||||
StatusLineItem::CurrentDir => "Current working directory",
|
||||
StatusLineItem::ProjectRoot => "Project root directory (omitted when unavailable)",
|
||||
StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)",
|
||||
StatusLineItem::GithubPr => "Current branch's GitHub PR (omitted when unavailable)",
|
||||
StatusLineItem::ContextUsage => {
|
||||
"Visual meter of context window usage (omitted when unknown)"
|
||||
}
|
||||
@@ -137,24 +141,32 @@ impl StatusLineItem {
|
||||
}
|
||||
}
|
||||
|
||||
const SELECTABLE_STATUS_LINE_ITEMS: &[StatusLineItem] = &[
|
||||
StatusLineItem::ModelName,
|
||||
StatusLineItem::ModelWithReasoning,
|
||||
StatusLineItem::CurrentDir,
|
||||
StatusLineItem::ProjectRoot,
|
||||
StatusLineItem::GitBranch,
|
||||
StatusLineItem::ContextUsage,
|
||||
StatusLineItem::FiveHourLimit,
|
||||
StatusLineItem::WeeklyLimit,
|
||||
StatusLineItem::CodexVersion,
|
||||
StatusLineItem::ContextWindowSize,
|
||||
StatusLineItem::UsedTokens,
|
||||
StatusLineItem::TotalInputTokens,
|
||||
StatusLineItem::TotalOutputTokens,
|
||||
StatusLineItem::SessionId,
|
||||
StatusLineItem::FastMode,
|
||||
StatusLineItem::ThreadTitle,
|
||||
];
|
||||
fn selectable_status_line_items(github_pr_available: bool) -> Vec<StatusLineItem> {
|
||||
let mut items = vec![
|
||||
StatusLineItem::ModelName,
|
||||
StatusLineItem::ModelWithReasoning,
|
||||
StatusLineItem::CurrentDir,
|
||||
StatusLineItem::ProjectRoot,
|
||||
StatusLineItem::GitBranch,
|
||||
];
|
||||
if github_pr_available {
|
||||
items.push(StatusLineItem::GithubPr);
|
||||
}
|
||||
items.extend([
|
||||
StatusLineItem::ContextUsage,
|
||||
StatusLineItem::FiveHourLimit,
|
||||
StatusLineItem::WeeklyLimit,
|
||||
StatusLineItem::CodexVersion,
|
||||
StatusLineItem::ContextWindowSize,
|
||||
StatusLineItem::UsedTokens,
|
||||
StatusLineItem::TotalInputTokens,
|
||||
StatusLineItem::TotalOutputTokens,
|
||||
StatusLineItem::SessionId,
|
||||
StatusLineItem::FastMode,
|
||||
StatusLineItem::ThreadTitle,
|
||||
]);
|
||||
items
|
||||
}
|
||||
|
||||
/// Runtime values used to preview the current status-line selection.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
@@ -214,6 +226,7 @@ impl StatusLineSetupView {
|
||||
pub(crate) fn new(
|
||||
status_line_items: Option<&[String]>,
|
||||
preview_data: StatusLinePreviewData,
|
||||
github_pr_available: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Self {
|
||||
let mut used_ids = HashSet::new();
|
||||
@@ -228,11 +241,13 @@ impl StatusLineSetupView {
|
||||
if !used_ids.insert(item_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
items.push(Self::status_line_select_item(item, /*enabled*/ true));
|
||||
if item != StatusLineItem::GithubPr || github_pr_available {
|
||||
items.push(Self::status_line_select_item(item, /*enabled*/ true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for item in SELECTABLE_STATUS_LINE_ITEMS.iter().cloned() {
|
||||
for item in selectable_status_line_items(github_pr_available) {
|
||||
let item_id = item.to_string();
|
||||
if used_ids.contains(&item_id) {
|
||||
continue;
|
||||
@@ -430,12 +445,34 @@ mod tests {
|
||||
),
|
||||
(StatusLineItem::WeeklyLimit, "weekly 82%".to_string()),
|
||||
]),
|
||||
/*github_pr_available*/ true,
|
||||
AppEventSender::new(tx_raw),
|
||||
);
|
||||
|
||||
assert_snapshot!(render_lines(&view, /*width*/ 72));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_view_hides_github_pr_when_gh_unavailable() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let view = StatusLineSetupView::new(
|
||||
Some(&[
|
||||
StatusLineItem::ModelName.to_string(),
|
||||
StatusLineItem::GithubPr.to_string(),
|
||||
]),
|
||||
StatusLinePreviewData::from_iter([
|
||||
(StatusLineItem::ModelName, "gpt-5-codex".to_string()),
|
||||
(StatusLineItem::GithubPr, "PR #123".to_string()),
|
||||
]),
|
||||
/*github_pr_available*/ false,
|
||||
AppEventSender::new(tx_raw),
|
||||
);
|
||||
|
||||
let rendered = render_lines(&view, /*width*/ 72);
|
||||
assert!(!rendered.contains("github-pr"));
|
||||
assert!(!rendered.contains("PR #123"));
|
||||
}
|
||||
|
||||
fn render_lines(view: &StatusLineSetupView, width: u16) -> String {
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
|
||||
@@ -44,6 +44,8 @@ pub(crate) enum TerminalTitleItem {
|
||||
Thread,
|
||||
/// Current git branch (if available).
|
||||
GitBranch,
|
||||
/// Current branch's GitHub pull request (if available from gh).
|
||||
GithubPr,
|
||||
/// Current model name.
|
||||
Model,
|
||||
/// Latest checklist task progress from `update_plan` (if available).
|
||||
@@ -61,6 +63,7 @@ impl TerminalTitleItem {
|
||||
TerminalTitleItem::Status => "Compact session status text (Ready, Working, Thinking)",
|
||||
TerminalTitleItem::Thread => "Current thread title (omitted until available)",
|
||||
TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)",
|
||||
TerminalTitleItem::GithubPr => "Current branch's GitHub PR (omitted when unavailable)",
|
||||
TerminalTitleItem::Model => "Current model name",
|
||||
TerminalTitleItem::TaskProgress => {
|
||||
"Latest task progress from update_plan (omitted until available)"
|
||||
@@ -80,6 +83,7 @@ impl TerminalTitleItem {
|
||||
TerminalTitleItem::Status => "Working",
|
||||
TerminalTitleItem::Thread => "Investigate flaky test",
|
||||
TerminalTitleItem::GitBranch => "feat/awesome-feature",
|
||||
TerminalTitleItem::GithubPr => "PR #123",
|
||||
TerminalTitleItem::Model => "gpt-5.2-codex",
|
||||
TerminalTitleItem::TaskProgress => "Tasks 2/5",
|
||||
}
|
||||
@@ -103,6 +107,10 @@ impl TerminalTitleItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_title_item_selectable(item: TerminalTitleItem, github_pr_available: bool) -> bool {
|
||||
item != TerminalTitleItem::GithubPr || github_pr_available
|
||||
}
|
||||
|
||||
fn parse_terminal_title_items<T>(ids: impl Iterator<Item = T>) -> Option<Vec<TerminalTitleItem>>
|
||||
where
|
||||
T: AsRef<str>,
|
||||
@@ -128,11 +136,16 @@ impl TerminalTitleSetupView {
|
||||
/// main TUI still warns about them when rendering the actual title, but the
|
||||
/// picker itself only exposes the selectable items it can meaningfully
|
||||
/// preview and persist.
|
||||
pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self {
|
||||
pub(crate) fn new(
|
||||
title_items: Option<&[String]>,
|
||||
github_pr_available: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Self {
|
||||
let selected_items = title_items
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|id| id.parse::<TerminalTitleItem>().ok())
|
||||
.filter(|item| terminal_title_item_selectable(*item, github_pr_available))
|
||||
.unique()
|
||||
.collect_vec();
|
||||
let selected_set = selected_items
|
||||
@@ -144,6 +157,7 @@ impl TerminalTitleSetupView {
|
||||
.map(|item| Self::title_select_item(item, /*enabled*/ true))
|
||||
.chain(
|
||||
TerminalTitleItem::iter()
|
||||
.filter(|item| terminal_title_item_selectable(*item, github_pr_available))
|
||||
.filter(|item| !selected_set.contains(item))
|
||||
.map(|item| Self::title_select_item(item, /*enabled*/ false)),
|
||||
)
|
||||
@@ -280,13 +294,27 @@ mod tests {
|
||||
"status".to_string(),
|
||||
"thread".to_string(),
|
||||
];
|
||||
let view = TerminalTitleSetupView::new(Some(&selected), tx);
|
||||
let view =
|
||||
TerminalTitleSetupView::new(Some(&selected), /*github_pr_available*/ true, tx);
|
||||
assert_snapshot!(
|
||||
"terminal_title_setup_basic",
|
||||
render_lines(&view, /*width*/ 84)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_github_pr_when_gh_unavailable() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let selected = ["project".to_string(), "github-pr".to_string()];
|
||||
let view =
|
||||
TerminalTitleSetupView::new(Some(&selected), /*github_pr_available*/ false, tx);
|
||||
|
||||
let rendered = render_lines(&view, /*width*/ 84);
|
||||
assert!(!rendered.contains("github-pr"));
|
||||
assert!(!rendered.contains("PR #123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_terminal_title_items_preserves_order() {
|
||||
let items =
|
||||
@@ -310,12 +338,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_terminal_title_items_accepts_kebab_case_variants() {
|
||||
let items = parse_terminal_title_items(["app-name", "git-branch"].into_iter());
|
||||
let items = parse_terminal_title_items(["app-name", "git-branch", "github-pr"].into_iter());
|
||||
assert_eq!(
|
||||
items,
|
||||
Some(vec![
|
||||
TerminalTitleItem::AppName,
|
||||
TerminalTitleItem::GitBranch,
|
||||
TerminalTitleItem::GithubPr,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,11 +47,13 @@ use crate::app_server_approval_conversions::network_approval_context_to_core;
|
||||
use crate::app_server_session::ThreadSessionState;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use crate::audio_device::list_realtime_audio_device_names;
|
||||
use crate::bottom_pane::StatusLine;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::StatusLinePreviewData;
|
||||
use crate::bottom_pane::StatusLineSetupView;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::bottom_pane::TerminalTitleSetupView;
|
||||
use crate::github_pr::GithubPullRequest;
|
||||
use crate::legacy_core::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::legacy_core::config::Constrained;
|
||||
@@ -965,6 +967,14 @@ pub(crate) struct ChatWidget {
|
||||
status_line_branch_pending: bool,
|
||||
// True once we've attempted a branch lookup for the current CWD.
|
||||
status_line_branch_lookup_complete: bool,
|
||||
// Cached GitHub PR for the current branch (None if unknown or unavailable).
|
||||
status_line_github_pr: Option<GithubPullRequest>,
|
||||
// CWD used to resolve the cached GitHub PR; change resets PR state.
|
||||
status_line_github_pr_cwd: Option<PathBuf>,
|
||||
// True while an async GitHub PR lookup is in flight.
|
||||
status_line_github_pr_pending: bool,
|
||||
// True once we've attempted a GitHub PR lookup for the current CWD.
|
||||
status_line_github_pr_lookup_complete: bool,
|
||||
external_editor_state: ExternalEditorState,
|
||||
realtime_conversation: RealtimeConversationUiState,
|
||||
last_rendered_user_message_event: Option<RenderedUserMessageEvent>,
|
||||
@@ -1813,7 +1823,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
/// Sets the currently rendered footer status-line value.
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) {
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<StatusLine>) {
|
||||
self.bottom_pane.set_status_line(status_line);
|
||||
}
|
||||
|
||||
@@ -1912,6 +1922,21 @@ impl ChatWidget {
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_line_github_pr(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
pull_request: Option<GithubPullRequest>,
|
||||
) {
|
||||
if self.status_line_github_pr_cwd.as_ref() != Some(&cwd) {
|
||||
self.status_line_github_pr_pending = false;
|
||||
return;
|
||||
}
|
||||
self.status_line_github_pr = pull_request;
|
||||
self.status_line_github_pr_pending = false;
|
||||
self.status_line_github_pr_lookup_complete = true;
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
fn collect_runtime_metrics_delta(&mut self) {
|
||||
if let Some(delta) = self.session_telemetry.runtime_metrics_summary() {
|
||||
self.apply_runtime_metrics_delta(delta);
|
||||
@@ -4871,6 +4896,10 @@ impl ChatWidget {
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
status_line_branch_lookup_complete: false,
|
||||
status_line_github_pr: None,
|
||||
status_line_github_pr_cwd: None,
|
||||
status_line_github_pr_pending: false,
|
||||
status_line_github_pr_lookup_complete: false,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
realtime_conversation: RealtimeConversationUiState::default(),
|
||||
last_rendered_user_message_event: None,
|
||||
@@ -7611,12 +7640,14 @@ impl ChatWidget {
|
||||
|
||||
fn open_status_line_setup(&mut self) {
|
||||
let configured_status_line_items = self.configured_status_line_items();
|
||||
let gh_available = crate::github_pr::gh_available();
|
||||
let view = StatusLineSetupView::new(
|
||||
Some(configured_status_line_items.as_slice()),
|
||||
StatusLinePreviewData::from_iter(StatusLineItem::iter().filter_map(|item| {
|
||||
self.status_line_value_for_item(&item)
|
||||
.map(|value| (item, value))
|
||||
})),
|
||||
gh_available,
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
@@ -7627,6 +7658,7 @@ impl ChatWidget {
|
||||
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
|
||||
let view = TerminalTitleSetupView::new(
|
||||
Some(configured_terminal_title_items.as_slice()),
|
||||
crate::github_pr::gh_available(),
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
|
||||
@@ -51,6 +51,27 @@ impl StatusSurfaceSelections {
|
||||
.terminal_title_items
|
||||
.contains(&TerminalTitleItem::GitBranch)
|
||||
}
|
||||
|
||||
fn uses_github_pr(&self) -> bool {
|
||||
self.status_line_items.contains(&StatusLineItem::GithubPr)
|
||||
|| self
|
||||
.terminal_title_items
|
||||
.contains(&TerminalTitleItem::GithubPr)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusLineSegment {
|
||||
line: Line<'static>,
|
||||
hyperlink_url: Option<String>,
|
||||
}
|
||||
|
||||
impl StatusLineSegment {
|
||||
fn text(value: String) -> Self {
|
||||
Self {
|
||||
line: Line::from(value),
|
||||
hyperlink_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached project-root display name keyed by the cwd used for the last lookup.
|
||||
@@ -124,13 +145,26 @@ impl ChatWidget {
|
||||
self.status_line_branch = None;
|
||||
self.status_line_branch_pending = false;
|
||||
self.status_line_branch_lookup_complete = false;
|
||||
} else {
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_branch_state(&cwd);
|
||||
if !self.status_line_branch_lookup_complete {
|
||||
self.request_status_line_branch(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
let uses_github_pr = selections.uses_github_pr();
|
||||
if !uses_github_pr || !crate::github_pr::gh_available() {
|
||||
self.status_line_github_pr = None;
|
||||
self.status_line_github_pr_pending = false;
|
||||
self.status_line_github_pr_lookup_complete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_branch_state(&cwd);
|
||||
if !self.status_line_branch_lookup_complete {
|
||||
self.request_status_line_branch(cwd);
|
||||
self.sync_status_line_github_pr_state(&cwd);
|
||||
if !self.status_line_github_pr_lookup_complete {
|
||||
self.request_status_line_github_pr(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,19 +176,30 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut spans = Vec::new();
|
||||
let mut hyperlink_url = None;
|
||||
for item in &selections.status_line_items {
|
||||
if let Some(value) = self.status_line_value_for_item(item) {
|
||||
parts.push(value);
|
||||
if let Some(segment) = self.status_line_segment_for_item(item) {
|
||||
if !spans.is_empty() {
|
||||
spans.push(" · ".into());
|
||||
}
|
||||
if hyperlink_url.is_none() {
|
||||
hyperlink_url.clone_from(&segment.hyperlink_url);
|
||||
}
|
||||
spans.extend(segment.line.spans);
|
||||
}
|
||||
}
|
||||
|
||||
let line = if parts.is_empty() {
|
||||
let status_line = if spans.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Line::from(parts.join(" · ")))
|
||||
let status_line = StatusLine::new(Line::from(spans));
|
||||
Some(match hyperlink_url {
|
||||
Some(url) => status_line.with_hyperlink(url),
|
||||
None => status_line,
|
||||
})
|
||||
};
|
||||
self.set_status_line(line);
|
||||
self.set_status_line(status_line);
|
||||
}
|
||||
|
||||
/// Clears the terminal title Codex most recently wrote, if any.
|
||||
@@ -264,12 +309,18 @@ impl ChatWidget {
|
||||
|
||||
pub(super) fn request_status_line_branch_refresh(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
if !selections.uses_git_branch() {
|
||||
if !selections.uses_git_branch() && !selections.uses_github_pr() {
|
||||
return;
|
||||
}
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_branch_state(&cwd);
|
||||
self.request_status_line_branch(cwd);
|
||||
if selections.uses_git_branch() {
|
||||
self.sync_status_line_branch_state(&cwd);
|
||||
self.request_status_line_branch(cwd.clone());
|
||||
}
|
||||
if selections.uses_github_pr() && crate::github_pr::gh_available() {
|
||||
self.sync_status_line_github_pr_state(&cwd);
|
||||
self.request_status_line_github_pr(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses configured status-line ids into known items and collects unknown ids.
|
||||
@@ -397,6 +448,20 @@ impl ChatWidget {
|
||||
self.status_line_branch_lookup_complete = false;
|
||||
}
|
||||
|
||||
fn sync_status_line_github_pr_state(&mut self, cwd: &Path) {
|
||||
if self
|
||||
.status_line_github_pr_cwd
|
||||
.as_ref()
|
||||
.is_some_and(|path| path == cwd)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.status_line_github_pr_cwd = Some(cwd.to_path_buf());
|
||||
self.status_line_github_pr = None;
|
||||
self.status_line_github_pr_pending = false;
|
||||
self.status_line_github_pr_lookup_complete = false;
|
||||
}
|
||||
|
||||
/// Starts an async git-branch lookup unless one is already running.
|
||||
///
|
||||
/// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject
|
||||
@@ -413,6 +478,18 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
fn request_status_line_github_pr(&mut self, cwd: PathBuf) {
|
||||
if self.status_line_github_pr_pending {
|
||||
return;
|
||||
}
|
||||
self.status_line_github_pr_pending = true;
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let pull_request = crate::github_pr::lookup_current_branch_pull_request(&cwd).await;
|
||||
tx.send(AppEvent::GithubPullRequestUpdated { cwd, pull_request });
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolves a display string for one configured status-line item.
|
||||
///
|
||||
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
|
||||
@@ -441,6 +518,10 @@ impl ChatWidget {
|
||||
}
|
||||
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
|
||||
StatusLineItem::GitBranch => self.status_line_branch.clone(),
|
||||
StatusLineItem::GithubPr => self
|
||||
.status_line_github_pr
|
||||
.as_ref()
|
||||
.map(GithubPullRequest::label),
|
||||
StatusLineItem::UsedTokens => {
|
||||
let usage = self.status_line_total_usage();
|
||||
let total = usage.tokens_in_context_window();
|
||||
@@ -502,11 +583,27 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_line_segment_for_item(&mut self, item: &StatusLineItem) -> Option<StatusLineSegment> {
|
||||
match item {
|
||||
StatusLineItem::GithubPr => {
|
||||
self.status_line_github_pr
|
||||
.as_ref()
|
||||
.map(|pr| StatusLineSegment {
|
||||
line: Line::from(pr.label().underlined()),
|
||||
hyperlink_url: Some(pr.url.clone()),
|
||||
})
|
||||
}
|
||||
_ => self
|
||||
.status_line_value_for_item(item)
|
||||
.map(StatusLineSegment::text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves one configured terminal-title item into a displayable segment.
|
||||
///
|
||||
/// Returning `None` means "omit this segment for now" so callers can keep
|
||||
/// the configured order while hiding values that are not yet available.
|
||||
fn terminal_title_value_for_item(
|
||||
pub(super) fn terminal_title_value_for_item(
|
||||
&mut self,
|
||||
item: TerminalTitleItem,
|
||||
now: Instant,
|
||||
@@ -530,6 +627,9 @@ impl ChatWidget {
|
||||
TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| {
|
||||
Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32)
|
||||
}),
|
||||
TerminalTitleItem::GithubPr => self.status_line_github_pr.as_ref().map(|pr| {
|
||||
Self::truncate_terminal_title_part(pr.label(), /*max_chars*/ 16)
|
||||
}),
|
||||
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
|
||||
self.model_display_name().to_string(),
|
||||
/*max_chars*/ 32,
|
||||
|
||||
@@ -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,43 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() {
|
||||
assert!(!chat.status_line_branch_lookup_complete);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_pr_values_feed_status_line_and_terminal_title() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.status_line_github_pr = Some(GithubPullRequest {
|
||||
number: 123,
|
||||
url: "https://github.com/openai/codex/pull/123".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
chat.status_line_value_for_item(&StatusLineItem::GithubPr),
|
||||
Some("PR #123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
chat.terminal_title_value_for_item(TerminalTitleItem::GithubPr, Instant::now()),
|
||||
Some("PR #123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_pr_state_resets_when_no_surface_uses_it() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.status_line_github_pr = Some(GithubPullRequest {
|
||||
number: 123,
|
||||
url: "https://github.com/openai/codex/pull/123".to_string(),
|
||||
});
|
||||
chat.status_line_github_pr_pending = true;
|
||||
chat.status_line_github_pr_lookup_complete = true;
|
||||
chat.config.tui_status_line = Some(vec!["model_name".to_string()]);
|
||||
chat.config.tui_terminal_title = Some(vec!["model".to_string()]);
|
||||
|
||||
chat.refresh_status_line();
|
||||
|
||||
assert_eq!(chat.status_line_github_pr, None);
|
||||
assert!(!chat.status_line_github_pr_pending);
|
||||
assert!(!chat.status_line_github_pr_lookup_complete);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_branch_refreshes_after_turn_complete() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
160
codex-rs/tui/src/github_pr.rs
Normal file
160
codex-rs/tui/src/github_pr.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const GH_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct GithubPullRequest {
|
||||
pub(crate) number: u64,
|
||||
pub(crate) url: String,
|
||||
}
|
||||
|
||||
impl GithubPullRequest {
|
||||
pub(crate) fn label(&self) -> String {
|
||||
format!("PR #{}", self.number)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gh_available() -> bool {
|
||||
resolve_gh_path().is_some()
|
||||
}
|
||||
|
||||
pub(crate) async fn lookup_current_branch_pull_request(cwd: &Path) -> Option<GithubPullRequest> {
|
||||
let gh_path = resolve_gh_path()?;
|
||||
let mut command = Command::new(&gh_path);
|
||||
command
|
||||
.args(["pr", "view", "--json", "number,url"])
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let output = match timeout(GH_LOOKUP_TIMEOUT, command.output()).await {
|
||||
Ok(Ok(output)) => output,
|
||||
Ok(Err(_)) | Err(_) => return None,
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
parse_gh_pr_view_json(&output.stdout)
|
||||
}
|
||||
|
||||
fn resolve_gh_path() -> Option<PathBuf> {
|
||||
resolve_gh_path_from_path(std::env::var_os("PATH").as_deref())
|
||||
}
|
||||
|
||||
fn resolve_gh_path_from_path(path_env: Option<&OsStr>) -> Option<PathBuf> {
|
||||
path_env
|
||||
.into_iter()
|
||||
.flat_map(std::env::split_paths)
|
||||
.flat_map(|dir| gh_executable_names().map(move |name| dir.join(name)))
|
||||
.find(|path| is_executable_file(path))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn gh_executable_names() -> impl Iterator<Item = &'static str> {
|
||||
["gh.exe", "gh.cmd", "gh.bat", "gh"].into_iter()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn gh_executable_names() -> impl Iterator<Item = &'static str> {
|
||||
["gh"].into_iter()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_executable_file(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
path.metadata()
|
||||
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_executable_file(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
fn parse_gh_pr_view_json(bytes: &[u8]) -> Option<GithubPullRequest> {
|
||||
#[derive(Deserialize)]
|
||||
struct GhPrView {
|
||||
number: u64,
|
||||
url: String,
|
||||
}
|
||||
|
||||
let view = serde_json::from_slice::<GhPrView>(bytes).ok()?;
|
||||
(!view.url.trim().is_empty()).then_some(GithubPullRequest {
|
||||
number: view.number,
|
||||
url: view.url,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parses_valid_pr_view_json() {
|
||||
assert_eq!(
|
||||
parse_gh_pr_view_json(br#"{"number":123,"url":"https://github.com/o/r/pull/123"}"#),
|
||||
Some(GithubPullRequest {
|
||||
number: 123,
|
||||
url: "https://github.com/o/r/pull/123".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_pr_view_json() {
|
||||
assert_eq!(
|
||||
parse_gh_pr_view_json(br#"{"url":"https://example.com"}"#),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_gh_pr_view_json(br#"{"number":123}"#), None);
|
||||
assert_eq!(parse_gh_pr_view_json(br#"{"number":123,"url":""}"#), None);
|
||||
assert_eq!(parse_gh_pr_view_json(b"not json"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_executable(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut permissions = path.metadata().expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(path, permissions).expect("set permissions");
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn make_executable(_path: &Path) {}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn gh_test_file_name() -> &'static str {
|
||||
"gh.exe"
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn gh_test_file_name() -> &'static str {
|
||||
"gh"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_gh_from_path_env() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let gh = temp.path().join(gh_test_file_name());
|
||||
std::fs::write(&gh, "").expect("write gh");
|
||||
make_executable(&gh);
|
||||
|
||||
let resolved = resolve_gh_path_from_path(Some(temp.path().as_os_str()));
|
||||
|
||||
assert_eq!(resolved, Some(gh));
|
||||
}
|
||||
}
|
||||
61
codex-rs/tui/src/hyperlink.rs
Normal file
61
codex-rs/tui/src/hyperlink.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
let safe_url = sanitize_osc8_url(url);
|
||||
if safe_url.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
let cell = &mut buf[(x, y)];
|
||||
if !cell.modifier.contains(Modifier::UNDERLINED) {
|
||||
continue;
|
||||
}
|
||||
let symbol = cell.symbol().to_string();
|
||||
if symbol.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{symbol}\x1B]8;;\x07"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_osc8_url(url: &str) -> String {
|
||||
url.chars()
|
||||
.filter(|&ch| ch != '\x1B' && ch != '\x07')
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
#[test]
|
||||
fn strips_osc8_control_characters_from_url() {
|
||||
assert_eq!(
|
||||
sanitize_osc8_url("https://example.com/\x1B]8;;\x07injected"),
|
||||
"https://example.com/]8;;injected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_only_underlined_cells() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
|
||||
buf[(0, 0)].set_symbol("A").set_style("".underlined().style);
|
||||
buf[(1, 0)].set_symbol("B");
|
||||
|
||||
mark_underlined_hyperlink(&mut buf, Rect::new(0, 0, 2, 1), "https://example.com");
|
||||
|
||||
assert!(
|
||||
buf[(0, 0)]
|
||||
.symbol()
|
||||
.contains("\x1B]8;;https://example.com\x07A")
|
||||
);
|
||||
assert_eq!(buf[(1, 0)].symbol(), "B");
|
||||
}
|
||||
}
|
||||
@@ -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