Files
codex/codex-rs/tui/src/chatwidget/notifications.rs
T
Eric Traut 9d57be71ba Separate TUI preferences from server configuration (#42202)
## Why

TUI preferences and persistence paths belong to the local client, while thread
configuration and account requirements may come from the active app server.
Keeping both in the same resolved `Config` can replace live local preferences
when a thread is resumed, forked, reconnected, or switched.

## What changed

- Add `LocalSettings` as the TUI-owned source for UI, history, notice, and local
  persistence settings, and preserve it across session lifecycle transitions.
- Write preference changes to the selected user config file and reload local
  settings from disk when changing roots.
- Use the app server's account response for authentication-dependent onboarding
  and status UI instead of the local model-provider configuration.
- Retain platform family and OS metadata reported by remote app servers.

## Testing

- Cover local setting defaults, overrides, persistence destinations, reloads,
  and preservation across widget replacement and root switching.
- Cover remote platform metadata and server-controlled authentication UI.

GitOrigin-RevId: dbc2965fb1d6cbecb3f973f6133f3dcd4467b753
2026-09-02 04:20:38 +00:00

129 lines
4.3 KiB
Rust

//! Desktop notification coalescing for `ChatWidget`.
use super::*;
impl ChatWidget {
pub(super) fn notify(&mut self, notification: Notification) {
if !notification.allowed_for(&self.local_settings.tui.notification_settings.notifications) {
return;
}
if let Some(existing) = self.pending_notification.as_ref()
&& existing.priority() > notification.priority()
{
return;
}
self.pending_notification = Some(notification);
self.request_redraw();
}
pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) {
if let Some(notif) = self.pending_notification.take() {
tui.notify(notif.display());
}
}
}
#[derive(Debug)]
pub(super) enum Notification {
AgentTurnComplete { response: String },
ExecApprovalRequested { command: String },
EditApprovalRequested { cwd: PathBuf, changes: Vec<PathBuf> },
ElicitationRequested { server_name: String },
PlanModePrompt { title: String },
}
impl Notification {
pub(super) fn display(&self) -> String {
match self {
Notification::AgentTurnComplete { response } => {
Notification::agent_turn_preview(response)
.unwrap_or_else(|| "Agent turn complete".to_string())
}
Notification::ExecApprovalRequested { command } => {
format!(
"Approval requested: {}",
truncate_text(command, /*max_graphemes*/ 30)
)
}
Notification::EditApprovalRequested { cwd, changes } => {
format!(
"Codex wants to edit {}",
if changes.len() == 1 {
#[allow(clippy::unwrap_used)]
display_path_for(changes.first().unwrap(), cwd)
} else {
format!("{} files", changes.len())
}
)
}
Notification::ElicitationRequested { server_name } => {
format!("Approval requested by {server_name}")
}
Notification::PlanModePrompt { title } => {
format!("Plan mode prompt: {title}")
}
}
}
fn type_name(&self) -> &str {
match self {
Notification::AgentTurnComplete { .. } => "agent-turn-complete",
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. }
| Notification::ElicitationRequested { .. } => "approval-requested",
Notification::PlanModePrompt { .. } => "plan-mode-prompt",
}
}
fn priority(&self) -> u8 {
match self {
Notification::AgentTurnComplete { .. } => 0,
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. }
| Notification::ElicitationRequested { .. }
| Notification::PlanModePrompt { .. } => 1,
}
}
pub(super) fn allowed_for(&self, settings: &Notifications) -> bool {
match settings {
Notifications::Enabled(enabled) => *enabled,
Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()),
}
}
pub(super) fn agent_turn_preview(response: &str) -> Option<String> {
let mut normalized = String::new();
for part in response.split_whitespace() {
if !normalized.is_empty() {
normalized.push(' ');
}
normalized.push_str(part);
}
let trimmed = normalized.trim();
if trimmed.is_empty() {
None
} else {
Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES))
}
}
pub(super) fn user_input_request_summary(
questions: &[codex_app_server_protocol::ToolRequestUserInputQuestion],
) -> Option<String> {
let first_question = questions.first()?;
let summary = if first_question.header.trim().is_empty() {
first_question.question.trim()
} else {
first_question.header.trim()
};
if summary.is_empty() {
None
} else {
Some(truncate_text(summary, /*max_graphemes*/ 30))
}
}
}
const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200;