Files
codex/codex-rs/tui/src/chatwidget/protocol.rs
T
Eric Traut edc48e4612 Sync TUI thread settings through app server (#23507)
Builds on #23502.

## Why

#23502 adds the app-server `thread/settings/update` API and matching
`thread/settings/updated` notification. The TUI already lets users
change thread-scoped settings such as model, reasoning effort, service
tier, approvals, permissions, personality, and collaboration mode, but
those updates need to flow through the app server so embedded and
connected clients observe the same thread state.

This is a rework (simplification) of PR
https://github.com/openai/codex/pull/22510. It has the same
functionality, but the underlying `thread/settings/update` api is now
simpler in that it no longer returns the effective settings as a
response. Now, clients receive the effective settings only through the
`thread/settings/updated` notification.

## What Changed

This updates the TUI to send `thread/settings/update` whenever those
thread-scoped settings change and to treat the RPC response as the
authoritative acknowledgement. It also routes `thread/settings/updated`
notifications back into cached session state and the visible chat widget
so active and inactive threads stay in sync after app-server-originated
changes.

The implementation is kept to the TUI layer: settings conversion and
merge logic live under `codex-rs/tui/src/app/thread_settings.rs`, with
dispatch/routing hooks in the existing app and chat widget paths.

## Verification

I manually tested using `codex app-server --listen unix://` and then
launching two copies of the TUI that use the same local app server. I
then resumed the same thread on both and verified that changes like plan
mode, fast mode, model, reasoning effort, etc. are reflected "live" in
the second client when modified in the first and vice versa.
2026-05-20 11:05:14 -07:00

357 lines
15 KiB
Rust

use super::*;
impl ChatWidget {
pub(crate) fn handle_server_notification(
&mut self,
notification: ServerNotification,
replay_kind: Option<ReplayKind>,
) {
if self.active_side_conversation
&& replay_kind.is_none()
&& matches!(notification, ServerNotification::McpServerStatusUpdated(_))
{
return;
}
let from_replay = replay_kind.is_some();
let is_resume_initial_replay =
matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages));
let is_retry_error = matches!(
&notification,
ServerNotification::Error(ErrorNotification {
will_retry: true,
..
})
);
if !is_resume_initial_replay && !is_retry_error {
self.restore_retry_status_header_if_present();
}
match notification {
ServerNotification::ThreadTokenUsageUpdated(notification) => {
self.set_token_info(Some(token_usage_info_from_app_server(
notification.token_usage,
)));
}
ServerNotification::ThreadNameUpdated(notification) => {
match ThreadId::from_string(&notification.thread_id) {
Ok(thread_id) => {
self.on_thread_name_updated(thread_id, notification.thread_name)
}
Err(err) => {
tracing::warn!(
thread_id = notification.thread_id,
error = %err,
"ignoring app-server ThreadNameUpdated with invalid thread_id"
);
}
}
}
ServerNotification::ThreadGoalUpdated(notification) => {
self.on_thread_goal_updated(notification.goal, notification.turn_id);
}
ServerNotification::ThreadGoalCleared(notification) => {
self.on_thread_goal_cleared(notification.thread_id.as_str());
}
ServerNotification::ThreadSettingsUpdated(notification) => {
self.on_thread_settings_updated(notification);
}
ServerNotification::TurnStarted(notification) => {
self.turn_lifecycle.last_turn_id = Some(notification.turn.id);
self.last_non_retry_error = None;
if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) {
self.on_task_started();
}
}
ServerNotification::TurnCompleted(notification) => {
self.handle_turn_completed_notification(notification, replay_kind);
}
ServerNotification::ItemStarted(notification) => {
self.handle_item_started_notification(notification, replay_kind.is_some());
}
ServerNotification::ItemCompleted(notification) => {
self.handle_item_completed_notification(notification, replay_kind);
}
ServerNotification::AgentMessageDelta(notification) => {
self.on_agent_message_delta(notification.delta);
}
ServerNotification::PlanDelta(notification) => self.on_plan_delta(notification.delta),
ServerNotification::ReasoningSummaryTextDelta(notification) => {
self.on_agent_reasoning_delta(notification.delta);
}
ServerNotification::ReasoningTextDelta(notification) => {
if self.config.show_raw_agent_reasoning {
self.on_agent_reasoning_delta(notification.delta);
}
}
ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(),
ServerNotification::TerminalInteraction(notification) => {
self.on_terminal_interaction(notification.process_id, notification.stdin)
}
ServerNotification::CommandExecutionOutputDelta(notification) => {
self.on_exec_command_output_delta(&notification.item_id, &notification.delta);
}
ServerNotification::FileChangeOutputDelta(notification) => {
self.on_patch_apply_output_delta(notification.item_id, notification.delta);
}
ServerNotification::TurnDiffUpdated(notification) => {
self.on_turn_diff(notification.diff)
}
ServerNotification::TurnPlanUpdated(notification) => {
self.on_plan_update(UpdatePlanArgs {
explanation: notification.explanation,
plan: notification
.plan
.into_iter()
.map(|step| UpdatePlanItemArg {
step: step.step,
status: match step.status {
TurnPlanStepStatus::Pending => UpdatePlanItemStatus::Pending,
TurnPlanStepStatus::InProgress => UpdatePlanItemStatus::InProgress,
TurnPlanStepStatus::Completed => UpdatePlanItemStatus::Completed,
},
})
.collect(),
})
}
ServerNotification::HookStarted(notification) => {
self.on_hook_started(notification.run);
}
ServerNotification::HookCompleted(notification) => {
self.on_hook_completed(notification.run);
}
ServerNotification::Error(notification) => {
if notification.will_retry {
if !from_replay {
self.on_stream_error(
notification.error.message,
notification.error.additional_details,
);
}
} else {
self.last_non_retry_error = Some((
notification.turn_id.clone(),
notification.error.message.clone(),
));
self.handle_non_retry_error(
notification.error.message,
notification.error.codex_error_info,
);
}
}
ServerNotification::SkillsChanged(_) => {
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
}
ServerNotification::ModelRerouted(_) => {}
ServerNotification::ModelVerification(notification) => {
self.on_app_server_model_verification(&notification.verifications)
}
ServerNotification::Warning(notification) => self.on_warning(notification.message),
ServerNotification::GuardianWarning(notification) => {
self.on_warning(notification.message)
}
ServerNotification::DeprecationNotice(notification) => {
self.on_deprecation_notice(notification.summary, notification.details)
}
ServerNotification::ConfigWarning(notification) => self.on_warning(
notification
.details
.map(|details| format!("{}: {details}", notification.summary))
.unwrap_or(notification.summary),
),
ServerNotification::McpServerStatusUpdated(notification) => {
self.on_mcp_server_status_updated(notification)
}
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
self.on_guardian_review_notification(
notification.review_id,
notification.turn_id,
notification.started_at_ms,
notification.review,
/*completion*/ None,
notification.action,
);
}
ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => {
self.on_guardian_review_notification(
notification.review_id,
notification.turn_id,
notification.started_at_ms,
notification.review,
Some((notification.completed_at_ms, notification.decision_source)),
notification.action,
);
}
ServerNotification::ThreadClosed(_) => {
if !from_replay {
self.on_shutdown_complete();
}
}
ServerNotification::ThreadRealtimeStarted(notification) => {
if !from_replay {
self.on_realtime_conversation_started(notification);
}
}
ServerNotification::ThreadRealtimeItemAdded(notification) => {
if !from_replay {
self.on_realtime_item_added(notification);
}
}
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => {
if !from_replay {
self.on_realtime_output_audio_delta(notification);
}
}
ServerNotification::ThreadRealtimeError(notification) => {
if !from_replay {
self.on_realtime_error(notification);
}
}
ServerNotification::ThreadRealtimeClosed(notification) => {
if !from_replay {
self.on_realtime_conversation_closed(notification);
}
}
ServerNotification::ThreadRealtimeSdp(notification) => {
if !from_replay {
self.on_realtime_conversation_sdp(notification.sdp);
}
}
ServerNotification::ServerRequestResolved(_)
| ServerNotification::AccountUpdated(_)
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::ThreadStarted(_)
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadUnarchived(_)
| ServerNotification::RawResponseItemCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::ProcessOutputDelta(_)
| ServerNotification::ProcessExited(_)
| ServerNotification::FileChangePatchUpdated(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::RemoteControlStatusChanged(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::FsChanged(_)
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
| ServerNotification::ThreadRealtimeTranscriptDelta(_)
| ServerNotification::ThreadRealtimeTranscriptDone(_)
| ServerNotification::WindowsWorldWritableWarning(_)
| ServerNotification::WindowsSandboxSetupCompleted(_)
| ServerNotification::AccountLoginCompleted(_) => {}
ServerNotification::ContextCompacted(_) => {}
}
}
pub(super) fn handle_turn_completed_notification(
&mut self,
notification: TurnCompletedNotification,
replay_kind: Option<ReplayKind>,
) {
// User-message dedupe only suppresses the app-server echo of a prompt
// this TUI already rendered locally. Once that turn ends, another
// client can submit the same text and it still needs its own user cell.
self.last_rendered_user_message_display = None;
match notification.turn.status {
TurnStatus::Completed => {
self.last_non_retry_error = None;
self.on_task_complete(
/*last_agent_message*/ None,
notification.turn.duration_ms,
replay_kind.is_some(),
)
}
TurnStatus::Interrupted => {
self.last_non_retry_error = None;
let reason = if self
.turn_lifecycle
.take_budget_limited(notification.turn.id.as_str())
{
TurnAbortReason::BudgetLimited
} else {
TurnAbortReason::Interrupted
};
self.on_interrupted_turn(reason);
}
TurnStatus::Failed => {
if let Some(error) = notification.turn.error {
if self.last_non_retry_error.as_ref()
== Some(&(notification.turn.id.clone(), error.message.clone()))
{
self.last_non_retry_error = None;
} else {
self.handle_non_retry_error(error.message, error.codex_error_info);
}
} else {
self.last_non_retry_error = None;
self.finalize_turn();
self.request_redraw();
self.maybe_send_next_queued_input();
}
}
TurnStatus::InProgress => {}
}
}
fn handle_item_started_notification(
&mut self,
notification: ItemStartedNotification,
from_replay: bool,
) {
match notification.item {
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item),
ThreadItem::FileChange { id: _, changes, .. } => {
self.on_patch_apply_begin(file_update_changes_to_display(changes));
}
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item),
ThreadItem::WebSearch { id, .. } => {
self.on_web_search_begin(id);
}
ThreadItem::ImageGeneration { .. } => {
self.on_image_generation_begin();
}
ThreadItem::CollabAgentToolCall {
id,
tool,
status,
sender_thread_id,
receiver_thread_ids,
prompt,
model,
reasoning_effort,
agents_states,
} => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall {
id,
tool,
status,
sender_thread_id,
receiver_thread_ids,
prompt,
model,
reasoning_effort,
agents_states,
}),
ThreadItem::EnteredReviewMode { review, .. } => {
if !from_replay {
self.enter_review_mode_with_hint(review, /*from_replay*/ false);
}
}
_ => {}
}
}
fn handle_item_completed_notification(
&mut self,
notification: ItemCompletedNotification,
replay_kind: Option<ReplayKind>,
) {
self.handle_thread_item(
notification.item,
notification.turn_id,
replay_kind.map_or(ThreadItemRenderSource::Live, ThreadItemRenderSource::Replay),
);
}
}