feat(tui/notifications): add OSC toggle and slash command

This commit is contained in:
James Peter
2025-09-27 06:37:31 +10:00
parent d1eeb0fd64
commit 6a8aac2d39
9 changed files with 437 additions and 3 deletions
+36
View File
@@ -855,6 +855,42 @@ pub fn set_tui_alternate_screen(codex_home: &Path, enabled: bool) -> anyhow::Res
Ok(())
}
/// Persist the TUI notifications preference into `CODEX_HOME/config.toml` at `[tui].notifications`.
pub fn set_tui_notifications(
codex_home: &Path,
notifications: crate::config_types::Notifications,
) -> anyhow::Result<()> {
use crate::config_types::Notifications;
let config_path = codex_home.join(CONFIG_TOML_FILE);
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
let mut doc = match std::fs::read_to_string(&read_path) {
Ok(contents) => contents.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
match notifications {
Notifications::Enabled(value) => {
doc["tui"]["notifications"] = toml_edit::value(value);
}
Notifications::Custom(values) => {
let mut array = TomlArray::default();
for value in values {
array.push(value);
}
doc["tui"]["notifications"] = TomlItem::Value(array.into());
}
}
std::fs::create_dir_all(codex_home)?;
let tmp_file = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp_file.path(), doc.to_string())?;
tmp_file.persist(config_path)?;
Ok(())
}
/// Persist the GitHub workflow check preference under `[github].check_workflows_on_push`.
pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
+75
View File
@@ -418,6 +418,66 @@ impl App<'_> {
);
}
fn sanitize_notification_text(input: &str) -> String {
let mut sanitized = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\u{00}'..='\u{08}' | '\u{0B}' | '\u{0C}' | '\u{0E}'..='\u{1F}' | '\u{7F}' => {}
'\n' | '\r' | '\t' => {
if !sanitized.ends_with(' ') {
sanitized.push(' ');
}
}
_ => sanitized.push(ch),
}
}
sanitized
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn format_notification_message(title: &str, body: Option<&str>) -> Option<String> {
let title = Self::sanitize_notification_text(title);
let body = body.map(Self::sanitize_notification_text);
let mut message = match body {
Some(ref b) if !b.is_empty() => {
if title.is_empty() {
b.clone()
} else {
format!("{}: {}", title, b)
}
}
_ => title.clone(),
};
if message.is_empty() {
return None;
}
const MAX_LEN: usize = 160;
if message.chars().count() > MAX_LEN {
let mut truncated = String::new();
for ch in message.chars() {
if truncated.chars().count() >= MAX_LEN.saturating_sub(3) {
break;
}
truncated.push(ch);
}
truncated.push_str("...");
message = truncated;
}
Some(message)
}
fn emit_osc9_notification(message: &str) {
let payload = format!("\u{1b}]9;{}\u{7}", message);
let mut stdout = std::io::stdout();
let _ = stdout.write_all(payload.as_bytes());
let _ = stdout.flush();
}
/// Schedule a redraw immediately and open a short debounce window to coalesce
/// subsequent requests. Crucially, even if a timer is already armed (e.g., an
@@ -1705,6 +1765,11 @@ impl App<'_> {
widget.handle_update_command();
}
}
SlashCommand::Notifications => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_notifications_command();
}
}
SlashCommand::Agents => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_agents_command(command_args);
@@ -1884,6 +1949,11 @@ impl App<'_> {
widget.set_github_watcher(enabled);
}
}
AppEvent::UpdateTuiNotifications(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_tui_notifications(enabled);
}
}
AppEvent::UpdateValidationTool { name, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_validation_tool(&name, enable);
@@ -1898,6 +1968,11 @@ impl App<'_> {
self.terminal_title_override = title;
self.apply_terminal_title();
}
AppEvent::EmitTuiNotification { title, body } => {
if let Some(message) = Self::format_notification_message(&title, body.as_deref()) {
Self::emit_osc9_notification(&message);
}
}
AppEvent::UpdateMcpServer { name, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_mcp_server(&name, enable);
+5
View File
@@ -85,6 +85,9 @@ pub(crate) enum AppEvent {
/// Update the terminal title override. `None` restores the default title.
SetTerminalTitle { title: Option<String> },
/// Emit a best-effort OSC 9 notification from the terminal.
EmitTuiNotification { title: String, body: Option<String> },
/// Schedule a one-shot animation frame roughly after the given duration.
/// Multiple requests are coalesced by the central frame scheduler.
ScheduleFrameIn(Duration),
@@ -140,6 +143,8 @@ pub(crate) enum AppEvent {
/// Update GitHub workflow monitoring toggle
UpdateGithubWatcher(bool),
/// Update the TUI notifications toggle
UpdateTuiNotifications(bool),
/// Enable/disable a specific validation tool
UpdateValidationTool { name: String, enable: bool },
/// Enable/disable an entire validation group
+1
View File
@@ -41,6 +41,7 @@ impl AppEventSender {
| AppEvent::Redraw
| AppEvent::ExitRequest
| AppEvent::SetTerminalTitle { .. }
| AppEvent::EmitTuiNotification { .. }
);
let tx = if is_high { &self.high_tx } else { &self.bulk_tx };
+8
View File
@@ -46,6 +46,7 @@ mod verbosity_selection_view;
pub(crate) mod validation_settings_view;
mod update_settings_view;
mod undo_restore_view;
mod notifications_settings_view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
@@ -63,6 +64,7 @@ pub(crate) use login_accounts_view::{
};
pub(crate) use update_settings_view::{UpdateSettingsView, UpdateSharedState};
pub(crate) use notifications_settings_view::{NotificationsMode, NotificationsSettingsView};
use codex_core::protocol::Op;
use approval_modal_view::ApprovalModalView;
@@ -161,6 +163,12 @@ impl BottomPane<'_> {
self.request_redraw();
}
pub fn show_notifications_settings(&mut self, view: NotificationsSettingsView) {
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw();
}
/// Show per-agent editor
pub fn show_agent_editor(
&mut self,
@@ -0,0 +1,220 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::BottomPaneView;
use super::BottomPane;
#[derive(Clone)]
pub(crate) enum NotificationsMode {
Toggle { enabled: bool },
Custom { command: Vec<String> },
}
pub(crate) struct NotificationsSettingsView {
mode: NotificationsMode,
app_event_tx: AppEventSender,
selected_row: usize,
is_complete: bool,
}
impl NotificationsSettingsView {
pub fn new(mode: NotificationsMode, app_event_tx: AppEventSender) -> Self {
Self {
mode,
app_event_tx,
selected_row: 0,
is_complete: false,
}
}
fn toggle(&mut self) {
match &mut self.mode {
NotificationsMode::Toggle { enabled } => {
*enabled = !*enabled;
self.app_event_tx
.send(AppEvent::UpdateTuiNotifications(*enabled));
}
NotificationsMode::Custom { .. } => {
self.app_event_tx.send_background_event(
"TUI notifications use a custom command. Edit ~/.code/config.toml to change this.",
);
}
}
}
fn status_line(&self) -> Line<'static> {
match &self.mode {
NotificationsMode::Toggle { enabled } => {
let status = if *enabled { "Enabled" } else { "Disabled" };
let color = if *enabled {
crate::colors::success()
} else {
crate::colors::warning()
};
Line::from(vec![
Span::styled("Status: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(status, Style::default().fg(color).add_modifier(Modifier::BOLD)),
])
}
NotificationsMode::Custom { command } => {
let command_display = if command.is_empty() {
"<empty>".to_string()
} else {
command.join(" ")
};
Line::from(vec![
Span::styled("Status: ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Custom command", Style::default().fg(crate::colors::info()).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(command_display, Style::default().fg(crate::colors::dim())),
])
}
}
}
fn toggle_line(&self) -> Line<'static> {
match &self.mode {
NotificationsMode::Toggle { enabled } => {
let label = if *enabled { "Enabled" } else { "Disabled" };
Line::from(vec![
Span::styled("Notifications: ", Style::default().fg(crate::colors::text_dim())),
Span::raw(label),
])
}
NotificationsMode::Custom { .. } => {
Line::from(vec![
Span::styled(
"Notifications are managed by your config file.",
Style::default().fg(crate::colors::text()),
),
])
}
}
}
}
impl<'a> BottomPaneView<'a> for NotificationsSettingsView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
match key_event {
KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row > 0 {
self.selected_row -= 1;
}
}
KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row < 1 {
self.selected_row += 1;
}
}
KeyEvent { code: KeyCode::Left | KeyCode::Right, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle();
}
}
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle();
} else {
self.is_complete = true;
}
}
KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle();
}
}
KeyEvent { code: KeyCode::Esc, .. } => {
self.is_complete = true;
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.is_complete
}
fn desired_height(&self, _width: u16) -> u16 {
9
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.title(" Notifications ")
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
let mut lines = Vec::new();
lines.push(self.status_line());
lines.push(Line::from(""));
let mut toggle_line = self.toggle_line();
if self.selected_row == 0 {
toggle_line = toggle_line
.style(
Style::default()
.bg(crate::colors::selection())
.add_modifier(Modifier::BOLD),
);
}
lines.push(toggle_line);
lines.push(Line::from(""));
let mut close_line = Line::from(vec![
Span::raw(if self.selected_row == 1 { "> " } else { " " }),
Span::raw("Close"),
]);
if self.selected_row == 1 {
close_line = close_line
.style(
Style::default()
.bg(crate::colors::selection())
.add_modifier(Modifier::BOLD),
);
}
lines.push(close_line);
lines.push(Line::from(""));
let footer = match &self.mode {
NotificationsMode::Toggle { .. } => Line::from(vec![
Span::styled("Up/Down", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("Left/Right or Space", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle or Close "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Cancel"),
]),
NotificationsMode::Custom { .. } => Line::from(vec![
Span::styled("Edit ", Style::default().fg(crate::colors::text_dim())),
Span::styled("[tui].notifications", Style::default().fg(crate::colors::info())),
Span::styled(" in ~/.code/config.toml to change this setting.", Style::default().fg(crate::colors::text_dim())),
]),
};
lines.push(footer);
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
paragraph.render(
Rect {
x: inner.x.saturating_add(1),
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
},
buf,
);
}
}
+89 -3
View File
@@ -23,6 +23,7 @@ use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::git_info::CommitLogEntry;
use codex_core::config_types::AgentConfig;
use codex_core::config_types::Notifications;
use codex_core::config_types::ReasoningEffort;
use codex_core::config_types::TextVerbosity;
use codex_core::plan_tool::{PlanItemArg, StepStatus, UpdatePlanArgs};
@@ -5828,9 +5829,7 @@ impl ChatWidget<'_> {
self.mark_needs_redraw();
}
EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: _,
}) => {
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
// Finalize any active streams
if self.stream.is_write_cycle_active() {
// Finalize both streams via streaming facade
@@ -5911,6 +5910,7 @@ impl ChatWidget<'_> {
self.stream_state.current_kind = None;
// Final re-check for idle state
self.maybe_hide_spinner();
self.emit_turn_complete_notification(last_agent_message);
self.mark_needs_redraw();
}
EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
@@ -7434,6 +7434,18 @@ impl ChatWidget<'_> {
);
}
pub(crate) fn handle_notifications_command(&mut self) {
use crate::bottom_pane::{NotificationsMode, NotificationsSettingsView};
let mode = match &self.config.tui.notifications {
Notifications::Enabled(enabled) => NotificationsMode::Toggle { enabled: *enabled },
Notifications::Custom(command) => NotificationsMode::Custom { command: command.clone() },
};
let view = NotificationsSettingsView::new(mode, self.app_event_tx.clone());
self.bottom_pane.show_notifications_settings(view);
}
pub(crate) fn add_prompts_output(&mut self) {
self.history_push(history_cell::new_prompts_output());
}
@@ -19350,6 +19362,80 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn set_tui_notifications(&mut self, enabled: bool) {
let new_state = Notifications::Enabled(enabled);
self.config.tui.notifications = new_state.clone();
self.config.tui_notifications = new_state.clone();
match find_codex_home() {
Ok(home) => {
match codex_core::config::set_tui_notifications(&home, new_state) {
Ok(()) => {
let msg = format!(
"✅ {} TUI notifications",
if enabled { "Enabled" } else { "Disabled" }
);
self.push_background_tail(msg);
}
Err(err) => {
let msg = format!(
"⚠️ Failed to persist TUI notifications setting: {}",
err
);
self.history_push(history_cell::new_error_event(msg));
}
}
}
Err(_) => {
let msg = format!(
"✅ {} TUI notifications (not persisted: CODE_HOME/CODEX_HOME not found)",
if enabled { "Enabled" } else { "Disabled" }
);
self.push_background_tail(msg);
}
}
}
fn emit_turn_complete_notification(&self, last_agent_message: Option<String>) {
if !matches!(self.config.tui.notifications, Notifications::Enabled(true)) {
return;
}
let snippet = last_agent_message
.as_deref()
.map(Self::notification_snippet)
.filter(|text| !text.is_empty());
self.app_event_tx.send(AppEvent::EmitTuiNotification {
title: "Code".to_string(),
body: snippet,
});
}
fn notification_snippet(input: &str) -> String {
let collapsed = input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
const LIMIT: usize = 120;
if collapsed.chars().count() <= LIMIT {
return collapsed;
}
let mut truncated = String::new();
let mut count = 0usize;
for ch in collapsed.chars() {
if count >= LIMIT.saturating_sub(3) {
break;
}
truncated.push(ch);
count += 1;
}
truncated.push_str("...");
truncated
}
pub(crate) fn toggle_mcp_server(&mut self, name: &str, enable: bool) {
match codex_core::config::find_codex_home() {
Ok(home) => match codex_core::config::set_mcp_server_enabled(&home, name, enable) {
+2
View File
@@ -64,6 +64,7 @@ pub enum SlashCommand {
Limits,
#[strum(serialize = "update", serialize = "upgrade")]
Update,
Notifications,
Theme,
Model,
Reasoning,
@@ -113,6 +114,7 @@ impl SlashCommand {
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Limits => "visualize weekly and hourly rate limits",
SlashCommand::Update => "check for updates and optionally upgrade",
SlashCommand::Notifications => "toggle TUI notifications (status/on/off)",
SlashCommand::Theme => "switch between color themes",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Model => "choose model & reasoning effort",
+1
View File
@@ -49,6 +49,7 @@ Notes
- `/limits`: visualize current hourly and weekly rate-limit usage.
- `/update`: check the installed version, detect available upgrades, and run the
appropriate installer when possible.
- `/notifications`: inspect and toggle TUI notifications.
- `/mcp`: manage MCP servers (status/on/off/add).
- `/validation [status|on|off|<tool> (on|off)]`: inspect or toggle validation
harness settings.