Compare commits

...

2 Commits

Author SHA1 Message Date
Naren
936ec73ab7 Add plan progress footer and controllable plan notifications 2025-09-17 15:52:47 -04:00
Naren
984f6f31fb Show current plan step in TUI footer 2025-09-17 14:22:39 -04:00
9 changed files with 389 additions and 5 deletions

View File

@@ -3603,6 +3603,7 @@ mod tests {
session_manager: ExecSessionManager::default(),
unified_exec_manager: UnifiedExecSessionManager::default(),
notify: None,
plan_step_notifications: false,
rollout: Mutex::new(None),
state: Mutex::new(State {
history: ConversationHistory::new(),

View File

@@ -122,6 +122,9 @@ pub struct Config {
/// and turn completions when not focused.
pub tui_notifications: Notifications,
/// Enable plan step completion notifications in the UI and external notifier.
pub plan_step_notifications: bool,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
@@ -1053,6 +1056,11 @@ impl Config {
.as_ref()
.map(|t| t.notifications.clone())
.unwrap_or_default(),
plan_step_notifications: cfg
.tui
.as_ref()
.map(|t| t.plan_step_notifications)
.unwrap_or(true),
};
Ok(config)
}
@@ -1617,6 +1625,7 @@ model_verbosity = "high"
active_profile: Some("o3".to_string()),
disable_paste_burst: false,
tui_notifications: Default::default(),
plan_step_notifications: true,
},
o3_profile_config
);
@@ -1675,6 +1684,7 @@ model_verbosity = "high"
active_profile: Some("gpt3".to_string()),
disable_paste_burst: false,
tui_notifications: Default::default(),
plan_step_notifications: true,
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -1748,6 +1758,7 @@ model_verbosity = "high"
active_profile: Some("zdr".to_string()),
disable_paste_burst: false,
tui_notifications: Default::default(),
plan_step_notifications: true,
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
@@ -1807,6 +1818,7 @@ model_verbosity = "high"
active_profile: Some("gpt5".to_string()),
disable_paste_burst: false,
tui_notifications: Default::default(),
plan_step_notifications: true,
};
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
@@ -1913,12 +1925,19 @@ trust_level = "trusted"
#[cfg(test)]
mod notifications_tests {
use crate::config::{Config, ConfigOverrides, ConfigToml};
use crate::config_types::Notifications;
use serde::Deserialize;
#[derive(Deserialize, Debug, PartialEq)]
struct TuiTomlTest {
notifications: Notifications,
#[serde(default = "default_true")]
plan_step_notifications: bool,
}
const fn default_true() -> bool {
true
}
#[derive(Deserialize, Debug, PartialEq)]
@@ -1952,4 +1971,40 @@ mod notifications_tests {
Notifications::Custom(ref v) if v == &vec!["foo".to_string()]
));
}
#[test]
fn test_plan_step_notifications_default_true() {
let toml = r#"
[tui]
notifications = false
"#;
let parsed: RootTomlTest =
toml::from_str(toml).expect("deserialize [tui] with only notifications");
assert!(
parsed.tui.plan_step_notifications,
"plan_step_notifications should default to true"
);
}
#[test]
fn test_config_loads_plan_step_notifications_by_default() {
let cfg: ConfigToml = toml::from_str(
r#"
[tui]
notifications = false
"#,
)
.expect("parse config toml");
let temp = tempfile::tempdir().expect("tempdir");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
temp.path().to_path_buf(),
)
.expect("load config");
assert!(
config.plan_step_notifications,
"plan_step_notifications should default to true in Config"
);
}
}

View File

@@ -95,6 +95,13 @@ pub struct Tui {
/// Enable desktop notifications from the TUI when the terminal is unfocused.
/// Defaults to `false`.
pub notifications: Notifications,
/// When true, emit notifications as plan steps are completed. Defaults to `true`.
#[serde(default = "true_bool")]
pub plan_step_notifications: bool,
}
const fn true_bool() -> bool {
true
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]

View File

