mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
## What changed - Add the disabled-by-default `tui_extensions_statusline` feature to render configured status items and their picker preview through a shared UI tree renderer. - Support text, HTTP(S) links, rows, spacers, dividers, and gaps with rendering budgets, text sanitization, and width-aware truncation that keeps the ellipsis outside hyperlinks. - Use plain text for the extension status line and hide the picker's theme color toggle while preserving the saved color preference. ## Testing Add coverage for tree serialization and URL validation, Unicode widths, hyperlink clipping, picker previews, color preference preservation, and token usage updates across renderer switches. GitOrigin-RevId: a6b87c2b1096765d61296d73e142834fcd53ceee
468 lines
18 KiB
Rust
468 lines
18 KiB
Rust
//! Status output and setup controls for `ChatWidget`.
|
|
//!
|
|
//! Rendering details live in `status_surfaces`; this module owns the mutable
|
|
//! widget entrypoints that apply status state, open setup views, and update the
|
|
//! history-facing `/status` surface.
|
|
|
|
use super::*;
|
|
use crate::bottom_pane::footer_content::FooterContent;
|
|
use crate::status::thread_cost_micros;
|
|
|
|
impl ChatWidget {
|
|
/// Update the status indicator header and details.
|
|
///
|
|
/// Passing `None` clears any existing details. Returns whether the visible status indicator
|
|
/// requested a redraw.
|
|
pub(super) fn set_status(
|
|
&mut self,
|
|
header: String,
|
|
details: Option<String>,
|
|
details_capitalization: StatusDetailsCapitalization,
|
|
details_max_lines: usize,
|
|
) -> bool {
|
|
// Follow-up input and background activity must not obscure compaction.
|
|
// Retry errors still get their own status until the next notification.
|
|
let (header, details, details_max_lines) = if self.status_state.compaction.is_some()
|
|
&& self.status_state.retry_status_header.is_none()
|
|
{
|
|
(
|
|
compaction::COMPACTION_HEADER.to_string(),
|
|
Some(compaction::COMPACTION_DETAILS.to_string()),
|
|
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
|
)
|
|
} else {
|
|
(header, details, details_max_lines)
|
|
};
|
|
let details = details
|
|
.filter(|details| !details.is_empty())
|
|
.map(|details| {
|
|
let trimmed = details.trim_start();
|
|
match details_capitalization {
|
|
StatusDetailsCapitalization::CapitalizeFirst => {
|
|
crate::text_formatting::capitalize_first(trimmed)
|
|
}
|
|
StatusDetailsCapitalization::Preserve => trimmed.to_string(),
|
|
}
|
|
});
|
|
self.status_state.set_status(StatusIndicatorState {
|
|
header: header.clone(),
|
|
details: details.clone(),
|
|
details_max_lines,
|
|
});
|
|
let status_indicator_updated = self.bottom_pane.update_status(
|
|
header,
|
|
details,
|
|
StatusDetailsCapitalization::Preserve,
|
|
details_max_lines,
|
|
);
|
|
let title_uses_status =
|
|
self.local_settings
|
|
.tui
|
|
.terminal_title
|
|
.as_ref()
|
|
.is_some_and(|items| {
|
|
items
|
|
.iter()
|
|
.any(|item| item == "run-state" || item == "status")
|
|
});
|
|
if title_uses_status {
|
|
self.refresh_status_surfaces();
|
|
}
|
|
status_indicator_updated
|
|
}
|
|
|
|
/// Convenience wrapper around [`Self::set_status`];
|
|
/// updates the status indicator header and clears any existing details, returning whether the
|
|
/// visible status indicator requested a redraw.
|
|
pub(super) fn set_status_header(&mut self, header: String) -> bool {
|
|
self.set_status(
|
|
header,
|
|
/*details*/ None,
|
|
StatusDetailsCapitalization::CapitalizeFirst,
|
|
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
|
)
|
|
}
|
|
|
|
/// Sets the currently rendered footer status-line value.
|
|
pub(crate) fn set_status_line(&mut self, status_line: Option<FooterContent>) {
|
|
self.bottom_pane.set_status_line(status_line);
|
|
}
|
|
|
|
/// 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
|
|
/// user actually looking at?" and the footer stack remains a pure renderer of that decision.
|
|
pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option<String>) {
|
|
self.bottom_pane.set_active_agent_label(active_agent_label);
|
|
}
|
|
|
|
/// Recomputes footer status-line content from config and current runtime state.
|
|
///
|
|
/// This method is the status-line orchestrator: it parses configured item identifiers,
|
|
/// warns once per session about invalid items, updates whether status-line mode is enabled,
|
|
/// schedules async git-branch lookup when needed, and renders only values that are currently
|
|
/// available.
|
|
///
|
|
/// The omission behavior is intentional. If selected items are unavailable (for example before
|
|
/// a session id exists or before branch lookup completes), those items are skipped without
|
|
/// placeholders so the line remains compact and stable.
|
|
pub(crate) fn refresh_status_line(&mut self) {
|
|
self.refresh_status_surfaces();
|
|
}
|
|
|
|
/// Records that status-line setup was canceled.
|
|
///
|
|
/// Cancellation is intentionally side-effect free for config state; the existing configuration
|
|
/// remains active and no persistence is attempted.
|
|
pub(crate) fn cancel_status_line_setup(&self) {
|
|
tracing::info!("Status line setup canceled by user");
|
|
}
|
|
|
|
/// Applies status-line item selection from the setup view to in-memory config.
|
|
///
|
|
/// An empty selection persists as an explicit empty list.
|
|
pub(crate) fn setup_status_line(&mut self, items: Vec<StatusLineItem>, use_theme_colors: bool) {
|
|
tracing::info!(
|
|
"status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}"
|
|
);
|
|
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
|
self.config_panel_tui.status_line = Some(ids.clone());
|
|
self.config_panel_tui.status_line_use_colors = use_theme_colors;
|
|
self.local_settings.tui.status_line = Some(ids);
|
|
self.local_settings.tui.status_line_use_colors = use_theme_colors;
|
|
self.status_surface_overrides.status_line = None;
|
|
self.status_surface_overrides.status_line_use_colors = None;
|
|
self.refresh_status_line();
|
|
}
|
|
|
|
/// Applies a temporary terminal-title selection while the setup UI is open.
|
|
pub(crate) fn preview_terminal_title(&mut self, items: Vec<TerminalTitleItem>) {
|
|
if self.terminal_title_setup_original_items.is_none() {
|
|
self.terminal_title_setup_original_items =
|
|
Some(self.local_settings.tui.terminal_title.clone());
|
|
}
|
|
|
|
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
|
self.local_settings.tui.terminal_title = Some(ids);
|
|
self.refresh_terminal_title();
|
|
}
|
|
|
|
/// Restores the terminal-title config that was active before the setup UI
|
|
/// opened, undoing any preview changes. No-op if no setup session is active.
|
|
pub(crate) fn revert_terminal_title_setup_preview(&mut self) {
|
|
let Some(original_items) = self.terminal_title_setup_original_items.take() else {
|
|
return;
|
|
};
|
|
|
|
self.local_settings.tui.terminal_title = original_items;
|
|
self.refresh_terminal_title();
|
|
}
|
|
|
|
/// Dismisses the terminal-title setup UI and reverts to the pre-setup config.
|
|
pub(crate) fn cancel_terminal_title_setup(&mut self) {
|
|
tracing::info!("Terminal title setup canceled by user");
|
|
self.revert_terminal_title_setup_preview();
|
|
}
|
|
|
|
/// Commits a confirmed terminal-title selection, ending the setup session.
|
|
///
|
|
/// After this call, `revert_terminal_title_setup_preview` becomes a no-op
|
|
/// because the original config snapshot is discarded.
|
|
pub(crate) fn setup_terminal_title(&mut self, items: Vec<TerminalTitleItem>) {
|
|
tracing::info!("terminal title setup confirmed with items: {items:#?}");
|
|
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
|
self.terminal_title_setup_original_items = None;
|
|
self.config_panel_tui.terminal_title = Some(ids.clone());
|
|
self.local_settings.tui.terminal_title = Some(ids);
|
|
self.status_surface_overrides.terminal_title = None;
|
|
self.refresh_terminal_title();
|
|
}
|
|
|
|
/// Stores async git-branch lookup results for the current status-line cwd.
|
|
///
|
|
/// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch
|
|
/// names after directory changes.
|
|
pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option<String>) {
|
|
if self.status_line_branch_cwd.as_ref() != Some(&cwd) {
|
|
self.status_line_branch_pending = false;
|
|
return;
|
|
}
|
|
self.status_line_branch = branch;
|
|
self.status_line_branch_pending = false;
|
|
self.status_line_branch_lookup_complete = true;
|
|
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();
|
|
}
|
|
|
|
pub(crate) fn add_status_output(
|
|
&mut self,
|
|
refreshing_rate_limits: bool,
|
|
request_id: Option<u64>,
|
|
) {
|
|
let default_usage = TokenUsage::default();
|
|
let token_info = self.token_info.as_ref();
|
|
let total_usage = token_info
|
|
.map(|ti| &ti.total_token_usage)
|
|
.unwrap_or(&default_usage);
|
|
let collaboration_mode = self.collaboration_mode_label();
|
|
let model = self.current_model().to_string();
|
|
let model_default_reasoning_effort =
|
|
self.model_catalog
|
|
.try_list_models()
|
|
.ok()
|
|
.and_then(|models| {
|
|
models
|
|
.into_iter()
|
|
.find(|preset| preset.model == model)
|
|
.map(|preset| preset.default_reasoning_effort)
|
|
});
|
|
let reasoning_effort_override = Some(
|
|
self.effective_reasoning_effort()
|
|
.or_else(|| self.config.model_reasoning_effort.clone())
|
|
.or(model_default_reasoning_effort),
|
|
);
|
|
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
|
|
.rate_limit_snapshots_by_limit_id
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
let agents_summary =
|
|
crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths);
|
|
let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle(
|
|
&self.config,
|
|
self.requires_openai_auth,
|
|
self.thread_id
|
|
.map(|_| self.config.model_provider_id.as_str()),
|
|
self.remote_connection.as_ref(),
|
|
self.status_account_display.as_ref(),
|
|
token_info,
|
|
total_usage,
|
|
&self.thread_id,
|
|
self.thread_name.clone(),
|
|
self.forked_from,
|
|
rate_limit_snapshots.as_slice(),
|
|
self.plan_type,
|
|
Local::now(),
|
|
self.model_display_name(),
|
|
collaboration_mode,
|
|
reasoning_effort_override,
|
|
agents_summary,
|
|
refreshing_rate_limits,
|
|
);
|
|
if let Some(request_id) = request_id {
|
|
self.refreshing_status_outputs
|
|
.push((request_id, handle.clone()));
|
|
}
|
|
if self.thread_usage_is_available() {
|
|
handle.reserve_thread_usage_label_width();
|
|
handle.set_thread_usage(self.estimated_thread_usage().cloned());
|
|
self.add_to_history(cell);
|
|
self.request_thread_usage_for_status(handle.clone());
|
|
} else {
|
|
self.add_to_history(cell);
|
|
}
|
|
// Capture the displayed status inputs before later configuration or thread changes.
|
|
let mut copy_targets = vec![
|
|
("Model".to_string(), Arc::<str>::from(model)),
|
|
(
|
|
"Directory".to_string(),
|
|
Arc::from(self.config.cwd.display().to_string()),
|
|
),
|
|
];
|
|
if let Some(name) = self.thread_name.as_deref().filter(|name| !name.is_empty()) {
|
|
copy_targets.push(("Thread name".to_string(), Arc::from(name)));
|
|
}
|
|
if let Some(thread_id) = self.thread_id {
|
|
copy_targets.push(("Session ID".to_string(), Arc::from(thread_id.to_string())));
|
|
}
|
|
self.transcript.last_status_copy_targets = Some(super::transcript::StatusCopySource {
|
|
handle,
|
|
fields: copy_targets,
|
|
});
|
|
}
|
|
|
|
pub(crate) fn finish_status_rate_limit_refresh(
|
|
&mut self,
|
|
request_id: u64,
|
|
snapshots: Vec<RateLimitSnapshot>,
|
|
) {
|
|
if !self
|
|
.refreshing_status_outputs
|
|
.iter()
|
|
.any(|(pending_request_id, _)| *pending_request_id == request_id)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for snapshot in snapshots {
|
|
self.on_rate_limit_snapshot(Some(snapshot));
|
|
}
|
|
|
|
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
|
|
.rate_limit_snapshots_by_limit_id
|
|
.values()
|
|
.cloned()
|
|
.collect();
|
|
let now = Local::now();
|
|
let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len());
|
|
let mut updated_any = false;
|
|
for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) {
|
|
if pending_request_id == request_id {
|
|
updated_any = true;
|
|
handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now);
|
|
} else {
|
|
remaining.push((pending_request_id, handle));
|
|
}
|
|
}
|
|
self.refreshing_status_outputs = remaining;
|
|
if updated_any {
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
|
|
pub(super) fn open_status_line_setup(&mut self) {
|
|
let configured_status_line_items = self.configured_status_line_items();
|
|
let view = StatusLineSetupView::new(
|
|
Some(configured_status_line_items.as_slice()),
|
|
self.local_settings.tui.status_line_use_colors,
|
|
self.status_surface_preview_data(),
|
|
self.app_event_tx.clone(),
|
|
self.bottom_pane.list_keymap(),
|
|
);
|
|
self.bottom_pane.show_view(Box::new(view));
|
|
}
|
|
|
|
pub(super) fn open_terminal_title_setup(&mut self) {
|
|
let configured_terminal_title_items = self.configured_terminal_title_items();
|
|
self.terminal_title_setup_original_items =
|
|
Some(self.local_settings.tui.terminal_title.clone());
|
|
let view = TerminalTitleSetupView::new(
|
|
Some(configured_terminal_title_items.as_slice()),
|
|
self.terminal_title_preview_data(),
|
|
self.app_event_tx.clone(),
|
|
self.bottom_pane.list_keymap(),
|
|
);
|
|
self.bottom_pane.show_view(Box::new(view));
|
|
}
|
|
|
|
pub(super) fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData {
|
|
let mut preview_data = StatusSurfacePreviewData::from_iter(
|
|
StatusSurfacePreviewItem::iter().filter_map(|item| {
|
|
self.status_surface_preview_value_for_item(item)
|
|
.map(|value| (item, value))
|
|
}),
|
|
);
|
|
preview_data.thread_id = self.thread_id;
|
|
preview_data.renderer = self.status_line_renderer();
|
|
preview_data.pull_request_url = self.status_line_pull_request_url().map(str::to_owned);
|
|
|
|
if self.rate_limit_snapshots_by_limit_id.contains_key("codex") {
|
|
for item in [
|
|
StatusSurfacePreviewItem::FiveHourLimit,
|
|
StatusSurfacePreviewItem::WeeklyLimit,
|
|
] {
|
|
if self.status_surface_preview_value_for_item(item).is_none() {
|
|
preview_data.suppress_placeholder(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
if self
|
|
.estimated_thread_usage()
|
|
.is_some_and(|usage| thread_cost_micros(usage, self.plan_type).is_none())
|
|
{
|
|
preview_data.suppress_placeholder(StatusSurfacePreviewItem::EstimatedThreadCost);
|
|
}
|
|
|
|
preview_data
|
|
}
|
|
|
|
pub(super) fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData {
|
|
let mut preview_data = self.status_surface_preview_data();
|
|
let now = Instant::now();
|
|
for item in TerminalTitleItem::iter() {
|
|
let Some(preview_item) = item.preview_item() else {
|
|
continue;
|
|
};
|
|
let Some(value) = self.terminal_title_value_for_item(item, now) else {
|
|
continue;
|
|
};
|
|
preview_data.set_live(preview_item, value);
|
|
}
|
|
preview_data
|
|
}
|
|
|
|
pub(super) fn status_line_context_window_size(&self) -> Option<i64> {
|
|
self.token_info
|
|
.as_ref()
|
|
.and_then(|info| info.model_context_window)
|
|
.or(self.config.model_context_window)
|
|
}
|
|
|
|
pub(super) fn status_line_context_remaining_percent(&self) -> Option<i64> {
|
|
if self.token_usage_pending {
|
|
return None;
|
|
}
|
|
let Some(context_window) = self.status_line_context_window_size() else {
|
|
return Some(100);
|
|
};
|
|
let default_usage = TokenUsage::default();
|
|
let usage = self
|
|
.token_info
|
|
.as_ref()
|
|
.map(|info| &info.last_token_usage)
|
|
.unwrap_or(&default_usage);
|
|
Some(
|
|
usage
|
|
.percent_of_context_window_remaining(context_window)
|
|
.clamp(0, 100),
|
|
)
|
|
}
|
|
|
|
pub(super) fn status_line_context_used_percent(&self) -> Option<i64> {
|
|
self.status_line_context_remaining_percent()
|
|
.map(|remaining| (100 - remaining).clamp(0, 100))
|
|
}
|
|
|
|
pub(super) fn status_line_total_usage(&self) -> TokenUsage {
|
|
self.token_info
|
|
.as_ref()
|
|
.map(|info| info.total_token_usage.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub(super) fn status_line_limit_display(
|
|
&self,
|
|
window: Option<&RateLimitWindowDisplay>,
|
|
label: &str,
|
|
) -> Option<String> {
|
|
let window = window?;
|
|
let remaining = (100.0f64 - window.used_percent).clamp(0.0f64, 100.0f64);
|
|
Some(format!("{label} {remaining:.0}% left"))
|
|
}
|
|
|
|
pub(super) fn status_line_reasoning_effort_label(
|
|
effort: Option<&ReasoningEffortConfig>,
|
|
) -> String {
|
|
match effort {
|
|
None | Some(ReasoningEffortConfig::None) => "default".to_string(),
|
|
Some(effort) => effort.as_str().to_string(),
|
|
}
|
|
}
|
|
}
|