mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): add PR summary statusline items (#20892)
## Why? The Codex App already exposes branch and PR context in its branch-details UI. This brings the same context into the CLI footer as opt-in statusline items, so users can choose the extra signal without making the default footer busier. ## What? Add optional `pull-request-number` and `branch-changes` items to the configurable TUI status line. - `pull-request-number` shows the open PR for the current checkout and renders as a clickable terminal hyperlink when OSC 8 links are supported. - `branch-changes` shows committed additions/deletions against the repository default branch, or `No changes` when the branch has no committed diff. <img width="1257" height="261" alt="CleanShot 2026-05-03 at 20 44 15" src="https://github.com/user-attachments/assets/10b4380b-c3e9-4729-9ee1-3f742068fa47" /> ## Architecture This follows the same client/app-server split as the Codex App: the TUI owns presentation, caching, and optional rendering, while workspace-sensitive `git` and `gh` discovery runs through app-server. The new TUI-local `workspace_command` layer sends bounded, non-interactive `command/exec` requests to the active app-server. That makes the implementation remote-friendly: the TUI does not decide whether commands run in an embedded local workspace or a remote workspace, and it does not bypass app-server sandbox or permission policy. The branch summary logic stays internal to `codex-tui` because this PR only needs TUI statusline behavior. The command boundary is still isolated behind `WorkspaceCommandExecutor`, so the lookup code can be lifted or reused later without changing statusline rendering. ## How? - Add a TUI `WorkspaceCommandExecutor` abstraction backed by app-server `command/exec`. - Add branch summary probes for: - current branch name, - open PR metadata, - committed branch diff stats against the default branch. - Prefer remote-tracking default branch refs for diff stats, avoiding stale or absent local `main` branches. - Resolve PRs with `gh pr view` first, then fall back to commit-associated PR lookup across parent/fork repos. - Add `/statusline` picker entries, preview values, rendering, and OSC 8 clickable PR links. - Keep all probes best-effort so missing `git`, missing `gh`, auth failures, or non-git directories hide optional items instead of surfacing footer errors. ## Validation - `cargo test -p codex-tui branch_summary -- --nocapture` - Snapshot coverage for the `/statusline` preview/setup rendering paths - Hyperlink rendering coverage for clickable PR statusline cells
This commit is contained in:
committed by
GitHub
Unverified
parent
c2fed01550
commit
cc16995cc6
@@ -76,6 +76,8 @@ use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use crate::workspace_command::AppServerWorkspaceCommandRunner;
|
||||
use crate::workspace_command::WorkspaceCommandRunner;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_client::TypedRequestError;
|
||||
@@ -432,6 +434,7 @@ pub(crate) struct App {
|
||||
pub(crate) session_telemetry: SessionTelemetry,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) chat_widget: ChatWidget,
|
||||
workspace_command_runner: Option<WorkspaceCommandRunner>,
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
pub(crate) config: Config,
|
||||
pub(crate) state_db: Option<StateDbHandle>,
|
||||
@@ -574,6 +577,7 @@ impl App {
|
||||
config: cfg,
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
workspace_command_runner: self.workspace_command_runner.clone(),
|
||||
initial_user_message,
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
has_chatgpt_account: self.chat_widget.has_chatgpt_account(),
|
||||
@@ -712,6 +716,9 @@ impl App {
|
||||
|
||||
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
|
||||
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
|
||||
let workspace_command_runner: WorkspaceCommandRunner = Arc::new(
|
||||
AppServerWorkspaceCommandRunner::new(app_server.request_handle()),
|
||||
);
|
||||
let runtime_model_provider_base_url =
|
||||
resolve_runtime_model_provider_base_url(&config.model_provider).await;
|
||||
|
||||
@@ -734,6 +741,7 @@ impl App {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
initial_user_message: crate::chatwidget::create_initial_user_message(
|
||||
initial_prompt.clone(),
|
||||
initial_images.clone(),
|
||||
@@ -769,6 +777,7 @@ impl App {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
initial_user_message: crate::chatwidget::create_initial_user_message(
|
||||
initial_prompt.clone(),
|
||||
initial_images.clone(),
|
||||
@@ -809,6 +818,7 @@ impl App {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
initial_user_message: crate::chatwidget::create_initial_user_message(
|
||||
initial_prompt.clone(),
|
||||
initial_images.clone(),
|
||||
@@ -856,6 +866,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
session_telemetry: session_telemetry.clone(),
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
workspace_command_runner: Some(workspace_command_runner),
|
||||
config,
|
||||
state_db,
|
||||
active_profile,
|
||||
|
||||
@@ -1848,6 +1848,10 @@ impl App {
|
||||
self.chat_widget.set_status_line_branch(cwd, branch);
|
||||
self.refresh_status_line();
|
||||
}
|
||||
AppEvent::StatusLineGitSummaryUpdated { cwd, summary } => {
|
||||
self.chat_widget.set_status_line_git_summary(cwd, summary);
|
||||
self.refresh_status_line();
|
||||
}
|
||||
AppEvent::StatusLineSetupCancelled => {
|
||||
self.chat_widget.cancel_status_line_setup();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
session_telemetry,
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
workspace_command_runner: None,
|
||||
config,
|
||||
state_db: None,
|
||||
active_profile: None,
|
||||
|
||||
@@ -424,6 +424,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
|
||||
config,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
app_event_tx: app.app_event_tx.clone(),
|
||||
workspace_command_runner: None,
|
||||
initial_user_message: create_initial_user_message(
|
||||
Some(initial_prompt.clone()),
|
||||
Vec::new(),
|
||||
@@ -3770,6 +3771,7 @@ async fn make_test_app() -> App {
|
||||
session_telemetry,
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
workspace_command_runner: None,
|
||||
config,
|
||||
state_db: None,
|
||||
active_profile: None,
|
||||
@@ -3832,6 +3834,7 @@ async fn make_test_app_with_channels() -> (
|
||||
session_telemetry,
|
||||
app_event_tx,
|
||||
chat_widget,
|
||||
workspace_command_runner: None,
|
||||
config,
|
||||
state_db: None,
|
||||
active_profile: None,
|
||||
@@ -4733,6 +4736,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
|
||||
config: app.config.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
app_event_tx: app.app_event_tx.clone(),
|
||||
workspace_command_runner: None,
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: app.enhanced_keys_supported,
|
||||
has_chatgpt_account: app.chat_widget.has_chatgpt_account(),
|
||||
|
||||
@@ -827,6 +827,11 @@ pub(crate) enum AppEvent {
|
||||
cwd: PathBuf,
|
||||
branch: Option<String>,
|
||||
},
|
||||
/// Async update of Git summary fields for status line rendering.
|
||||
StatusLineGitSummaryUpdated {
|
||||
cwd: PathBuf,
|
||||
summary: crate::chatwidget::StatusLineGitSummary,
|
||||
},
|
||||
/// Apply a user-confirmed status-line item ordering/selection.
|
||||
StatusLineSetup {
|
||||
items: Vec<StatusLineItem>,
|
||||
|
||||
@@ -190,6 +190,7 @@ use crate::keymap::EditorKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::keymap::VimNormalKeymap;
|
||||
use crate::keymap::primary_binding;
|
||||
use crate::onboarding::mark_underlined_hyperlink;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::render::renderable::Renderable;
|
||||
@@ -396,6 +397,7 @@ pub(crate) struct ChatComposer {
|
||||
side_conversation_active: bool,
|
||||
is_zellij: bool,
|
||||
status_line_value: Option<Line<'static>>,
|
||||
status_line_hyperlink_url: Option<String>,
|
||||
status_line_enabled: bool,
|
||||
side_conversation_context_label: Option<String>,
|
||||
// Agent label injected into the footer's contextual row when multi-agent mode is active.
|
||||
@@ -580,6 +582,7 @@ impl ChatComposer {
|
||||
Some(codex_terminal_detection::Multiplexer::Zellij {})
|
||||
),
|
||||
status_line_value: None,
|
||||
status_line_hyperlink_url: None,
|
||||
status_line_enabled: false,
|
||||
side_conversation_context_label: None,
|
||||
active_agent_label: None,
|
||||
@@ -4037,6 +4040,14 @@ impl ChatComposer {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) -> bool {
|
||||
if self.status_line_hyperlink_url == url {
|
||||
return false;
|
||||
}
|
||||
self.status_line_hyperlink_url = url;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool {
|
||||
if self.status_line_enabled == enabled {
|
||||
return false;
|
||||
@@ -4441,6 +4452,11 @@ impl ChatComposer {
|
||||
if show_right && let Some(line) = &right_line {
|
||||
render_context_right(hint_rect, buf, line);
|
||||
}
|
||||
if status_line_active
|
||||
&& let Some(url) = self.status_line_hyperlink_url.as_deref()
|
||||
{
|
||||
mark_underlined_hyperlink(buf, hint_rect, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5022,6 +5038,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_hyperlink_marks_pr_number_cells() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ true,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
let url = "https://github.com/openai/codex/pull/20252";
|
||||
composer.set_status_line_enabled(/*enabled*/ true);
|
||||
composer.set_status_line(Some(Line::from(Span::styled(
|
||||
"PR #20252",
|
||||
Style::default().cyan().underlined(),
|
||||
))));
|
||||
composer.set_status_line_hyperlink(Some(url.to_string()));
|
||||
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
composer.render(area, &mut buf);
|
||||
|
||||
let marked_cells = (area.top()..area.bottom())
|
||||
.flat_map(|y| (area.left()..area.right()).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| buf[(x, y)].symbol().contains(url))
|
||||
.count();
|
||||
assert_eq!(
|
||||
marked_cells,
|
||||
"PR #20252".chars().filter(|ch| !ch.is_whitespace()).count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_exits_empty_shell_mode() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -1544,6 +1544,12 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) {
|
||||
if self.composer.set_status_line_hyperlink(url) {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) {
|
||||
if self.composer.set_status_line_enabled(enabled) {
|
||||
self.request_redraw();
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ expression: "render_lines(&view, 72)"
|
||||
[x] git-branch Current Git branch (omitted when unavaila…
|
||||
[ ] model-with-reasoning Current model name with reasoning level
|
||||
[ ] project-name Project name (omitted when unavailable)
|
||||
[ ] run-state Compact session run-state text (Ready, Wo…
|
||||
[ ] pull-request-number Open pull request number for the current …
|
||||
|
||||
gpt-5-codex · ~/codex-rs · jif/statusline-preview
|
||||
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc
|
||||
|
||||
@@ -71,6 +71,12 @@ pub(crate) enum StatusLineItem {
|
||||
/// Current git branch name (if in a repository).
|
||||
GitBranch,
|
||||
|
||||
/// Open pull request number for the current branch.
|
||||
PullRequestNumber,
|
||||
|
||||
/// Committed branch diff stats relative to the default branch.
|
||||
BranchChanges,
|
||||
|
||||
/// Compact runtime run-state text.
|
||||
#[strum(to_string = "run-state", serialize = "status")]
|
||||
Status,
|
||||
@@ -127,6 +133,12 @@ impl StatusLineItem {
|
||||
StatusLineItem::CurrentDir => "Current working directory",
|
||||
StatusLineItem::ProjectRoot => "Project name (omitted when unavailable)",
|
||||
StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)",
|
||||
StatusLineItem::PullRequestNumber => {
|
||||
"Open pull request number for the current branch (omitted when unavailable)"
|
||||
}
|
||||
StatusLineItem::BranchChanges => {
|
||||
"Committed branch changes against the default branch (omitted when unavailable)"
|
||||
}
|
||||
StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)",
|
||||
StatusLineItem::ContextRemaining => {
|
||||
"Percentage of context window remaining (omitted when unknown)"
|
||||
@@ -165,6 +177,8 @@ impl StatusLineItem {
|
||||
StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir,
|
||||
StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot,
|
||||
StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch,
|
||||
StatusLineItem::PullRequestNumber => StatusSurfacePreviewItem::PullRequestNumber,
|
||||
StatusLineItem::BranchChanges => StatusSurfacePreviewItem::BranchChanges,
|
||||
StatusLineItem::Status => StatusSurfacePreviewItem::Status,
|
||||
StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining,
|
||||
StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed,
|
||||
@@ -409,6 +423,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_summary_items_are_selectable_ids() {
|
||||
assert_eq!(
|
||||
"pull-request-number".parse::<StatusLineItem>(),
|
||||
Ok(StatusLineItem::PullRequestNumber)
|
||||
);
|
||||
assert_eq!(
|
||||
"branch-changes".parse::<StatusLineItem>(),
|
||||
Ok(StatusLineItem::BranchChanges)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_status_line_items_accepts_title_only_variants() {
|
||||
let items = ["run-state", "task-progress"]
|
||||
|
||||
@@ -32,7 +32,9 @@ impl StatusLineAccent {
|
||||
match item {
|
||||
StatusLineItem::ModelName | StatusLineItem::ModelWithReasoning => Self::Model,
|
||||
StatusLineItem::CurrentDir | StatusLineItem::ProjectRoot => Self::Path,
|
||||
StatusLineItem::GitBranch => Self::Branch,
|
||||
StatusLineItem::GitBranch
|
||||
| StatusLineItem::PullRequestNumber
|
||||
| StatusLineItem::BranchChanges => Self::Branch,
|
||||
StatusLineItem::Status => Self::State,
|
||||
StatusLineItem::ContextRemaining
|
||||
| StatusLineItem::ContextUsed
|
||||
@@ -106,6 +108,11 @@ where
|
||||
} else {
|
||||
Style::default().dim()
|
||||
};
|
||||
let style = if item == StatusLineItem::PullRequestNumber {
|
||||
style.underlined()
|
||||
} else {
|
||||
style
|
||||
};
|
||||
spans.push(Span::styled(text, style));
|
||||
}
|
||||
|
||||
@@ -256,6 +263,25 @@ mod tests {
|
||||
assert!(line.spans[2].style.add_modifier.contains(Modifier::DIM));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_number_uses_link_style() {
|
||||
let line = status_line_from_segments_with_resolver(
|
||||
[(StatusLineItem::PullRequestNumber, "PR #20252".to_string())],
|
||||
/*use_theme_colors*/ false,
|
||||
|_| None,
|
||||
)
|
||||
.expect("status line");
|
||||
|
||||
assert_eq!(line.spans[0].style.fg, None);
|
||||
assert!(line.spans[0].style.add_modifier.contains(Modifier::DIM));
|
||||
assert!(
|
||||
line.spans[0]
|
||||
.style
|
||||
.add_modifier
|
||||
.contains(Modifier::UNDERLINED)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_segments_return_none_when_empty() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -14,6 +14,8 @@ pub(crate) enum StatusSurfacePreviewItem {
|
||||
Status,
|
||||
ThreadTitle,
|
||||
GitBranch,
|
||||
PullRequestNumber,
|
||||
BranchChanges,
|
||||
ContextRemaining,
|
||||
ContextUsed,
|
||||
FiveHourLimit,
|
||||
@@ -40,6 +42,8 @@ impl StatusSurfacePreviewItem {
|
||||
StatusSurfacePreviewItem::Status => "Working",
|
||||
StatusSurfacePreviewItem::ThreadTitle => "thread title",
|
||||
StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature",
|
||||
StatusSurfacePreviewItem::PullRequestNumber => "PR #123",
|
||||
StatusSurfacePreviewItem::BranchChanges => "+12 -3",
|
||||
StatusSurfacePreviewItem::ContextRemaining => "Context 0% left",
|
||||
StatusSurfacePreviewItem::ContextUsed => "Context 0% used",
|
||||
StatusSurfacePreviewItem::FiveHourLimit => "5h 0%",
|
||||
@@ -66,6 +70,8 @@ impl StatusSurfacePreviewItem {
|
||||
Self::Status,
|
||||
Self::ThreadTitle,
|
||||
Self::GitBranch,
|
||||
Self::PullRequestNumber,
|
||||
Self::BranchChanges,
|
||||
Self::ContextRemaining,
|
||||
Self::ContextUsed,
|
||||
Self::FiveHourLimit,
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
//! Branch and pull-request metadata for TUI status-line items.
|
||||
//!
|
||||
//! This module owns the git and GitHub probes behind the TUI `git-branch`, `pull-request-number`,
|
||||
//! and `branch-changes` status-line items. It deliberately talks only to a
|
||||
//! `WorkspaceCommandExecutor`, not to `tokio::process::Command`, so the same lookup logic works
|
||||
//! when the TUI is connected to either an embedded or remote app-server.
|
||||
//!
|
||||
//! All lookups are best-effort. A failed command, missing `git` or `gh`, unauthenticated GitHub
|
||||
//! CLI, non-git directory, or ambiguous repository state should result in absent optional metadata
|
||||
//! rather than a user-visible error. The status line can then render whichever pieces are available
|
||||
//! without blocking the rest of the UI.
|
||||
|
||||
#[cfg(test)]
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::workspace_command::WorkspaceCommand;
|
||||
#[cfg(test)]
|
||||
use crate::workspace_command::WorkspaceCommandError;
|
||||
use crate::workspace_command::WorkspaceCommandExecutor;
|
||||
use crate::workspace_command::WorkspaceCommandOutput;
|
||||
|
||||
/// Additions and deletions between `HEAD` and a branch comparison base.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct GitBranchDiffStats {
|
||||
/// Total added lines in committed changes on the current branch.
|
||||
pub(crate) additions: u64,
|
||||
/// Total deleted lines in committed changes on the current branch.
|
||||
pub(crate) deletions: u64,
|
||||
}
|
||||
|
||||
/// Combined git metadata cached by the status line for one working directory.
|
||||
///
|
||||
/// A summary may contain only one of the fields when the other probe fails. Renderers should treat
|
||||
/// missing fields as omitted optional UI rather than as a hard lookup failure.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct StatusLineGitSummary {
|
||||
/// Open pull request associated with the current branch or HEAD commit.
|
||||
pub(crate) pull_request: Option<StatusLinePullRequest>,
|
||||
/// Additions and deletions between `HEAD` and the repository default branch merge base.
|
||||
pub(crate) branch_change_stats: Option<GitBranchDiffStats>,
|
||||
}
|
||||
|
||||
/// Open GitHub pull request shown by the `pull-request-number` status-line item.
|
||||
///
|
||||
/// The URL is kept with the number so clickable renderers can open the same PR represented by the
|
||||
/// compact label. Callers should only construct this for open PRs; closed or merged PRs are filtered
|
||||
/// out by this module.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct StatusLinePullRequest {
|
||||
/// GitHub pull request number.
|
||||
pub(crate) number: u64,
|
||||
/// Browser URL for the pull request.
|
||||
pub(crate) url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct DefaultBranch {
|
||||
/// Git ref used for merge-base comparison.
|
||||
///
|
||||
/// This may be a remote-tracking ref such as `refs/remotes/origin/main`, which avoids
|
||||
/// comparing against a stale or absent local `main` branch.
|
||||
merge_ref: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhPullRequestView {
|
||||
number: u64,
|
||||
url: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhPullRequestApiItem {
|
||||
number: u64,
|
||||
#[serde(rename = "html_url")]
|
||||
url: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhRepoView {
|
||||
#[serde(rename = "nameWithOwner")]
|
||||
name_with_owner: Option<String>,
|
||||
parent: Option<GhRepoParent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GhRepoParent {
|
||||
#[serde(rename = "nameWithOwner")]
|
||||
name_with_owner: String,
|
||||
}
|
||||
|
||||
/// Returns the checked-out branch name for one status-line working directory.
|
||||
///
|
||||
/// Detached HEADs, non-git directories, and command failures return `None` so the renderer can
|
||||
/// omit the branch item without surfacing a background lookup error.
|
||||
pub(crate) async fn current_branch_name(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> Option<String> {
|
||||
let output = run_git_command(runner, cwd, &["branch", "--show-current"])
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(output.stdout.trim().to_string()).filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
/// Resolves PR and branch-change metadata for one status-line working directory.
|
||||
///
|
||||
/// The PR and diff-stat probes run concurrently because each is independent and both are optional.
|
||||
/// The returned summary is suitable for caching by `cwd`; callers should discard it if the active
|
||||
/// status-line cwd changes before the async lookup completes.
|
||||
pub(crate) async fn status_line_git_summary(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> StatusLineGitSummary {
|
||||
let (pull_request, branch_change_stats) = tokio::join!(
|
||||
open_pull_request(runner, cwd),
|
||||
branch_diff_stats_to_default_branch(runner, cwd),
|
||||
);
|
||||
StatusLineGitSummary {
|
||||
pull_request,
|
||||
branch_change_stats,
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts committed line changes between `HEAD` and the repository default branch.
|
||||
///
|
||||
/// The comparison base is the merge base with a verified default-branch ref. Uncommitted working
|
||||
/// tree edits are intentionally ignored because the status-line item summarizes the checked-out
|
||||
/// branch, not the current dirty worktree.
|
||||
async fn branch_diff_stats_to_default_branch(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> Option<GitBranchDiffStats> {
|
||||
let git_dir = run_git_command(runner, cwd, &["rev-parse", "--git-dir"])
|
||||
.await
|
||||
.ok()?;
|
||||
if !git_dir.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let default_branch = get_default_branch(runner, cwd).await?;
|
||||
let merge_base = run_git_command(
|
||||
runner,
|
||||
cwd,
|
||||
&["merge-base", "HEAD", &default_branch.merge_ref],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !merge_base.success() {
|
||||
return None;
|
||||
}
|
||||
let merge_base = merge_base.stdout.trim();
|
||||
if merge_base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let range = format!("{merge_base}..HEAD");
|
||||
let numstat = run_git_command(runner, cwd, &["diff", "--numstat", &range])
|
||||
.await
|
||||
.ok()?;
|
||||
if !numstat.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut additions = 0_u64;
|
||||
let mut deletions = 0_u64;
|
||||
for line in numstat.stdout.lines() {
|
||||
let mut columns = line.split('\t');
|
||||
additions += columns
|
||||
.next()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(0);
|
||||
deletions += columns
|
||||
.next()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
|
||||
Some(GitBranchDiffStats {
|
||||
additions,
|
||||
deletions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns git remotes in the order used for default-branch discovery.
|
||||
///
|
||||
/// `origin` is prioritized because most repositories use it as the canonical upstream. Other
|
||||
/// remotes are still tried so fork or enterprise layouts with a differently named upstream can
|
||||
/// produce branch-change stats when their remote HEAD is configured.
|
||||
async fn get_git_remotes(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option<Vec<String>> {
|
||||
let output = run_git_command(runner, cwd, &["remote"]).await.ok()?;
|
||||
if !output.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut remotes: Vec<String> = output.stdout.lines().map(str::to_string).collect();
|
||||
if let Some(pos) = remotes.iter().position(|remote| remote == "origin") {
|
||||
let origin = remotes.remove(pos);
|
||||
remotes.insert(0, origin);
|
||||
}
|
||||
Some(remotes)
|
||||
}
|
||||
|
||||
/// Resolves the default branch ref that should be used for branch-change comparisons.
|
||||
///
|
||||
/// The lookup prefers remote-tracking refs over local branches so feature-only clones and stale
|
||||
/// local `main` branches do not inflate the status-line diff. When no remote default is available,
|
||||
/// local `main` or `master` is used as a last resort.
|
||||
async fn get_default_branch(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> Option<DefaultBranch> {
|
||||
let remotes = get_git_remotes(runner, cwd).await.unwrap_or_default();
|
||||
for remote in remotes {
|
||||
if let Some(branch) =
|
||||
get_remote_default_branch_from_symbolic_ref(runner, cwd, &remote).await
|
||||
{
|
||||
return Some(branch);
|
||||
}
|
||||
|
||||
if let Some(branch) = get_remote_default_branch_from_remote_show(runner, cwd, &remote).await
|
||||
{
|
||||
return Some(branch);
|
||||
}
|
||||
}
|
||||
|
||||
get_default_branch_local(runner, cwd).await
|
||||
}
|
||||
|
||||
/// Resolves a remote's symbolic HEAD into a concrete remote-tracking ref.
|
||||
///
|
||||
/// The returned ref is verified before use. Without that check, a symbolic `origin/HEAD` left over
|
||||
/// from an old fetch could point at a ref that no longer exists, causing the later merge-base probe
|
||||
/// to fail in a less obvious place.
|
||||
async fn get_remote_default_branch_from_symbolic_ref(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
remote: &str,
|
||||
) -> Option<DefaultBranch> {
|
||||
let remote_head = format!("refs/remotes/{remote}/HEAD");
|
||||
let output = run_git_command(runner, cwd, &["symbolic-ref", "--quiet", &remote_head])
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let trimmed = output.stdout.trim();
|
||||
let remote_ref_prefix = format!("refs/remotes/{remote}/");
|
||||
trimmed.strip_prefix(&remote_ref_prefix)?;
|
||||
if !git_ref_exists(runner, cwd, trimmed).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(DefaultBranch {
|
||||
merge_ref: trimmed.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses `git remote show` output to discover a remote's default branch ref.
|
||||
///
|
||||
/// This is a fallback for repositories where `refs/remotes/<remote>/HEAD` is not configured but
|
||||
/// `git remote show` can still report the upstream HEAD branch. The concrete remote-tracking ref
|
||||
/// must already exist locally before it is accepted.
|
||||
async fn get_remote_default_branch_from_remote_show(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
remote: &str,
|
||||
) -> Option<DefaultBranch> {
|
||||
let output = run_git_command(runner, cwd, &["remote", "show", remote])
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
for line in output.stdout.lines() {
|
||||
let line = line.trim();
|
||||
let Some(rest) = line.strip_prefix("HEAD branch:") else {
|
||||
continue;
|
||||
};
|
||||
let name = rest.trim();
|
||||
let remote_ref = format!("refs/remotes/{remote}/{name}");
|
||||
if !name.is_empty() && git_ref_exists(runner, cwd, &remote_ref).await {
|
||||
return Some(DefaultBranch {
|
||||
merge_ref: remote_ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Falls back to local `main` or `master` when no remote default branch can be found.
|
||||
async fn get_default_branch_local(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> Option<DefaultBranch> {
|
||||
for candidate in ["main", "master"] {
|
||||
let local_ref = format!("refs/heads/{candidate}");
|
||||
if git_ref_exists(runner, cwd, &local_ref).await {
|
||||
return Some(DefaultBranch {
|
||||
merge_ref: local_ref,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Checks whether a git ref exists in the status-line working directory.
|
||||
async fn git_ref_exists(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
reference: &str,
|
||||
) -> bool {
|
||||
run_git_command(
|
||||
runner,
|
||||
cwd,
|
||||
&["rev-parse", "--verify", "--quiet", reference],
|
||||
)
|
||||
.await
|
||||
.is_ok_and(|output| output.success())
|
||||
}
|
||||
|
||||
/// Resolves the open PR associated with the current checkout.
|
||||
///
|
||||
/// Branch-based lookup is attempted first because it is cheap and mirrors `gh pr view`. Commit-based
|
||||
/// lookup is used as a fallback so fork workflows can still find a PR opened against the upstream
|
||||
/// repository even when `gh` infers the fork from the current checkout.
|
||||
async fn open_pull_request(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> Option<StatusLinePullRequest> {
|
||||
if let Some(pull_request) = open_pull_request_for_current_branch(runner, cwd).await {
|
||||
return Some(pull_request);
|
||||
}
|
||||
|
||||
open_pull_request_for_head_commit(runner, cwd).await
|
||||
}
|
||||
|
||||
/// Uses GitHub CLI's current-branch PR lookup.
|
||||
async fn open_pull_request_for_current_branch(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> Option<StatusLinePullRequest> {
|
||||
let output = run_gh_command(runner, cwd, &["pr", "view", "--json", "number,url,state"])
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.success() {
|
||||
return None;
|
||||
}
|
||||
pull_request_from_view_output(&output.stdout)
|
||||
}
|
||||
|
||||
/// Looks up open PRs for `HEAD` across the upstream/fork repository search order.
|
||||
async fn open_pull_request_for_head_commit(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> Option<StatusLinePullRequest> {
|
||||
let head_sha = current_head_sha(runner, cwd).await?;
|
||||
for repo in gh_repo_search_order(runner, cwd).await? {
|
||||
let endpoint = format!("repos/{repo}/commits/{head_sha}/pulls");
|
||||
let output = run_gh_command(
|
||||
runner,
|
||||
cwd,
|
||||
&[
|
||||
"api",
|
||||
"-H",
|
||||
"Accept: application/vnd.github+json",
|
||||
&endpoint,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if output.success()
|
||||
&& let Some(pull_request) = pull_request_from_api_output(&output.stdout)
|
||||
{
|
||||
return Some(pull_request);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the current `HEAD` SHA for commit-based PR lookup.
|
||||
async fn current_head_sha(runner: &dyn WorkspaceCommandExecutor, cwd: &Path) -> Option<String> {
|
||||
let output = run_git_command(runner, cwd, &["rev-parse", "HEAD"])
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(output.stdout.trim().to_string()).filter(|sha| !sha.is_empty())
|
||||
}
|
||||
|
||||
/// Returns repositories to query for commit-associated PRs, with parent before fork.
|
||||
async fn gh_repo_search_order(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
) -> Option<Vec<String>> {
|
||||
let output = run_gh_command(
|
||||
runner,
|
||||
cwd,
|
||||
&["repo", "view", "--json", "nameWithOwner,parent"],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
repo_search_order_from_output(&output.stdout)
|
||||
}
|
||||
|
||||
/// Parses `gh pr view --json number,url,state` output for an open PR.
|
||||
fn pull_request_from_view_output(stdout: &str) -> Option<StatusLinePullRequest> {
|
||||
let pull_request = serde_json::from_str::<GhPullRequestView>(stdout).ok()?;
|
||||
pull_request
|
||||
.state
|
||||
.eq_ignore_ascii_case("open")
|
||||
.then_some(StatusLinePullRequest {
|
||||
number: pull_request.number,
|
||||
url: pull_request.url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses the GitHub REST commit-to-PR response and returns the first open PR.
|
||||
fn pull_request_from_api_output(stdout: &str) -> Option<StatusLinePullRequest> {
|
||||
serde_json::from_str::<Vec<GhPullRequestApiItem>>(stdout)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|pull_request| pull_request.state.eq_ignore_ascii_case("open"))
|
||||
.map(|pull_request| StatusLinePullRequest {
|
||||
number: pull_request.number,
|
||||
url: pull_request.url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses `gh repo view` output into the repository search order for fallback PR lookup.
|
||||
///
|
||||
/// Parent-first ordering matches upstream PR workflows: a branch may be checked out from a fork
|
||||
/// while the open PR lives on the parent repository.
|
||||
fn repo_search_order_from_output(stdout: &str) -> Option<Vec<String>> {
|
||||
let repo = serde_json::from_str::<GhRepoView>(stdout).ok()?;
|
||||
let mut repos = Vec::new();
|
||||
if let Some(parent) = repo.parent {
|
||||
repos.push(parent.name_with_owner);
|
||||
}
|
||||
if let Some(name_with_owner) = repo.name_with_owner
|
||||
&& !repos.iter().any(|repo| repo == &name_with_owner)
|
||||
{
|
||||
repos.push(name_with_owner);
|
||||
}
|
||||
if repos.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(repos)
|
||||
}
|
||||
|
||||
/// Runs a git command through the workspace-command abstraction.
|
||||
async fn run_git_command(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
args: &[&str],
|
||||
) -> Result<WorkspaceCommandOutput, crate::workspace_command::WorkspaceCommandError> {
|
||||
let mut argv = Vec::with_capacity(args.len() + 1);
|
||||
argv.push("git".to_string());
|
||||
argv.extend(args.iter().map(|arg| (*arg).to_string()));
|
||||
runner
|
||||
.run(
|
||||
WorkspaceCommand::new(argv)
|
||||
.cwd(cwd.to_path_buf())
|
||||
.env("GIT_OPTIONAL_LOCKS", "0"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Runs a GitHub CLI command through the workspace-command abstraction.
|
||||
///
|
||||
/// Prompting is disabled because status-line probes are background UI work. A command that needs
|
||||
/// authentication or user input should fail and leave the optional PR item hidden.
|
||||
async fn run_gh_command(
|
||||
runner: &dyn WorkspaceCommandExecutor,
|
||||
cwd: &Path,
|
||||
args: &[&str],
|
||||
) -> Result<WorkspaceCommandOutput, crate::workspace_command::WorkspaceCommandError> {
|
||||
let mut argv = Vec::with_capacity(args.len() + 1);
|
||||
argv.push("gh".to_string());
|
||||
argv.extend(args.iter().map(|arg| (*arg).to_string()));
|
||||
runner
|
||||
.run(
|
||||
WorkspaceCommand::new(argv)
|
||||
.cwd(cwd.to_path_buf())
|
||||
.env("GH_PROMPT_DISABLED", "1")
|
||||
.env("GIT_TERMINAL_PROMPT", "0"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::workspace_command::WorkspaceCommand;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[tokio::test]
|
||||
async fn branch_diff_stats_prefers_remote_default_ref_over_stale_local_branch() {
|
||||
let runner = FakeRunner::new(vec![
|
||||
response(
|
||||
&["git", "rev-parse", "--git-dir"],
|
||||
/*exit_code*/ 0,
|
||||
".git\n",
|
||||
),
|
||||
response(&["git", "remote"], /*exit_code*/ 0, "origin\n"),
|
||||
response(
|
||||
&["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
|
||||
/*exit_code*/ 0,
|
||||
"refs/remotes/origin/main\n",
|
||||
),
|
||||
response(
|
||||
&[
|
||||
"git",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
"refs/remotes/origin/main",
|
||||
],
|
||||
/*exit_code*/ 0,
|
||||
"remote-main-sha\n",
|
||||
),
|
||||
response(
|
||||
&["git", "merge-base", "HEAD", "refs/remotes/origin/main"],
|
||||
/*exit_code*/ 0,
|
||||
"base-sha\n",
|
||||
),
|
||||
response(
|
||||
&["git", "diff", "--numstat", "base-sha..HEAD"],
|
||||
/*exit_code*/ 0,
|
||||
"1\t0\tfile\n",
|
||||
),
|
||||
]);
|
||||
|
||||
let stats = branch_diff_stats_to_default_branch(&runner, Path::new("/repo"))
|
||||
.await
|
||||
.expect("branch diff stats");
|
||||
|
||||
assert_eq!(
|
||||
stats,
|
||||
GitBranchDiffStats {
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
}
|
||||
);
|
||||
assert!(runner.saw(&["git", "merge-base", "HEAD", "refs/remotes/origin/main"]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_pull_request_uses_current_branch_view_first() {
|
||||
let runner = FakeRunner::new(vec![response(
|
||||
&["gh", "pr", "view", "--json", "number,url,state"],
|
||||
/*exit_code*/ 0,
|
||||
r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"OPEN"}"#,
|
||||
)]);
|
||||
|
||||
let pull_request = open_pull_request(&runner, Path::new("/repo"))
|
||||
.await
|
||||
.expect("pull request");
|
||||
|
||||
assert_eq!(
|
||||
pull_request,
|
||||
StatusLinePullRequest {
|
||||
number: 20_252,
|
||||
url: "https://github.com/openai/codex/pull/20252".to_string(),
|
||||
}
|
||||
);
|
||||
assert!(!runner.saw(&["git", "rev-parse", "HEAD"]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_pull_request_falls_back_to_parent_repo_commit_lookup() {
|
||||
let runner = FakeRunner::new(vec![
|
||||
response(
|
||||
&["gh", "pr", "view", "--json", "number,url,state"],
|
||||
/*exit_code*/ 1,
|
||||
"",
|
||||
),
|
||||
response(
|
||||
&["git", "rev-parse", "HEAD"],
|
||||
/*exit_code*/ 0,
|
||||
"head-sha\n",
|
||||
),
|
||||
response(
|
||||
&["gh", "repo", "view", "--json", "nameWithOwner,parent"],
|
||||
/*exit_code*/ 0,
|
||||
r#"{"nameWithOwner":"fcoury/codex","parent":{"nameWithOwner":"openai/codex"}}"#,
|
||||
),
|
||||
response(
|
||||
&[
|
||||
"gh",
|
||||
"api",
|
||||
"-H",
|
||||
"Accept: application/vnd.github+json",
|
||||
"repos/openai/codex/commits/head-sha/pulls",
|
||||
],
|
||||
/*exit_code*/ 0,
|
||||
r#"[{"number":20252,"html_url":"https://github.com/openai/codex/pull/20252","state":"open"}]"#,
|
||||
),
|
||||
]);
|
||||
|
||||
let pull_request = open_pull_request(&runner, Path::new("/repo"))
|
||||
.await
|
||||
.expect("pull request");
|
||||
|
||||
assert_eq!(
|
||||
pull_request,
|
||||
StatusLinePullRequest {
|
||||
number: 20_252,
|
||||
url: "https://github.com/openai/codex/pull/20252".to_string(),
|
||||
}
|
||||
);
|
||||
assert!(runner.saw(&[
|
||||
"gh",
|
||||
"api",
|
||||
"-H",
|
||||
"Accept: application/vnd.github+json",
|
||||
"repos/openai/codex/commits/head-sha/pulls",
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_pr_view_parser_requires_open_pr() {
|
||||
assert_eq!(
|
||||
pull_request_from_view_output(
|
||||
r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"OPEN"}"#
|
||||
),
|
||||
Some(StatusLinePullRequest {
|
||||
number: 20_252,
|
||||
url: "https://github.com/openai/codex/pull/20252".to_string(),
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
pull_request_from_view_output(
|
||||
r#"{"number":20252,"url":"https://github.com/openai/codex/pull/20252","state":"MERGED"}"#
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_line_pr_fallback_searches_parent_repo_first() {
|
||||
assert_eq!(
|
||||
repo_search_order_from_output(
|
||||
r#"{"nameWithOwner":"fcoury/codex","parent":{"nameWithOwner":"openai/codex"}}"#
|
||||
),
|
||||
Some(vec!["openai/codex".to_string(), "fcoury/codex".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse {
|
||||
FakeResponse {
|
||||
argv: argv.iter().map(|arg| (*arg).to_string()).collect(),
|
||||
output: WorkspaceCommandOutput {
|
||||
exit_code,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeResponse {
|
||||
argv: Vec<String>,
|
||||
output: WorkspaceCommandOutput,
|
||||
}
|
||||
|
||||
struct FakeRunner {
|
||||
responses: Mutex<VecDeque<FakeResponse>>,
|
||||
seen: Mutex<Vec<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl FakeRunner {
|
||||
fn new(responses: Vec<FakeResponse>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(responses.into()),
|
||||
seen: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn saw(&self, argv: &[&str]) -> bool {
|
||||
let argv: Vec<String> = argv.iter().map(|arg| (*arg).to_string()).collect();
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("seen lock")
|
||||
.iter()
|
||||
.any(|seen| seen == &argv)
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceCommandExecutor for FakeRunner {
|
||||
fn run(
|
||||
&self,
|
||||
command: WorkspaceCommand,
|
||||
) -> Pin<
|
||||
Box<
|
||||
dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>>
|
||||
+ Send
|
||||
+ '_,
|
||||
>,
|
||||
> {
|
||||
self.seen
|
||||
.lock()
|
||||
.expect("seen lock")
|
||||
.push(command.argv.clone());
|
||||
Box::pin(async move {
|
||||
let mut responses = self.responses.lock().expect("responses lock");
|
||||
let index = responses
|
||||
.iter()
|
||||
.position(|response| response.argv == command.argv)
|
||||
.unwrap_or_else(|| panic!("missing fake response for {:?}", command.argv));
|
||||
let response = responses.remove(index).expect("fake response");
|
||||
Ok(response.output)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,11 +349,13 @@ use self::status_surfaces::TerminalTitleStatusKind;
|
||||
mod user_messages;
|
||||
use self::user_messages::PendingSteerCompareKey;
|
||||
use self::user_messages::UserMessageDisplay;
|
||||
pub(crate) use crate::branch_summary::StatusLineGitSummary;
|
||||
use crate::streaming::chunking::AdaptiveChunkingPolicy;
|
||||
use crate::streaming::commit_tick::CommitTickScope;
|
||||
use crate::streaming::commit_tick::run_commit_tick;
|
||||
use crate::streaming::controller::PlanStreamController;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use crate::workspace_command::WorkspaceCommandRunner;
|
||||
|
||||
use chrono::Local;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
@@ -554,6 +556,11 @@ pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) frame_requester: FrameRequester,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
/// App-server-backed runner used by status surfaces for workspace metadata probes.
|
||||
///
|
||||
/// Tests that do not exercise git status-line refreshes may leave this unset. Production TUI
|
||||
/// construction provides a runner for the active app-server session.
|
||||
pub(crate) workspace_command_runner: Option<WorkspaceCommandRunner>,
|
||||
pub(crate) initial_user_message: Option<UserMessage>,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) has_chatgpt_account: bool,
|
||||
@@ -972,6 +979,8 @@ pub(crate) struct ChatWidget {
|
||||
current_rollout_path: Option<PathBuf>,
|
||||
// Current working directory (if known)
|
||||
current_cwd: Option<PathBuf>,
|
||||
// App-server-backed command runner for status-line workspace metadata lookups.
|
||||
workspace_command_runner: Option<WorkspaceCommandRunner>,
|
||||
// Instruction source files loaded for the current session, supplied by app-server.
|
||||
instruction_source_paths: Vec<AbsolutePathBuf>,
|
||||
// Runtime network proxy bind addresses from SessionConfigured.
|
||||
@@ -1004,6 +1013,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 PR and branch-change summary for the active status-line cwd.
|
||||
status_line_git_summary: Option<StatusLineGitSummary>,
|
||||
// CWD used to resolve the cached Git summary; change resets summary state.
|
||||
status_line_git_summary_cwd: Option<PathBuf>,
|
||||
// True while an async Git summary lookup is in flight.
|
||||
status_line_git_summary_pending: bool,
|
||||
// True once we've attempted a Git summary lookup for the current CWD.
|
||||
status_line_git_summary_lookup_complete: bool,
|
||||
// Current thread-goal status shown in the status line when plan mode is inactive.
|
||||
current_goal_status_indicator: Option<GoalStatusIndicator>,
|
||||
current_goal_status: Option<GoalStatusState>,
|
||||
@@ -1831,6 +1848,11 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_status_line(status_line);
|
||||
}
|
||||
|
||||
/// Sets the terminal hyperlink target for the currently rendered footer status line.
|
||||
pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) {
|
||||
self.bottom_pane.set_status_line_hyperlink(url);
|
||||
}
|
||||
|
||||
/// Forwards the contextual active-agent label into the bottom-pane footer pipeline.
|
||||
///
|
||||
/// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the
|
||||
@@ -1929,6 +1951,22 @@ impl ChatWidget {
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
|
||||
/// Stores async Git summary lookup results for the current status-line cwd.
|
||||
pub(crate) fn set_status_line_git_summary(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
summary: StatusLineGitSummary,
|
||||
) {
|
||||
if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) {
|
||||
self.status_line_git_summary_pending = false;
|
||||
return;
|
||||
}
|
||||
self.status_line_git_summary = Some(summary);
|
||||
self.status_line_git_summary_pending = false;
|
||||
self.status_line_git_summary_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);
|
||||
@@ -2499,6 +2537,7 @@ impl ChatWidget {
|
||||
self.needs_final_message_separator = false;
|
||||
self.had_work_activity = false;
|
||||
self.request_status_line_branch_refresh();
|
||||
self.request_status_line_git_summary_refresh();
|
||||
}
|
||||
// Mark task stopped and request redraw now that all content is in history.
|
||||
self.pending_status_indicator_restore = false;
|
||||
@@ -2960,6 +2999,7 @@ impl ChatWidget {
|
||||
self.plan_stream_controller = None;
|
||||
self.pending_status_indicator_restore = false;
|
||||
self.request_status_line_branch_refresh();
|
||||
self.request_status_line_git_summary_refresh();
|
||||
self.maybe_show_pending_rate_limit_prompt();
|
||||
}
|
||||
|
||||
@@ -4768,6 +4808,7 @@ impl ChatWidget {
|
||||
config,
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
workspace_command_runner,
|
||||
initial_user_message,
|
||||
enhanced_keys_supported,
|
||||
has_chatgpt_account,
|
||||
@@ -4960,6 +5001,7 @@ impl ChatWidget {
|
||||
feedback,
|
||||
current_rollout_path: None,
|
||||
current_cwd,
|
||||
workspace_command_runner,
|
||||
instruction_source_paths: Vec::new(),
|
||||
session_network_proxy: None,
|
||||
status_line_invalid_items_warned,
|
||||
@@ -4973,6 +5015,10 @@ impl ChatWidget {
|
||||
status_line_branch_cwd: None,
|
||||
status_line_branch_pending: false,
|
||||
status_line_branch_lookup_complete: false,
|
||||
status_line_git_summary: None,
|
||||
status_line_git_summary_cwd: None,
|
||||
status_line_git_summary_pending: false,
|
||||
status_line_git_summary_lookup_complete: false,
|
||||
current_goal_status_indicator: None,
|
||||
current_goal_status: None,
|
||||
goal_status_active_turn_started_at: None,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::bottom_pane::status_line_from_segments;
|
||||
use crate::branch_summary;
|
||||
use crate::status::format_tokens_compact;
|
||||
|
||||
/// Items shown in the terminal title when the user has not configured a
|
||||
@@ -59,6 +60,14 @@ impl StatusSurfaceSelections {
|
||||
.terminal_title_items
|
||||
.contains(&TerminalTitleItem::GitBranch)
|
||||
}
|
||||
|
||||
fn uses_git_summary(&self) -> bool {
|
||||
self.status_line_items
|
||||
.contains(&StatusLineItem::PullRequestNumber)
|
||||
|| self
|
||||
.status_line_items
|
||||
.contains(&StatusLineItem::BranchChanges)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached project-root display name keyed by the cwd used for the last lookup.
|
||||
@@ -132,13 +141,24 @@ impl ChatWidget {
|
||||
self.status_line_branch = None;
|
||||
self.status_line_branch_pending = false;
|
||||
self.status_line_branch_lookup_complete = false;
|
||||
return;
|
||||
} 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 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);
|
||||
if !selections.uses_git_summary() {
|
||||
self.status_line_git_summary = None;
|
||||
self.status_line_git_summary_pending = false;
|
||||
self.status_line_git_summary_lookup_complete = false;
|
||||
} else {
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_git_summary_state(&cwd);
|
||||
if !self.status_line_git_summary_lookup_complete {
|
||||
self.request_status_line_git_summary(cwd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +167,7 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_status_line_enabled(enabled);
|
||||
if !enabled {
|
||||
self.set_status_line(/*status_line*/ None);
|
||||
self.set_status_line_hyperlink(/*url*/ None);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,6 +182,12 @@ impl ChatWidget {
|
||||
segments,
|
||||
self.config.tui_status_line_use_colors,
|
||||
));
|
||||
let hyperlink_url = selections
|
||||
.status_line_items
|
||||
.contains(&StatusLineItem::PullRequestNumber)
|
||||
.then(|| self.status_line_pull_request_url())
|
||||
.flatten();
|
||||
self.set_status_line_hyperlink(hyperlink_url);
|
||||
}
|
||||
|
||||
/// Clears the terminal title Codex most recently wrote, if any.
|
||||
@@ -348,6 +375,16 @@ impl ChatWidget {
|
||||
self.request_status_line_branch(cwd);
|
||||
}
|
||||
|
||||
pub(super) fn request_status_line_git_summary_refresh(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
if !selections.uses_git_summary() {
|
||||
return;
|
||||
}
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_git_summary_state(&cwd);
|
||||
self.request_status_line_git_summary(cwd);
|
||||
}
|
||||
|
||||
/// Parses configured status-line ids into known items and collects unknown ids.
|
||||
///
|
||||
/// Unknown ids are deduplicated in insertion order for warning messages.
|
||||
@@ -473,6 +510,16 @@ impl ChatWidget {
|
||||
self.status_line_branch_lookup_complete = false;
|
||||
}
|
||||
|
||||
fn sync_status_line_git_summary_state(&mut self, cwd: &Path) {
|
||||
if self.status_line_git_summary_cwd.as_deref() == Some(cwd) {
|
||||
return;
|
||||
}
|
||||
self.status_line_git_summary_cwd = Some(cwd.to_path_buf());
|
||||
self.status_line_git_summary = None;
|
||||
self.status_line_git_summary_pending = false;
|
||||
self.status_line_git_summary_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
|
||||
@@ -481,14 +528,34 @@ impl ChatWidget {
|
||||
if self.status_line_branch_pending {
|
||||
return;
|
||||
}
|
||||
let Some(runner) = self.workspace_command_runner.clone() else {
|
||||
self.status_line_branch_lookup_complete = true;
|
||||
return;
|
||||
};
|
||||
self.status_line_branch_pending = true;
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let branch = current_branch_name(&cwd).await;
|
||||
let branch = branch_summary::current_branch_name(runner.as_ref(), &cwd).await;
|
||||
tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch });
|
||||
});
|
||||
}
|
||||
|
||||
fn request_status_line_git_summary(&mut self, cwd: PathBuf) {
|
||||
if self.status_line_git_summary_pending {
|
||||
return;
|
||||
}
|
||||
let Some(runner) = self.workspace_command_runner.clone() else {
|
||||
self.status_line_git_summary_lookup_complete = true;
|
||||
return;
|
||||
};
|
||||
self.status_line_git_summary_pending = true;
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let summary = branch_summary::status_line_git_summary(runner.as_ref(), &cwd).await;
|
||||
tx.send(AppEvent::StatusLineGitSummaryUpdated { cwd, summary });
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolves a display string for one configured status-line item.
|
||||
///
|
||||
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
|
||||
@@ -506,6 +573,22 @@ impl ChatWidget {
|
||||
}
|
||||
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
|
||||
StatusLineItem::GitBranch => self.status_line_branch.clone(),
|
||||
StatusLineItem::PullRequestNumber => self
|
||||
.status_line_git_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.pull_request.as_ref())
|
||||
.map(|pull_request| format!("PR #{}", pull_request.number)),
|
||||
StatusLineItem::BranchChanges => self
|
||||
.status_line_git_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.branch_change_stats.as_ref())
|
||||
.map(|stats| {
|
||||
if stats.additions == 0 && stats.deletions == 0 {
|
||||
"No changes".to_string()
|
||||
} else {
|
||||
format!("+{} -{}", stats.additions, stats.deletions)
|
||||
}
|
||||
}),
|
||||
StatusLineItem::Status => Some(self.run_state_status_text()),
|
||||
StatusLineItem::UsedTokens => {
|
||||
let usage = self.status_line_total_usage();
|
||||
@@ -572,6 +655,13 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_line_pull_request_url(&self) -> Option<String> {
|
||||
self.status_line_git_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.pull_request.as_ref())
|
||||
.map(|pull_request| pull_request.url.clone())
|
||||
}
|
||||
|
||||
pub(super) fn status_surface_preview_value_for_item(
|
||||
&mut self,
|
||||
item: StatusSurfacePreviewItem,
|
||||
@@ -585,6 +675,8 @@ impl ChatWidget {
|
||||
StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir,
|
||||
StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle,
|
||||
StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch,
|
||||
StatusSurfacePreviewItem::PullRequestNumber => StatusLineItem::PullRequestNumber,
|
||||
StatusSurfacePreviewItem::BranchChanges => StatusLineItem::BranchChanges,
|
||||
StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining,
|
||||
StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed,
|
||||
StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit,
|
||||
|
||||
@@ -302,6 +302,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
current_rollout_path: None,
|
||||
current_cwd: None,
|
||||
workspace_command_runner: None,
|
||||
instruction_source_paths: Vec::new(),
|
||||
session_network_proxy: None,
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
@@ -315,6 +316,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_git_summary: None,
|
||||
status_line_git_summary_cwd: None,
|
||||
status_line_git_summary_pending: false,
|
||||
status_line_git_summary_lookup_complete: false,
|
||||
current_goal_status_indicator: None,
|
||||
current_goal_status: None,
|
||||
goal_status_active_turn_started_at: None,
|
||||
|
||||
@@ -1536,6 +1536,7 @@ async fn make_startup_chat_with_cli_overrides(
|
||||
config: cfg.clone(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
|
||||
workspace_command_runner: None,
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: false,
|
||||
has_chatgpt_account: false,
|
||||
|
||||
@@ -72,6 +72,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
|
||||
config: cfg.clone(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
|
||||
workspace_command_runner: None,
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: false,
|
||||
has_chatgpt_account: false,
|
||||
|
||||
@@ -131,6 +131,71 @@ async fn token_usage_update_uses_runtime_context_window() {
|
||||
"expected /status to avoid raw config context window, got: {context_line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_git_summary_items_render_values() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.status_line_git_summary = Some(StatusLineGitSummary {
|
||||
pull_request: Some(crate::branch_summary::StatusLinePullRequest {
|
||||
number: 20_252,
|
||||
url: "https://github.com/openai/codex/pull/20252".to_string(),
|
||||
}),
|
||||
branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats {
|
||||
additions: 143,
|
||||
deletions: 22,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::PullRequestNumber),
|
||||
Some("PR #20252".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::BranchChanges),
|
||||
Some("+143 -22".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_branch_changes_render_no_changes() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.status_line_git_summary = Some(StatusLineGitSummary {
|
||||
pull_request: None,
|
||||
branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats {
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::BranchChanges),
|
||||
Some("No changes".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_status_line_git_summary_update_is_ignored() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.status_line_git_summary_cwd = Some(PathBuf::from("/expected"));
|
||||
chat.status_line_git_summary_pending = true;
|
||||
|
||||
chat.set_status_line_git_summary(
|
||||
PathBuf::from("/other"),
|
||||
StatusLineGitSummary {
|
||||
pull_request: Some(crate::branch_summary::StatusLinePullRequest {
|
||||
number: 20_252,
|
||||
url: "https://github.com/openai/codex/pull/20252".to_string(),
|
||||
}),
|
||||
branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats {
|
||||
additions: 143,
|
||||
deletions: 22,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(chat.status_line_git_summary.is_none());
|
||||
assert!(!chat.status_line_git_summary_pending);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn helpers_are_available_and_do_not_panic() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -142,6 +207,7 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
config: cfg.clone(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: tx,
|
||||
workspace_command_runner: None,
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: false,
|
||||
has_chatgpt_account: false,
|
||||
@@ -1310,6 +1376,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() {
|
||||
#[tokio::test]
|
||||
async fn status_line_branch_refreshes_after_turn_complete() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
install_noop_workspace_command_runner(&mut chat);
|
||||
chat.config.tui_status_line = Some(vec!["git-branch".to_string()]);
|
||||
chat.status_line_branch_lookup_complete = true;
|
||||
chat.status_line_branch_pending = false;
|
||||
@@ -1322,6 +1389,7 @@ async fn status_line_branch_refreshes_after_turn_complete() {
|
||||
#[tokio::test]
|
||||
async fn status_line_branch_refreshes_after_interrupt() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
install_noop_workspace_command_runner(&mut chat);
|
||||
chat.config.tui_status_line = Some(vec!["git-branch".to_string()]);
|
||||
chat.status_line_branch_lookup_complete = true;
|
||||
chat.status_line_branch_pending = false;
|
||||
@@ -1331,6 +1399,37 @@ async fn status_line_branch_refreshes_after_interrupt() {
|
||||
assert!(chat.status_line_branch_pending);
|
||||
}
|
||||
|
||||
fn install_noop_workspace_command_runner(chat: &mut ChatWidget) {
|
||||
chat.workspace_command_runner = Some(std::sync::Arc::new(NoopWorkspaceCommandRunner));
|
||||
}
|
||||
|
||||
struct NoopWorkspaceCommandRunner;
|
||||
|
||||
impl crate::workspace_command::WorkspaceCommandExecutor for NoopWorkspaceCommandRunner {
|
||||
fn run(
|
||||
&self,
|
||||
_command: crate::workspace_command::WorkspaceCommand,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<
|
||||
Output = Result<
|
||||
crate::workspace_command::WorkspaceCommandOutput,
|
||||
crate::workspace_command::WorkspaceCommandError,
|
||||
>,
|
||||
> + Send
|
||||
+ '_,
|
||||
>,
|
||||
> {
|
||||
Box::pin(async {
|
||||
Ok(crate::workspace_command::WorkspaceCommandOutput {
|
||||
exit_code: 1,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_turn_clears_visible_running_hook() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -103,6 +103,7 @@ mod audio_device {
|
||||
}
|
||||
}
|
||||
mod bottom_pane;
|
||||
mod branch_summary;
|
||||
mod chatwidget;
|
||||
mod cli;
|
||||
mod clipboard_copy;
|
||||
@@ -188,6 +189,7 @@ mod version;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod voice;
|
||||
mod width;
|
||||
mod workspace_command;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(dead_code)]
|
||||
mod voice {
|
||||
|
||||
@@ -61,6 +61,24 @@ use crate::tui::FrameRequester;
|
||||
/// row boundary, which breaks normal terminal URL detection for long URLs that
|
||||
/// wrap across multiple rows.
|
||||
pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
mark_hyperlink_cells(buf, area, url, |cell| {
|
||||
cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED)
|
||||
});
|
||||
}
|
||||
|
||||
/// Marks any underlined buffer cells as an OSC 8 hyperlink.
|
||||
pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
mark_hyperlink_cells(buf, area, url, |cell| {
|
||||
cell.modifier.contains(Modifier::UNDERLINED)
|
||||
});
|
||||
}
|
||||
|
||||
fn mark_hyperlink_cells(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
url: &str,
|
||||
should_mark: impl Fn(&ratatui::buffer::Cell) -> bool,
|
||||
) {
|
||||
// Sanitize: strip any characters that could break out of the OSC 8
|
||||
// sequence (ESC or BEL) to prevent terminal escape injection from a
|
||||
// malformed or compromised upstream URL.
|
||||
@@ -75,8 +93,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
let cell = &mut buf[(x, y)];
|
||||
// Only mark cells that carry the URL's distinctive style.
|
||||
if cell.fg != Color::Cyan || !cell.modifier.contains(Modifier::UNDERLINED) {
|
||||
if !should_mark(cell) {
|
||||
continue;
|
||||
}
|
||||
let sym = cell.symbol().to_string();
|
||||
|
||||
@@ -2,5 +2,6 @@ mod auth;
|
||||
mod keys;
|
||||
pub(crate) mod onboarding_screen;
|
||||
mod trust_directory;
|
||||
pub(crate) use auth::mark_underlined_hyperlink;
|
||||
pub(crate) use auth::mark_url_hyperlink;
|
||||
mod welcome;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! App-server-backed workspace command execution for TUI-owned background lookups.
|
||||
//!
|
||||
//! This module is the TUI boundary for short, non-interactive commands that need to run wherever
|
||||
//! the active workspace lives. Callers describe a command in terms of argv, cwd, environment
|
||||
//! overrides, timeout, and output cap; the runner translates that request to app-server
|
||||
//! `command/exec`. Keeping this as a TUI-local abstraction lets status surfaces avoid knowing
|
||||
//! whether the current app-server is embedded or remote.
|
||||
//!
|
||||
//! Commands sent through this path are best-effort metadata probes. They should not prompt for
|
||||
//! stdin, should tolerate failure by omitting optional UI, and should keep output bounded so a
|
||||
//! status-line refresh cannot grow into an unbounded background process.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CommandExecParams;
|
||||
use codex_app_server_protocol::CommandExecResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Shared handle for running workspace commands from TUI components.
|
||||
pub(crate) type WorkspaceCommandRunner = Arc<dyn WorkspaceCommandExecutor>;
|
||||
|
||||
/// Describes a bounded non-interactive command to execute in the active workspace.
|
||||
///
|
||||
/// The command is intentionally argv-based rather than shell-based so callers do not need to quote
|
||||
/// user or repository data. `cwd` is interpreted by app-server relative to the workspace rules for
|
||||
/// the active session, which is what makes the same request shape work for embedded and remote
|
||||
/// app-server instances.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct WorkspaceCommand {
|
||||
/// Program and arguments to execute without shell interpolation.
|
||||
pub(crate) argv: Vec<String>,
|
||||
/// Working directory for the command, if different from app-server's session cwd.
|
||||
pub(crate) cwd: Option<PathBuf>,
|
||||
/// Environment overrides where `None` removes a variable.
|
||||
pub(crate) env: HashMap<String, Option<String>>,
|
||||
/// Maximum wall-clock duration before app-server cancels the command.
|
||||
pub(crate) timeout: Duration,
|
||||
/// Maximum captured stdout/stderr bytes returned by app-server.
|
||||
pub(crate) output_bytes_cap: usize,
|
||||
}
|
||||
|
||||
impl WorkspaceCommand {
|
||||
/// Creates a workspace command with conservative defaults for status-style metadata probes.
|
||||
pub(crate) fn new(argv: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
Self {
|
||||
argv: argv.into_iter().map(Into::into).collect(),
|
||||
cwd: None,
|
||||
env: HashMap::new(),
|
||||
timeout: Duration::from_secs(5),
|
||||
output_bytes_cap: 64 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the command working directory.
|
||||
pub(crate) fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
|
||||
self.cwd = Some(cwd.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds or replaces one environment variable override.
|
||||
pub(crate) fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.env.insert(key.into(), Some(value.into()));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Captured result from a completed workspace command.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct WorkspaceCommandOutput {
|
||||
/// Process exit status code reported by app-server.
|
||||
pub(crate) exit_code: i32,
|
||||
/// Captured stdout after app-server output capping.
|
||||
pub(crate) stdout: String,
|
||||
/// Captured stderr after app-server output capping.
|
||||
pub(crate) stderr: String,
|
||||
}
|
||||
|
||||
impl WorkspaceCommandOutput {
|
||||
/// Returns whether the process exited successfully.
|
||||
pub(crate) fn success(&self) -> bool {
|
||||
self.exit_code == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport or protocol failure before a command result was available.
|
||||
///
|
||||
/// Non-zero process exits are represented as `WorkspaceCommandOutput` so callers can distinguish
|
||||
/// a normal probe miss from an app-server request failure.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct WorkspaceCommandError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl WorkspaceCommandError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorkspaceCommandError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorkspaceCommandError {}
|
||||
|
||||
/// Executes non-interactive workspace commands through the active TUI app-server session.
|
||||
///
|
||||
/// Implementations decide where the workspace lives. Callers provide argv/cwd/env and should not
|
||||
/// branch on local versus remote execution.
|
||||
pub(crate) trait WorkspaceCommandExecutor: Send + Sync {
|
||||
/// Runs a workspace command and returns captured output or an app-server request error.
|
||||
///
|
||||
/// Callers should treat errors as infrastructure failures and should treat successful output
|
||||
/// with a non-zero exit code as ordinary command failure. Returning a future instead of using
|
||||
/// `async_trait` keeps the trait object-safe while matching the repo's native async trait
|
||||
/// conventions.
|
||||
fn run(
|
||||
&self,
|
||||
command: WorkspaceCommand,
|
||||
) -> Pin<
|
||||
Box<dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>> + Send + '_>,
|
||||
>;
|
||||
}
|
||||
|
||||
/// Workspace command runner that forwards every request to the active app-server.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AppServerWorkspaceCommandRunner {
|
||||
request_handle: AppServerRequestHandle,
|
||||
}
|
||||
|
||||
impl AppServerWorkspaceCommandRunner {
|
||||
/// Creates a runner from an app-server request handle owned by the current TUI session.
|
||||
pub(crate) fn new(request_handle: AppServerRequestHandle) -> Self {
|
||||
Self { request_handle }
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceCommandExecutor for AppServerWorkspaceCommandRunner {
|
||||
/// Sends the command as a one-off app-server `command/exec` request.
|
||||
///
|
||||
/// The request is non-tty, does not stream stdin/stdout/stderr, and uses the caller's timeout
|
||||
/// and output cap. It leaves sandbox and permission profile selection to app-server so the same
|
||||
/// runner follows the active session's embedded or remote execution policy.
|
||||
fn run(
|
||||
&self,
|
||||
command: WorkspaceCommand,
|
||||
) -> Pin<
|
||||
Box<dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>> + Send + '_>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
let timeout_ms = i64::try_from(command.timeout.as_millis()).unwrap_or(i64::MAX);
|
||||
let env = if command.env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(command.env)
|
||||
};
|
||||
let response: CommandExecResponse = self
|
||||
.request_handle
|
||||
.request_typed(ClientRequest::OneOffCommandExec {
|
||||
request_id: RequestId::String(format!("workspace-command-{}", Uuid::new_v4())),
|
||||
params: CommandExecParams {
|
||||
command: command.argv,
|
||||
process_id: None,
|
||||
tty: false,
|
||||
stream_stdin: false,
|
||||
stream_stdout_stderr: false,
|
||||
output_bytes_cap: Some(command.output_bytes_cap),
|
||||
disable_output_cap: false,
|
||||
disable_timeout: false,
|
||||
timeout_ms: Some(timeout_ms),
|
||||
cwd: command.cwd,
|
||||
env,
|
||||
size: None,
|
||||
sandbox_policy: None,
|
||||
permission_profile: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.map_err(|err| WorkspaceCommandError::new(err.to_string()))?;
|
||||
|
||||
Ok(WorkspaceCommandOutput {
|
||||
exit_code: response.exit_code,
|
||||
stdout: response.stdout,
|
||||
stderr: response.stderr,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user