@@ -90,6 +90,50 @@ pub(crate) async fn handle_update_plan(
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletedStep {
pub step: String,
pub position: usize,
pub total: usize,
}
pub fn newly_completed_steps(
previous: &[PlanItemArg],
current: &[PlanItemArg],
) -> Vec<CompletedStep> {
use std::collections::HashMap;
let mut prev_status: HashMap<&str, &StepStatus> = HashMap::new();
for item in previous {
let step = item.step.trim();
if !step.is_empty() {
prev_status.insert(step, &item.status);
}
}
let total = current.len();
let mut completed = Vec::new();
for (idx, item) in current.iter().enumerate() {
let step = item.step.trim();
if step.is_empty() || !matches!(item.status, StepStatus::Completed) {
continue;
}
let was_completed = prev_status
.get(step)
.map(|status| matches!(status, StepStatus::Completed))
.unwrap_or(false);
if !was_completed {
completed.push(CompletedStep {
step: step.to_owned(),
position: idx + 1,
total,
});
}
}
completed
}
fn parse_update_plan_arguments(
arguments: String,
call_id: &str,
@@ -108,3 +152,63 @@ fn parse_update_plan_arguments(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_step(step: &str, status: StepStatus) -> PlanItemArg {
PlanItemArg {
step: step.to_string(),
status,
}
}
#[test]
fn detects_newly_completed_steps() {
let prev = vec![
make_step("Explore", StepStatus::Completed),
make_step("Implement", StepStatus::InProgress),
];
let current = vec![
make_step("Explore", StepStatus::Completed),
make_step("Implement", StepStatus::Completed),
make_step("Document", StepStatus::Pending),
];
let completed = newly_completed_steps(&prev, &current);
assert_eq!(
completed,
vec![CompletedStep {
step: "Implement".to_string(),
position: 2,
total: 3,
}]
);
}
#[test]
fn ignores_already_completed_steps() {
let prev = vec![make_step("Explore", StepStatus::Completed)];
let current = vec![make_step("Explore", StepStatus::Completed)];
let completed = newly_completed_steps(&prev, &current);
assert!(completed.is_empty());
}
#[test]
fn trims_whitespace_in_step_names() {
let prev = vec![make_step(" Implement ", StepStatus::InProgress)];
let current = vec![make_step("Implement", StepStatus::Completed)];
let completed = newly_completed_steps(&prev, &current);
assert_eq!(
completed,
vec![CompletedStep {
step: "Implement".to_string(),
position: 1,
total: 1,
}]
);
}
}

View File

@@ -82,6 +82,7 @@ pub(crate) struct ChatComposer {
attached_images: Vec<AttachedImage>,
placeholder_text: String,
is_task_running: bool,
current_plan_step: Option<String>,
// Non-bracketed paste burst tracker.
paste_burst: PasteBurst,
// When true, disables paste-burst logic and inserts characters immediately.
@@ -127,6 +128,7 @@ impl ChatComposer {
attached_images: Vec::new(),
placeholder_text,
is_task_running: false,
current_plan_step: None,
paste_burst: PasteBurst::default(),
disable_paste_burst: false,
custom_prompts: Vec::new(),
@@ -177,6 +179,14 @@ impl ChatComposer {
self.token_usage_info = token_info;
}
pub(crate) fn set_current_plan_step(&mut self, step: Option<String>) -> bool {
if self.current_plan_step == step {
return false;
}
self.current_plan_step = step;
true
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
/// that the composer can navigate cross-session history.
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
@@ -1300,6 +1310,13 @@ impl WidgetRef for ChatComposer {
hint.push(" edit prev".into());
}
if let Some(step) = &self.current_plan_step {
hint.push(" ".into());
hint.push("Now:".dim());
hint.push(" ".into());
hint.push(step.clone().cyan().bold());
}
// Append token/context usage info to the footer hints when available.
if let Some(token_usage_info) = &self.token_usage_info {
let token_usage = &token_usage_info.total_token_usage;
@@ -1436,6 +1453,44 @@ mod tests {
);
}
#[test]
fn footer_hint_includes_current_plan_step() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
assert!(composer.set_current_plan_step(Some("Implement feature".to_string())));
let area = Rect::new(0, 0, 80, 6);
let mut buf = Buffer::empty(area);
composer.render_ref(area, &mut buf);
let bottom_row: String = (0..area.width)
.map(|x| {
buf[(x, area.height - 1)]
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
bottom_row.contains("Now:"),
"missing status label: {bottom_row:?}"
);
assert!(
bottom_row.contains("Implement feature"),
"missing plan text: {bottom_row:?}"
);
}
#[test]
fn test_current_at_token_basic_cases() {
let test_cases = vec![

View File

@@ -373,6 +373,12 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn set_plan_progress(&mut self, step: Option<String>) {
if self.composer.set_current_plan_step(step) {
self.request_redraw();
}
}
/// Called when the agent requests user approval.
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
let request = if let Some(view) = self.active_view.as_mut() {

View File

@@ -5,6 +5,9 @@ use std::sync::Arc;
use codex_core::config::Config;
use codex_core::config_types::Notifications;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::newly_completed_steps;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
@@ -141,6 +144,7 @@ pub(crate) struct ChatWidget {
queued_user_messages: VecDeque<UserMessage>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
last_plan: Vec<PlanItemArg>,
}
struct UserMessage {
@@ -172,6 +176,8 @@ impl ChatWidget {
}
// --- Small event handlers ---
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
self.bottom_pane.set_plan_progress(None);
self.last_plan.clear();
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.conversation_id = Some(event.session_id);
@@ -325,6 +331,19 @@ impl ChatWidget {
}
fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
let completed_steps = newly_completed_steps(&self.last_plan, &update.plan);
let current_step = select_current_plan_step(&update.plan);
self.bottom_pane.set_plan_progress(current_step);
if self.config.plan_step_notifications {
for completed in &completed_steps {
self.notify(Notification::PlanStepComplete {
step: completed.step.clone(),
position: completed.position,
total: completed.total,
});
}
}
self.last_plan = update.plan.clone();
self.add_to_history(history_cell::new_plan_update(update));
}
@@ -700,6 +719,7 @@ impl ChatWidget {
show_welcome_banner: true,
suppress_session_configured_redraw: false,
pending_notification: None,
last_plan: Vec::new(),
}
}
@@ -756,6 +776,7 @@ impl ChatWidget {
show_welcome_banner: true,
suppress_session_configured_redraw: true,
pending_notification: None,
last_plan: Vec::new(),
}
}
@@ -1153,7 +1174,12 @@ impl ChatWidget {
}
fn notify(&mut self, notification: Notification) {
if !notification.allowed_for(&self.config.tui_notifications) {
let allow = match &self.config.tui_notifications {
Notifications::Enabled(enabled) => *enabled,
custom => notification.allowed_for(custom),
};
if !allow {
return;
}
self.pending_notification = Some(notification);
@@ -1478,10 +1504,21 @@ impl WidgetRef for &ChatWidget {
}
}
#[derive(Debug)]
enum Notification {
AgentTurnComplete,
ExecApprovalRequested { command: String },
EditApprovalRequested { cwd: PathBuf, changes: Vec<PathBuf> },
ExecApprovalRequested {
command: String,
},
EditApprovalRequested {
cwd: PathBuf,
changes: Vec<PathBuf>,
},
PlanStepComplete {
step: String,
position: usize,
total: usize,
},
}
impl Notification {
@@ -1502,6 +1539,18 @@ impl Notification {
}
)
}
Notification::PlanStepComplete {
step,
position,
total,
} => {
format!(
"Plan step complete ({}/{}): {}",
position,
total,
truncate_text(step, 40)
)
}
}
}
@@ -1510,6 +1559,7 @@ impl Notification {
Notification::AgentTurnComplete => "agent-turn-complete",
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. } => "approval-requested",
Notification::PlanStepComplete { .. } => "plan-step-complete",
}
}
@@ -1560,5 +1610,37 @@ fn extract_first_bold(s: &str) -> Option<String> {
None
}
fn select_current_plan_step(plan: &[PlanItemArg]) -> Option<String> {
let in_progress = plan.iter().find_map(|item| {
if matches!(item.status, StepStatus::InProgress) {
let trimmed = item.step.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
} else {
None
}
});
if in_progress.is_some() {
return in_progress;
}
plan.iter().find_map(|item| {
if matches!(item.status, StepStatus::Pending) {
let trimmed = item.step.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
} else {
None
}
})
}
#[cfg(test)]
pub(crate) mod tests;

View File

@@ -252,6 +252,7 @@ fn make_chatwidget_manual() -> (
queued_user_messages: VecDeque::new(),
suppress_session_configured_redraw: false,
pending_notification: None,
last_plan: Vec::new(),
};
(widget, rx, op_rx)
}
@@ -1496,6 +1497,74 @@ fn plan_update_renders_history_cell() {
assert!(blob.contains("Write tests"));
}
#[test]
fn plan_completion_notification_emitted_when_enabled() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
chat.config.plan_step_notifications = true;
chat.config.tui_notifications = Notifications::Enabled(true);
let seed_plan = UpdatePlanArgs {
explanation: None,
plan: vec![PlanItemArg {
step: "Implement feature".into(),
status: StepStatus::InProgress,
}],
};
chat.handle_codex_event(Event {
id: "sub-seed".into(),
msg: EventMsg::PlanUpdate(seed_plan),
});
drain_insert_history(&mut rx);
assert!(chat.pending_notification.is_none());
let completion = UpdatePlanArgs {
explanation: None,
plan: vec![PlanItemArg {
step: "Implement feature".into(),
status: StepStatus::Completed,
}],
};
chat.handle_codex_event(Event {
id: "sub-complete".into(),
msg: EventMsg::PlanUpdate(completion),
});
drain_insert_history(&mut rx);
match chat.pending_notification {
Some(Notification::PlanStepComplete {
ref step,
position,
total,
}) => {
assert_eq!(step, "Implement feature");
assert_eq!(position, 1);
assert_eq!(total, 1);
}
other => panic!("expected plan completion notification, got {other:?}"),
}
}
#[test]
fn plan_completion_notification_respects_toggle() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
chat.config.plan_step_notifications = false;
chat.config.tui_notifications = Notifications::Enabled(true);
let update = UpdatePlanArgs {
explanation: None,
plan: vec![PlanItemArg {
step: "Implement feature".into(),
status: StepStatus::Completed,
}],
};
chat.handle_codex_event(Event {
id: "sub".into(),
msg: EventMsg::PlanUpdate(update),
});
drain_insert_history(&mut rx);
assert!(chat.pending_notification.is_none());
}
#[test]
fn stream_error_is_rendered_to_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();

