Add goal TUI UX (5 / 5) (#18077)

Adds the TUI user experience for goals on top of the core runtime from
PR 4.

## Why

Users need a direct TUI control surface for long-running goals. The UI
should make the current goal visible, support common goal actions
without waiting for a model turn, and avoid confusing end-of-turn
notifications while an active goal is immediately continuing.

## What changed

- Added `/goal` summary rendering for the current goal, including
active, paused, budget-limited, and complete states.
- Added `/goal <objective>` creation/replacement through the app-server
goal API rather than a model prompt.
- Added `/goal clear`, `/goal pause`, and `/goal unpause` command
variants.
- Added a confirmation menu when the user enters a new goal while
another goal already exists.
- Updated `/goal` help and summary tip text so it reflects the supported
command variants without advertising slash-command token budgets.
- Added footer/statusline goal indicators, including elapsed time and
token budget display when a budget exists from API/tool-created goals.
- Consumes goal updated/cleared notifications so the TUI stays in sync
with external app-server changes.
- Suppresses end-of-turn desktop notifications only when a goal is still
active and follow-up work is expected.
- Preserves slash-command history behavior and avoids leaking queued
`/goal` state into unrelated submissions.

## Verification

- Added TUI unit and snapshot coverage for goal command availability,
summary rendering, control commands, replacement menu behavior,
status/footer display, notification handling, and command history.
This commit is contained in:
Eric Traut
2026-04-24 21:16:45 -07:00
committed by GitHub
parent 4167628622
commit f1c963d77e
32 changed files with 2709 additions and 177 deletions
@@ -872,8 +872,11 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers: VecDeque::new(),
pending_steer_history_records: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
rejected_steer_history_records: VecDeque::new(),
queued_user_messages: VecDeque::new(),
queued_user_message_history_records: VecDeque::new(),
user_turn_pending_start: false,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
@@ -0,0 +1,71 @@
use super::*;
#[tokio::test]
async fn goal_menu_active_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_summary(test_goal(
thread_id,
AppThreadGoalStatus::Active,
/*token_budget*/ Some(80_000),
));
assert_chatwidget_snapshot!("goal_menu_active", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn goal_menu_paused_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_summary(test_goal(
thread_id,
AppThreadGoalStatus::Paused,
/*token_budget*/ None,
));
assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn goal_menu_budget_limited_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_summary(test_goal(
thread_id,
AppThreadGoalStatus::BudgetLimited,
/*token_budget*/ Some(80_000),
));
assert_chatwidget_snapshot!("goal_menu_budget_limited", rendered_goal_summary(&mut rx));
}
fn test_goal(
thread_id: ThreadId,
status: AppThreadGoalStatus,
token_budget: Option<i64>,
) -> AppThreadGoal {
AppThreadGoal {
thread_id: thread_id.to_string(),
objective: "Keep improving the bare goal command until it feels calm and useful."
.to_string(),
status,
token_budget,
tokens_used: 12_500,
time_used_seconds: 90,
created_at: 1_776_272_400,
updated_at: 1_776_272_460,
}
}
fn rendered_goal_summary(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::app_event::AppEvent>,
) -> String {
drain_insert_history(rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n")
}
@@ -256,6 +256,7 @@ pub(super) async fn make_chatwidget_manual(
suppress_queue_autosend: false,
thread_id: None,
last_turn_id: None,
budget_limited_turn_ids: HashSet::new(),
thread_name: None,
thread_rename_block_message: None,
active_side_conversation: false,
@@ -267,8 +268,10 @@ pub(super) async fn make_chatwidget_manual(
show_welcome_banner: true,
startup_tooltip_override: None,
queued_user_messages: VecDeque::new(),
queued_user_message_history_records: VecDeque::new(),
user_turn_pending_start: false,
rejected_steers_queue: VecDeque::new(),
rejected_steer_history_records: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
@@ -304,6 +307,9 @@ pub(super) async fn make_chatwidget_manual(
status_line_branch_cwd: None,
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
current_goal_status_indicator: None,
current_goal_status: None,
goal_status_active_turn_started_at: None,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_event: None,
@@ -584,6 +590,7 @@ pub(super) fn complete_assistant_message(
pub(super) fn pending_steer(text: &str) -> PendingSteer {
PendingSteer {
user_message: UserMessage::from(text),
history_record: UserMessageHistoryRecord::UserMessageText,
compare_key: PendingSteerCompareKey {
message: text.to_string(),
image_count: 0,
@@ -441,8 +441,11 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers,
pending_steer_history_records: VecDeque::new(),
rejected_steers_queue,
rejected_steer_history_records: VecDeque::new(),
queued_user_messages,
queued_user_message_history_records: VecDeque::new(),
user_turn_pending_start: false,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
@@ -1051,6 +1054,24 @@ async fn ctrl_c_shutdown_works_with_caps_lock() {
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn ctrl_c_interrupts_without_arming_quit_when_double_press_disabled() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.set_task_running(/*running*/ true);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
next_interrupt_op(&mut op_rx);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
next_interrupt_op(&mut op_rx);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn ctrl_c_closes_realtime_conversation_before_interrupt_or_quit() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1300,6 +1321,132 @@ async fn interrupted_turn_error_message_snapshot() {
assert_chatwidget_snapshot!("interrupted_turn_error_message", last);
}
#[tokio::test]
async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.handle_server_notification(
codex_app_server_protocol::ServerNotification::TurnStarted(
codex_app_server_protocol::TurnStartedNotification {
thread_id: "thread-1".to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::InProgress,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
},
),
/*replay_kind*/ None,
);
chat.handle_server_notification(
codex_app_server_protocol::ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
goal: codex_app_server_protocol::ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "Run until the token budget is limited".to_string(),
status: codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
token_budget: Some(10_000),
tokens_used: 10_500,
time_used_seconds: 0,
created_at: 0,
updated_at: 1,
},
},
),
/*replay_kind*/ None,
);
chat.handle_server_notification(
codex_app_server_protocol::ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::Interrupted,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
},
),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
let last = lines_to_single_string(cells.last().unwrap());
assert_chatwidget_snapshot!("interrupted_turn_goal_budget_limited_message", last);
}
#[tokio::test]
async fn direct_budget_limited_turn_uses_budget_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::BudgetLimited,
completed_at: None,
duration_ms: None,
}),
});
let cells = drain_insert_history(&mut rx);
let last = lines_to_single_string(cells.last().unwrap());
assert_chatwidget_snapshot!("direct_budget_limited_turn_message", last);
}
#[tokio::test]
async fn budget_limited_turn_restores_queued_input_without_submitting() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.queued_user_messages
.push_back(UserMessage::from("follow-up after budget stop").into());
chat.refresh_pending_input_preview();
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::BudgetLimited,
completed_at: None,
duration_ms: None,
}),
});
assert!(chat.queued_user_messages.is_empty());
assert_eq!(
chat.bottom_pane.composer_text(),
"follow-up after budget stop"
);
assert_no_submit_op(&mut op_rx);
}
// Snapshot test: interrupting specifically to submit pending steers shows an
// informational banner instead of the generic "tell the model what to do
// differently" error prompt.
@@ -30,6 +30,19 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
chat.bottom_pane.composer_text()
}
fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> String {
loop {
match op_rx.try_recv() {
Ok(Op::AddToHistory { text }) => return text,
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"),
Err(TryRecvError::Disconnected) => {
panic!("expected AddToHistory op but channel closed")
}
}
}
}
#[tokio::test]
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -664,6 +677,445 @@ async fn inline_slash_command_is_available_from_local_recall_after_dispatch() {
assert_eq!(chat.bottom_pane.composer_text(), "/rename Better title");
}
#[tokio::test]
async fn goal_slash_command_emits_set_goal_event() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
let command = "/goal --tokens 98.5K improve benchmark coverage";
submit_composer_text(&mut chat, command);
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
objective,
mode,
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "--tokens 98.5K improve benchmark coverage");
assert_eq!(mode, crate::app_event::ThreadGoalSetMode::ConfirmIfExists);
assert_no_submit_op(&mut op_rx);
assert_eq!(recall_latest_after_clearing(&mut chat), command);
}
#[tokio::test]
async fn goal_slash_command_uses_plain_text_for_mentions() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.bottom_pane.set_composer_text_with_mention_bindings(
"/goal use $figma for the mockup".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
mention: "figma".to_string(),
path: "app://figma".to_string(),
}],
);
chat.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "use $figma for the mockup");
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn goal_slash_command_drops_attached_images() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
let remote_url = "https://example.com/goal.png".to_string();
let local_image = PathBuf::from("/tmp/goal-local.png");
let placeholder = "[Image #2]";
let command = format!("/goal describe {placeholder}");
let placeholder_start = command.find(placeholder).expect("placeholder in command");
chat.set_remote_image_urls(vec![remote_url]);
chat.bottom_pane.set_composer_text(
command,
vec![TextElement::new(
(placeholder_start..placeholder_start + placeholder.len()).into(),
Some(placeholder.to_string()),
)],
vec![local_image],
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "describe [Image #2]");
assert!(chat.remote_image_urls().is_empty());
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn bare_goal_slash_command_drains_pending_submission_state() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
let remote_url = "https://example.com/goal-menu.png".to_string();
let local_image = PathBuf::from("/tmp/goal-menu-local.png");
chat.set_remote_image_urls(vec![remote_url]);
chat.bottom_pane
.set_composer_text("/goal".to_string(), Vec::new(), vec![local_image]);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::OpenThreadGoalMenu { thread_id: opened }) if opened == thread_id
);
assert!(chat.remote_image_urls().is_empty());
assert!(chat.bottom_pane.composer_local_image_paths().is_empty());
}
#[tokio::test]
async fn goal_control_slash_commands_emit_goal_events() {
let cases = [
("/goal clear", None),
("/goal pause", Some(AppThreadGoalStatus::Paused)),
("/goal unpause", Some(AppThreadGoalStatus::Active)),
];
for (command, status) in cases {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
submit_composer_text(&mut chat, command);
match status {
Some(status) => {
let event = rx.try_recv().expect("expected goal status event");
let AppEvent::SetThreadGoalStatus {
thread_id: actual_thread_id,
status: actual_status,
} = event
else {
panic!("expected SetThreadGoalStatus, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(actual_status, status);
}
None => {
let event = rx.try_recv().expect("expected clear goal event");
let AppEvent::ClearThreadGoal {
thread_id: actual_thread_id,
} = event
else {
panic!("expected ClearThreadGoal, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
}
}
}
}
#[tokio::test]
async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let command = "/goal improve benchmark coverage";
submit_composer_text(&mut chat, command);
assert_eq!(chat.queued_user_messages.len(), 1);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.maybe_send_next_queued_input();
let event = rx.try_recv().expect("expected goal objective event");
let AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
objective,
..
} = event
else {
panic!("expected SetThreadGoalObjective, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_eq!(objective, "improve benchmark coverage");
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn queued_goal_slash_command_preserves_current_draft_metadata() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let command = "/goal improve benchmark coverage";
submit_composer_text(&mut chat, command);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
let remote_url = "https://example.com/current-draft.png".to_string();
let local_image = PathBuf::from("/tmp/current-draft-local.png");
let placeholder = "[Image #3]";
let draft = format!("draft with {placeholder}");
let placeholder_start = draft.find(placeholder).expect("placeholder in draft");
chat.set_remote_image_urls(vec![remote_url.clone()]);
chat.bottom_pane.set_composer_text(
draft.clone(),
vec![TextElement::new(
(placeholder_start..placeholder_start + placeholder.len()).into(),
Some(placeholder.to_string()),
)],
vec![local_image.clone()],
);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.maybe_send_next_queued_input();
let event = rx.try_recv().expect("expected goal objective event");
assert_matches!(
event,
AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
..
} if actual_thread_id == thread_id
);
assert_no_submit_op(&mut op_rx);
assert_eq!(chat.bottom_pane.composer_text(), draft);
assert_eq!(chat.remote_image_urls(), vec![remote_url]);
assert_eq!(
chat.bottom_pane.composer_local_image_paths(),
vec![local_image]
);
}
#[tokio::test]
async fn restored_queued_goal_slash_command_emits_set_goal_event() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let command = "/goal improve benchmark coverage";
submit_composer_text(&mut chat, command);
let input_state = chat
.capture_thread_input_state()
.expect("expected queued input state");
let (mut restored_chat, mut restored_rx, mut restored_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
restored_chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
restored_chat.restore_thread_input_state(Some(input_state));
let thread_id = ThreadId::new();
restored_chat.thread_id = Some(thread_id);
restored_chat.maybe_send_next_queued_input();
let event = restored_rx
.try_recv()
.expect("expected goal objective event");
assert_matches!(
event,
AppEvent::SetThreadGoalObjective {
thread_id: actual_thread_id,
..
} if actual_thread_id == thread_id
);
assert_no_submit_op(&mut restored_op_rx);
}
#[test]
fn merged_history_record_preserves_raw_text_and_rebased_elements() {
let first = UserMessage {
text: "Ask $figma".to_string(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))],
mention_bindings: vec![MentionBinding {
mention: "figma".to_string(),
path: "app://figma".to_string(),
}],
};
let second = UserMessage::from("internal prompt");
let (_message, history_record) = merge_user_messages_with_history_record(vec![
(first, UserMessageHistoryRecord::UserMessageText),
(
second,
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
text: "/goal inspect [Image #1]".to_string(),
text_elements: vec![TextElement::new(
(14..24).into(),
Some("[Image #1]".to_string()),
)],
}),
),
]);
assert_eq!(
history_record,
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
text: "Ask $figma\n/goal inspect [Image #1]".to_string(),
text_elements: vec![
TextElement::new((4..10).into(), Some("$figma".to_string())),
TextElement::new((25..35).into(), Some("[Image #1]".to_string())),
],
})
);
}
#[test]
fn merged_history_record_remaps_override_image_placeholders() {
let first_placeholder = "[Image #1]";
let second_placeholder = "[Image #1]";
let first = UserMessage {
text: format!("first {first_placeholder}"),
local_images: vec![LocalImageAttachment {
placeholder: first_placeholder.to_string(),
path: PathBuf::from("/tmp/first.png"),
}],
remote_image_urls: Vec::new(),
text_elements: vec![TextElement::new(
(6..16).into(),
Some(first_placeholder.to_string()),
)],
mention_bindings: Vec::new(),
};
let second = UserMessage {
text: format!("internal {second_placeholder}"),
local_images: vec![LocalImageAttachment {
placeholder: second_placeholder.to_string(),
path: PathBuf::from("/tmp/second.png"),
}],
remote_image_urls: Vec::new(),
text_elements: vec![TextElement::new(
(9..19).into(),
Some(second_placeholder.to_string()),
)],
mention_bindings: Vec::new(),
};
let (message, history_record) = merge_user_messages_with_history_record(vec![
(first, UserMessageHistoryRecord::UserMessageText),
(
second,
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
text: format!("goal {second_placeholder}"),
text_elements: vec![TextElement::new(
(5..15).into(),
Some(second_placeholder.to_string()),
)],
}),
),
]);
assert_eq!(message.text, "first [Image #1]\ninternal [Image #2]");
assert_eq!(
message.text_elements,
vec![
TextElement::new((6..16).into(), Some("[Image #1]".to_string())),
TextElement::new((26..36).into(), Some("[Image #2]".to_string())),
]
);
assert_eq!(
message
.local_images
.iter()
.map(|image| image.placeholder.as_str())
.collect::<Vec<_>>(),
vec!["[Image #1]", "[Image #2]"]
);
assert_eq!(
history_record,
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
text: "first [Image #1]\ngoal [Image #2]".to_string(),
text_elements: vec![
TextElement::new((6..16).into(), Some("[Image #1]".to_string())),
TextElement::new((22..32).into(), Some("[Image #2]".to_string())),
],
})
);
}
#[tokio::test]
async fn interrupted_merged_message_history_encodes_mentions_once() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
let text = "use $figma now";
chat.bottom_pane.set_composer_text_with_mention_bindings(
text.to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
mention: "figma".to_string(),
path: "app://figma".to_string(),
}],
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => {
let [
UserInput::Text {
text: submitted, ..
},
] = items.as_slice()
else {
panic!("expected text item, got {items:?}");
};
assert_eq!(submitted, text);
}
other => panic!("expected user turn, got {other:?}"),
}
let encoded = "use [$figma](app://figma) now";
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => {
let [
UserInput::Text {
text: submitted, ..
},
] = items.as_slice()
else {
panic!("expected resubmitted text item, got {items:?}");
};
assert_eq!(submitted, text);
}
other => panic!("expected resubmitted user turn, got {other:?}"),
}
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
}
#[tokio::test]
async fn slash_rename_prefills_existing_thread_name() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1034,6 +1486,91 @@ async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() {
);
}
#[tokio::test]
async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notification() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: codex_app_server_protocol::ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "finish the benchmark".to_string(),
status: codex_app_server_protocol::ThreadGoalStatus::Active,
token_budget: None,
tokens_used: 0,
time_used_seconds: 0,
created_at: 1,
updated_at: 1,
},
},
),
/*replay_kind*/ None,
);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
});
assert_matches!(chat.pending_notification, None);
}
#[tokio::test]
async fn queued_follow_up_suppresses_agent_turn_complete_notification() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.queue_user_message("Continue".into());
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
});
assert_matches!(chat.pending_notification, None);
assert!(chat.queued_user_messages.is_empty());
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
}
#[tokio::test]
async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/model");
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Done"))),
});
assert_matches!(
chat.pending_notification,
Some(Notification::AgentTurnComplete { ref response }) if response == "Done"
);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Select Model"));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn slash_copy_uses_latest_surviving_response_after_rollback() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1,4 +1,5 @@
use super::*;
use crate::bottom_pane::goal_status_indicator_line;
use pretty_assertions::assert_eq;
/// Receiving a TokenCount event without usage clears the context indicator.
@@ -1628,6 +1629,279 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() {
);
}
#[tokio::test]
async fn status_line_goal_active_token_budget_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
chat.refresh_status_line();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
),
},
),
/*replay_kind*/ None,
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw goal status footer");
assert_chatwidget_snapshot!(
"status_line_goal_active_token_budget_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_goal_complete_elapsed_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
chat.refresh_status_line();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Complete,
/*token_budget*/ None,
/*tokens_used*/ 40_000,
),
},
),
/*replay_kind*/ None,
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw goal status footer");
assert_chatwidget_snapshot!(
"status_line_goal_complete_elapsed_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn session_configured_clears_goal_status_footer() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
),
},
),
/*replay_kind*/ None,
);
assert_eq!(
chat.current_goal_status_indicator,
Some(GoalStatusIndicator::Active {
usage: Some("40K / 50K".to_string())
})
);
chat.budget_limited_turn_ids.insert("turn-1".to_string());
let rollout_file = NamedTempFile::new().unwrap();
chat.handle_codex_event(Event {
id: "session-2".into(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-5.4".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
}),
});
assert_eq!(chat.current_goal_status_indicator, None);
assert!(chat.budget_limited_turn_ids.is_empty());
}
#[tokio::test]
async fn thread_goal_update_for_other_thread_is_ignored() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.thread_id = Some(ThreadId::new());
let other_thread_id = ThreadId::new().to_string();
let mut goal = test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 50_000,
);
goal.thread_id = other_thread_id.clone();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: other_thread_id,
turn_id: Some("turn-other".to_string()),
goal,
},
),
/*replay_kind*/ None,
);
assert_eq!(chat.current_goal_status_indicator, None);
assert!(chat.current_goal_status.is_none());
assert!(chat.budget_limited_turn_ids.is_empty());
}
#[test]
fn goal_status_indicator_formats_statuses_and_budgets() {
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
)),
Some(GoalStatusIndicator::Active {
usage: Some("40K / 50K".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ None,
/*tokens_used*/ 0,
)),
Some(GoalStatusIndicator::Active {
usage: Some("30m".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 51_000,
)),
Some(GoalStatusIndicator::BudgetLimited {
usage: Some("51K / 50K tokens".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
/*token_budget*/ None,
/*tokens_used*/ 0,
)),
Some(GoalStatusIndicator::BudgetLimited { usage: None })
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Complete,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
)),
Some(GoalStatusIndicator::Complete {
usage: Some("40K tokens".to_string()),
})
);
}
#[test]
fn goal_status_indicator_line_formats_goal_text() {
let cases = [
(
GoalStatusIndicator::Active {
usage: Some("4K / 5K".to_string()),
},
"Pursuing goal (4K / 5K)",
),
(
GoalStatusIndicator::BudgetLimited {
usage: Some("4K / 5K tokens".to_string()),
},
"Goal unmet (4K / 5K tokens)",
),
(
GoalStatusIndicator::Paused,
"Goal paused (/goal to unpause)",
),
(
GoalStatusIndicator::BudgetLimited { usage: None },
"Goal abandoned",
),
(
GoalStatusIndicator::Complete {
usage: Some("10h 12m".to_string()),
},
"Goal achieved (10h 12m)",
),
(
GoalStatusIndicator::Complete { usage: None },
"Goal achieved",
),
];
for (indicator, expected) in cases {
let line =
goal_status_indicator_line(Some(&indicator)).expect("goal indicator should render");
let actual = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(expected, actual);
}
}
fn test_thread_goal(
status: codex_app_server_protocol::ThreadGoalStatus,
token_budget: Option<i64>,
tokens_used: i64,
) -> codex_app_server_protocol::ThreadGoal {
codex_app_server_protocol::ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "Keep improving the benchmark".to_string(),
status,
token_budget,
tokens_used,
time_used_seconds: 30 * 60,
created_at: 0,
updated_at: 0,
}
}
#[tokio::test]
async fn runtime_metrics_websocket_timing_logs_and_final_separator_sums_totals() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;