View File

@@ -510,7 +510,7 @@ notify = ["python3", "/Users/mbolin/.codex/notify.py"]
```
> [!NOTE]
> Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers inTUI alerts (e.g., approval prompts), while `notify` is best for systemlevel hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering.
> Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers inTUI alerts (e.g., approval prompts), while `notify` is best for system-level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete`, `approval-requested`, and `plan-step-complete` with optional filtering.
## history
@@ -589,8 +589,12 @@ Options that are specific to the TUI.
notifications = true
# You can optionally filter to specific notification types.
# Available types are "agent-turn-complete" and "approval-requested".
# Available types are "agent-turn-complete", "approval-requested", and "plan-step-complete".
notifications = [ "agent-turn-complete", "approval-requested" ]
# Emit notifications when a plan step transitions to completed.
# Defaults to true; set to false to silence plan updates.
plan_step_notifications = true
```
> [!NOTE]
@@ -615,6 +619,7 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
| `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). |
| `disable_response_storage` | boolean | Required for ZDR orgs. |
| `notify` | array<string> | External program for notifications. |
| `tui.plan_step_notifications` | boolean | Notify when plan steps complete (default: true). |
| `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. |
| `mcp_servers.<id>.command` | string | MCP server launcher command. |
| `mcp_servers.<id>.args` | array<string> | MCP server args. |