Refactor chatwidget orchestration into modules (phase 5) (#22537)

## Why

`chatwidget.rs` is still carrying too many unrelated responsibilities in
one file. #22269 started a five-phase cleanup to move coherent behavior
domains into focused modules while keeping `chatwidget.rs` as the
composition layer. #22407 completed phase 2 by extracting input and
submission flow, #22433 completed phase 3 by extracting protocol,
replay, streaming, and tool lifecycle handling, and #22518 completed
phase 4 by extracting settings, popups, and status surfaces.

This PR is phase 5. It cleans up the remaining constructor and
orchestration code now that the larger behavior domains have moved out,
leaving `chatwidget.rs` much closer to the composition layer the cleanup
was aiming for. This is once again a mechanical movement of existing
functions. No functional changes.

## What Changed

- Added focused modules for widget construction and initial wiring,
session configuration flow, key/composer interaction routing, review
popup orchestration, desktop notification coalescing, and render
composition.
- Moved the remaining constructor, session setup, interaction,
notification, review picker, and rendering helpers out of
`codex-rs/tui/src/chatwidget.rs`.
- Preserved the existing startup/session behavior, keyboard handling,
review picker flow, notification priority behavior, and render
composition while shrinking the central widget module substantially.
- Left `codex-rs/tui/src/chatwidget.rs` as the registration and
composition surface for the extracted behavior modules.

## Cleanup Phases

The five-phase cleanup plan from #22269 is:

1. Phase 1: mechanical helper and state moves. Completed in #22269.
2. Phase 2: extract input and submission flow, including queued user
messages, shell prompt submission, pending steer restoration, and thread
input snapshot/restore behavior. Completed in #22407.
3. Phase 3: extract protocol, replay, streaming, and tool lifecycle
handling, while preserving active-cell grouping, transcript
invalidation, interrupt deferral, and final-message separator behavior.
Completed in #22433.
4. Phase 4: extract settings, popups, and status surfaces, including
model/reasoning/collaboration/personality popups, permission prompts,
rate-limit UI, and connectors helpers. Completed in #22518.
5. Phase 5: clean up the remaining constructor and orchestration code
once the larger behavior domains have moved out, leaving `chatwidget.rs`
as the composition layer. This PR.

## Verification

- `cargo check -p codex-tui`
- `cargo test -p codex-tui chatwidget::tests::popups_and_settings`
- `cargo test -p codex-tui chatwidget::tests::plan_mode`
- `cargo test -p codex-tui chatwidget::tests::review_mode`
- `cargo test -p codex-tui chatwidget::tests::status_and_layout`

`cargo test -p codex-tui` also compiles and begins running, but aborts
in the unchanged app-side test
`app::tests::discard_side_thread_keeps_local_state_when_server_close_fails`
with the same reproducible stack overflow noted in phase 4.
This commit is contained in:
Eric Traut
2026-05-13 15:40:53 -07:00
committed by GitHub
Unverified
parent efdcbba053
commit 3c3e18c222
7 changed files with 1376 additions and 1332 deletions
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
//! Construction and initial wiring for `ChatWidget`.
use super::*;
impl ChatWidget {
pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self {
Self::new_with_op_target(common, CodexOpTarget::AppEvent)
}
pub(super) fn new_with_op_target(
common: ChatWidgetInit,
codex_op_target: CodexOpTarget,
) -> Self {
let ChatWidgetInit {
config,
environment_manager,
frame_requester,
app_event_tx,
workspace_command_runner,
initial_user_message,
enhanced_keys_supported,
has_chatgpt_account,
model_catalog,
feedback,
is_first_run,
status_account_display,
runtime_model_provider_base_url,
initial_plan_type,
model,
startup_tooltip_override,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
session_telemetry,
} = common;
let model = model.filter(|m| !m.trim().is_empty());
let mut config = config;
config.model = model.clone();
let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep);
let mut rng = rand::rng();
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
let side_placeholder =
SIDE_PLACEHOLDERS[rng.random_range(0..SIDE_PLACEHOLDERS.len())].to_string();
let model_override = model.as_deref();
let model_for_header = model
.clone()
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
let active_collaboration_mask =
Self::initial_collaboration_mask(&config, model_catalog.as_ref(), model_override);
let header_model = active_collaboration_mask
.as_ref()
.and_then(|mask| mask.model.clone())
.unwrap_or_else(|| model_for_header.clone());
let fallback_default = Settings {
model: header_model.clone(),
reasoning_effort: None,
developer_instructions: None,
};
// Collaboration modes start in Default mode.
let current_collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: fallback_default,
};
let active_cell = Some(Self::placeholder_session_header_cell(&config));
let current_cwd = Some(config.cwd.to_path_buf());
let effective_service_tier = config.service_tier.clone();
let current_terminal_info = terminal_info();
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
let default_keymap = RuntimeKeymap::defaults();
let copy_last_response_binding = runtime_keymap
.as_ref()
.map(|keymap| keymap.app.copy.clone())
.unwrap_or_else(|| default_keymap.app.copy.clone());
let chat_keymap = runtime_keymap
.as_ref()
.map(|keymap| keymap.chat.clone())
.unwrap_or_else(|| default_keymap.chat.clone());
let queued_message_edit_hint_binding = queued_message_edit_hint_binding(
&chat_keymap.edit_queued_message,
current_terminal_info,
);
pets::start_configured_pet_load_if_needed(
&config,
/*ambient_pet_missing*/ true,
frame_requester.clone(),
app_event_tx.clone(),
);
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
codex_op_target,
bottom_pane: BottomPane::new(BottomPaneParams {
frame_requester,
app_event_tx,
has_input_focus: true,
enhanced_keys_supported,
placeholder_text: placeholder.clone(),
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
skills: None,
}),
transcript: TranscriptState::new(active_cell),
raw_output_mode: config.tui_raw_output_mode,
config,
environment_manager,
effective_service_tier,
skills_all: Vec::new(),
skills_initial_state: None,
current_collaboration_mode,
active_collaboration_mask,
has_chatgpt_account,
model_catalog,
session_telemetry,
session_header: SessionHeader::new(header_model),
initial_user_message,
status_account_display,
runtime_model_provider_base_url,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: initial_plan_type,
codex_rate_limit_reached_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
warning_display_state: WarningDisplayState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
add_credits_nudge_email_in_flight: None,
adaptive_chunking: AdaptiveChunkingPolicy::default(),
stream_controller: None,
plan_stream_controller: None,
clipboard_lease: None,
copy_last_response_binding,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
pending_collab_spawn_requests: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
unified_exec_wait_streak: None,
turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep),
task_complete_pending: false,
unified_exec_processes: Vec::new(),
mcp_startup_status: None,
mcp_startup_expected_servers: None,
mcp_startup_ignore_updates_until_next_start: false,
mcp_startup_allow_terminal_only_next_round: false,
mcp_startup_pending_next_round: HashMap::new(),
mcp_startup_pending_next_round_saw_starting: false,
connectors: ConnectorsState::default(),
ide_context: IdeContextState::default(),
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
plugins_active_tab_id: None,
newly_installed_marketplace_tab_id: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
status_state: StatusState::default(),
review: ReviewState::default(),
active_hook_cell: None,
ambient_pet: None,
pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(),
pet_picker_preview_pet: None,
pet_picker_preview_request_id: 0,
pet_picker_preview_image_visible: std::cell::Cell::new(/*value*/ false),
pet_selection_load_request_id: 0,
#[cfg(test)]
pet_image_support_override: None,
thread_id: None,
dismissed_plan_mode_nudge_scopes: HashSet::new(),
thread_name: None,
thread_rename_block_message: None,
active_side_conversation: false,
normal_placeholder_text: placeholder,
side_placeholder_text: side_placeholder,
forked_from: None,
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
input_queue: InputQueueState::default(),
chat_keymap,
queued_message_edit_hint_binding,
show_welcome_banner: is_first_run,
startup_tooltip_override,
suppress_session_configured_redraw: false,
suppress_initial_user_message_submit: false,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
turn_runtime_metrics: RuntimeMetricsSummary::default(),
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
current_cwd,
workspace_command_runner,
instruction_source_paths: Vec::new(),
session_network_proxy: None,
status_line_invalid_items_warned,
terminal_title_invalid_items_warned,
last_terminal_title: None,
last_terminal_title_requires_action: false,
terminal_title_setup_original_items: None,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
status_line_git_summary: None,
status_line_git_summary_cwd: None,
status_line_git_summary_pending: false,
status_line_git_summary_lookup_complete: false,
current_goal_status_indicator: None,
current_goal_status: None,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_display: None,
last_non_retry_error: None,
};
widget.prefetch_rate_limits();
if let Some(keymap) = runtime_keymap {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_vim_enabled(widget.config.tui_vim_mode_default);
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
widget
.bottom_pane
.set_collaboration_modes_enabled(/*enabled*/ true);
widget.sync_service_tier_commands();
widget.sync_personality_command_enabled();
widget.sync_plugins_command_enabled();
widget.sync_goal_command_enabled();
widget.sync_mentions_v2_enabled();
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_hint_binding);
#[cfg(target_os = "windows")]
widget.bottom_pane.set_windows_degraded_sandbox_active(
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& matches!(
WindowsSandboxLevel::from_config(&widget.config),
WindowsSandboxLevel::RestrictedToken
),
);
widget.update_collaboration_mode_indicator();
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget.refresh_status_surfaces();
widget
}
}
+455
View File
@@ -0,0 +1,455 @@
//! Key routing and composer-adjacent UI interaction for `ChatWidget`.
use super::*;
impl ChatWidget {
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.bottom_pane.has_active_view()
&& !matches!(
key_event,
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c')
)
&& !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event)
&& !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event)
{
self.bottom_pane.handle_key_event(key_event);
if self.bottom_pane.no_modal_or_popup_active() {
self.maybe_send_next_queued_input();
}
return;
}
if self.handle_reasoning_shortcut(key_event) {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
return;
}
if key_event.kind == KeyEventKind::Press
&& self.copy_last_response_binding.is_pressed(key_event)
{
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.copy_last_agent_markdown();
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => {
self.on_ctrl_c();
return;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
if self.on_ctrl_d() {
return;
}
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& c.eq_ignore_ascii_case(&'v') =>
{
match paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}",
)));
}
}
return;
}
other if other.kind == KeyEventKind::Press => {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
}
_ => {}
}
if key_event.kind == KeyEventKind::Press
&& self.chat_keymap.edit_queued_message.is_pressed(key_event)
&& self.has_queued_follow_up_messages()
&& self.bottom_pane.no_modal_or_popup_active()
{
if let Some(user_message) = self.pop_latest_queued_user_message() {
self.restore_user_message_to_composer(user_message);
self.refresh_pending_input_preview();
self.request_redraw();
}
return;
}
if matches!(key_event.code, KeyCode::Esc)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !self.input_queue.pending_steers.is_empty()
&& self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active()
&& !self.should_handle_vim_insert_escape(key_event)
{
self.input_queue.submit_pending_steers_after_interrupt = true;
if !self.submit_op(AppCommand::interrupt()) {
self.input_queue.submit_pending_steers_after_interrupt = false;
}
return;
}
if matches!(key_event.code, KeyCode::Esc)
&& key_event.kind == KeyEventKind::Press
&& self.should_show_plan_mode_nudge()
{
self.dismiss_plan_mode_nudge();
return;
}
if self.handle_plugins_popup_key_event(key_event) {
return;
}
match key_event {
KeyEvent {
code: KeyCode::BackTab,
kind: KeyEventKind::Press,
..
} if self.collaboration_modes_enabled()
&& !self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active() =>
{
self.cycle_collaboration_mode();
self.refresh_plan_mode_nudge();
}
_ => {
let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active();
let input_result = self.bottom_pane.handle_key_event(key_event);
self.handle_composer_input_result(input_result, had_modal_or_popup);
}
}
}
/// Attach a local image to the composer when the active model supports image inputs.
///
/// When the model does not advertise image support, we keep the draft unchanged and surface a
/// warning event so users can switch models or remove attachments.
pub(crate) fn attach_image(&mut self, path: PathBuf) {
if !self.current_model_supports_images() {
self.add_to_history(history_cell::new_warning_event(
self.image_inputs_not_supported_message(),
));
self.request_redraw();
return;
}
tracing::info!("attach_image path={path:?}");
self.bottom_pane.attach_image(path);
self.request_redraw();
}
pub(crate) fn composer_text_with_pending(&self) -> String {
self.bottom_pane.composer_text_with_pending()
}
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.bottom_pane.apply_external_edit(text);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
pub(crate) fn external_editor_state(&self) -> ExternalEditorState {
self.external_editor_state
}
pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) {
self.external_editor_state = state;
}
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
self.bottom_pane.set_footer_hint_override(items);
}
pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) {
self.bottom_pane.show_selection_view(params);
self.refresh_plan_mode_nudge();
self.request_redraw();
}
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
self.bottom_pane.no_modal_or_popup_active()
}
pub(crate) fn can_launch_external_editor(&self) -> bool {
self.bottom_pane.can_launch_external_editor()
}
pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool {
// Ctrl+L is not a slash command, but it follows /clear's current rule:
// block while a task is running.
if !self.bottom_pane.is_task_running() {
return true;
}
let message = "Ctrl+L is disabled while a task is in progress.".to_string();
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
false
}
/// Copy the last agent response (raw markdown) to the system clipboard.
pub(crate) fn copy_last_agent_markdown(&mut self) {
self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard);
}
pub(crate) fn truncate_agent_copy_history_to_user_turn_count(
&mut self,
user_turn_count: usize,
) {
self.transcript
.truncate_copy_history_to_user_turn_count(user_turn_count);
}
/// Inner implementation with an injectable clipboard backend for testing.
pub(super) fn copy_last_agent_markdown_with(
&mut self,
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) {
match self.transcript.last_agent_markdown.clone() {
Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) {
Ok(lease) => {
self.clipboard_lease = lease;
self.add_to_history(history_cell::new_info_event(
"Copied last message to clipboard".into(),
/*hint*/ None,
));
}
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
"Copy failed: {error}"
))),
},
_ if self.transcript.copy_history_evicted_by_rollback => {
self.add_to_history(history_cell::new_error_event(format!(
"Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy."
)));
}
_ => self.add_to_history(history_cell::new_error_event(
"No agent response to copy".into(),
)),
}
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
self.transcript.last_agent_markdown.as_deref()
}
pub(super) fn show_rename_prompt(&mut self) {
if !self.ensure_thread_rename_allowed() {
return;
}
let tx = self.app_event_tx.clone();
let existing_name = self.thread_name.as_deref().filter(|name| !name.is_empty());
let title = if existing_name.is_some() {
"Rename thread"
} else {
"Name thread"
};
let view = CustomPromptView::new(
title.to_string(),
"Type a name and press Enter".to_string(),
/*initial_text*/ existing_name.unwrap_or_default().to_string(),
/*context_label*/ None,
Box::new(move |name: String| {
let Some(name) = crate::legacy_core::util::normalize_thread_name(&name) else {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event("Thread name cannot be empty.".to_string()),
)));
return;
};
tx.set_thread_name(name);
}),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool {
match self.thread_rename_block_message.clone() {
Some(message) => {
self.add_error_message(message);
false
}
None => true,
}
}
pub(crate) fn handle_paste(&mut self, text: String) {
self.bottom_pane.handle_paste(text);
self.refresh_plan_mode_nudge();
}
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
if self.bottom_pane.flush_paste_burst_if_due() {
self.refresh_plan_mode_nudge();
// A paste just flushed; request an immediate redraw and skip this frame.
self.request_redraw();
true
} else if self.bottom_pane.is_in_paste_burst() {
// While capturing a burst, schedule a follow-up tick and skip this frame
// to avoid redundant renders between ticks.
frame_requester.schedule_frame_in(
crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(),
);
true
} else {
false
}
}
/// Handles a Ctrl+C press at the chat-widget layer.
///
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
/// is armed.
///
/// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the
/// first press always stops live voice, even when the composer contains the recording meter.
///
/// When the double-press quit shortcut is enabled, pressing the same shortcut again before
/// expiry requests a shutdown-first quit.
fn on_ctrl_c(&mut self) {
let key = key_hint::ctrl(KeyCode::Char('c'));
if self.realtime_conversation.is_live() {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.stop_realtime_conversation_from_ui();
return;
}
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if modal_or_popup_active {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
} else {
self.arm_quit_shortcut(key);
}
}
return;
}
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if self.is_cancellable_work_active() {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
self.submit_op(AppCommand::interrupt());
} else {
self.request_quit_without_confirmation();
}
return;
}
if self.quit_shortcut_active_for(key) {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.request_quit_without_confirmation();
return;
}
self.arm_quit_shortcut(key);
if self.is_cancellable_work_active() {
self.submit_op(AppCommand::interrupt());
}
}
/// Handles a Ctrl+D press at the chat-widget layer.
///
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
/// Otherwise it should be routed to the active view and not attempt to quit.
fn on_ctrl_d(&mut self) -> bool {
let key = key_hint::ctrl(KeyCode::Char('d'));
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active()
{
return false;
}
self.request_quit_without_confirmation();
return true;
}
if self.quit_shortcut_active_for(key) {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.request_quit_without_confirmation();
return true;
}
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
return false;
}
self.arm_quit_shortcut(key);
true
}
/// True if `key` matches the armed quit shortcut and the window has not expired.
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
self.quit_shortcut_key == Some(key)
&& self
.quit_shortcut_expires_at
.is_some_and(|expires_at| Instant::now() < expires_at)
}
/// Arm the double-press quit shortcut and show the footer hint.
///
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
pub(super) fn arm_quit_shortcut(&mut self, key: KeyBinding) {
self.quit_shortcut_expires_at = Instant::now()
.checked_add(QUIT_SHORTCUT_TIMEOUT)
.or_else(|| Some(Instant::now()));
self.quit_shortcut_key = Some(key);
self.bottom_pane.show_quit_shortcut_hint(key);
}
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
fn is_cancellable_work_active(&self) -> bool {
self.bottom_pane.is_task_running() || self.review.is_review_mode
}
}
@@ -0,0 +1,128 @@
//! Desktop notification coalescing for `ChatWidget`.
use super::*;
impl ChatWidget {
pub(super) fn notify(&mut self, notification: Notification) {
if !notification.allowed_for(&self.config.tui_notifications.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;
+129
View File
@@ -0,0 +1,129 @@
//! Render composition for the main chat widget surface.
use super::*;
impl ChatWidget {
pub(super) fn as_renderable(&self) -> RenderableItem<'_> {
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
let active_cell_renderable = match &self.transcript.active_cell {
Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell.as_ref(),
top: 1,
right: active_cell_right_reserve,
})),
None => RenderableItem::Owned(Box::new(())),
};
let active_hook_cell_renderable = match &self.active_hook_cell {
Some(cell) if cell.should_render() => {
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell,
top: 1,
right: active_cell_right_reserve,
}))
}
_ => RenderableItem::Owned(Box::new(())),
};
let mut flex = FlexRenderable::new();
flex.push(/*flex*/ 1, active_cell_renderable);
flex.push(/*flex*/ 0, active_hook_cell_renderable);
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
bottom_pane: &self.bottom_pane,
right_reserve: active_cell_right_reserve,
}))
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
RenderableItem::Owned(Box::new(flex))
}
}
struct BottomPaneComposerReserveRenderable<'a> {
bottom_pane: &'a BottomPane,
right_reserve: u16,
}
impl Renderable for BottomPaneComposerReserveRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.bottom_pane
.render_with_composer_right_reserve(area, buf, self.right_reserve);
}
fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane
.desired_height_with_composer_right_reserve(width, self.right_reserve)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.bottom_pane
.cursor_pos_with_composer_right_reserve(area, self.right_reserve)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.bottom_pane
.cursor_style_with_composer_right_reserve(area, self.right_reserve)
}
}
struct TranscriptAreaRenderable<'a> {
child: &'a dyn HistoryCell,
top: u16,
right: u16,
}
impl Renderable for TranscriptAreaRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let area = self.child_area(area);
let lines = self.child.display_lines(area.width);
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
let y = if area.height == 0 {
0
} else {
let overflow = paragraph
.line_count(area.width)
.saturating_sub(usize::from(area.height));
u16::try_from(overflow).unwrap_or(u16::MAX)
};
Clear.render(area, buf);
paragraph.scroll((y, 0)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let child_width = width.saturating_sub(self.right).max(1);
HistoryCell::desired_height(self.child, child_width) + self.top
}
}
impl TranscriptAreaRenderable<'_> {
fn child_area(&self, area: Rect) -> Rect {
let y = area.y.saturating_add(self.top);
let height = area.height.saturating_sub(self.top);
Rect::new(
area.x,
y,
area.width.saturating_sub(self.right).max(1),
height,
)
}
}
impl Renderable for ChatWidget {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_renderable().render(area, buf);
self.last_rendered_width.set(Some(area.width as usize));
}
fn desired_height(&self, width: u16) -> u16 {
self.as_renderable().desired_height(width)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.as_renderable().cursor_pos(area)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.as_renderable().cursor_style(area)
}
}
@@ -0,0 +1,182 @@
//! Review preset selection and custom review prompt surfaces.
use super::*;
impl ChatWidget {
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();
items.push(SelectionItem {
name: "Review against a base branch".to_string(),
description: Some("(PR Style)".into()),
actions: vec![Box::new({
let cwd = self.config.cwd.to_path_buf();
move |tx| {
tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone()));
}
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
items.push(SelectionItem {
name: "Review uncommitted changes".to_string(),
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.review(ReviewTarget::UncommittedChanges);
})],
dismiss_on_select: true,
..Default::default()
});
items.push(SelectionItem {
name: "Review a commit".to_string(),
actions: vec![Box::new({
let cwd = self.config.cwd.to_path_buf();
move |tx| {
tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone()));
}
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
items.push(SelectionItem {
name: "Custom review instructions".to_string(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReviewCustomPrompt);
})],
dismiss_on_select: false,
dismiss_parent_on_child_accept: true,
..Default::default()
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a review preset".into()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) {
let branches = local_git_branches(cwd).await;
let current_branch = current_branch_name(cwd)
.await
.unwrap_or_else(|| "(detached HEAD)".to_string());
let mut items: Vec<SelectionItem> = Vec::with_capacity(branches.len());
for option in branches {
let branch = option.clone();
items.push(SelectionItem {
name: format!("{current_branch} -> {branch}"),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review(ReviewTarget::BaseBranch {
branch: branch.clone(),
});
})],
dismiss_on_select: true,
search_value: Some(option),
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a base branch".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
..Default::default()
});
}
pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) {
let commits = recent_commits(cwd, /*limit*/ 100).await;
let mut items: Vec<SelectionItem> = Vec::with_capacity(commits.len());
for entry in commits {
let subject = entry.subject.clone();
let sha = entry.sha.clone();
let search_val = format!("{subject} {sha}");
items.push(SelectionItem {
name: subject.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review(ReviewTarget::Commit {
sha: sha.clone(),
title: Some(subject.clone()),
});
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
..Default::default()
});
}
pub(crate) fn show_review_custom_prompt(&mut self) {
let tx = self.app_event_tx.clone();
let view = CustomPromptView::new(
"Custom review instructions".to_string(),
"Type instructions and press Enter".to_string(),
/*initial_text*/ String::new(),
/*context_label*/ None,
Box::new(move |prompt: String| {
let trimmed = prompt.trim().to_string();
if trimmed.is_empty() {
return;
}
tx.review(ReviewTarget::Custom {
instructions: trimmed,
});
}),
);
self.bottom_pane.show_view(Box::new(view));
}
}
#[cfg(test)]
pub(crate) fn show_review_commit_picker_with_entries(
chat: &mut ChatWidget,
entries: Vec<CommitLogEntry>,
) {
let mut items: Vec<SelectionItem> = Vec::with_capacity(entries.len());
for entry in entries {
let subject = entry.subject.clone();
let sha = entry.sha.clone();
let search_val = format!("{subject} {sha}");
items.push(SelectionItem {
name: subject.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.review(ReviewTarget::Commit {
sha: sha.clone(),
title: Some(subject.clone()),
});
})],
dismiss_on_select: true,
search_value: Some(search_val),
..Default::default()
});
}
chat.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
..Default::default()
});
}
+207
View File
@@ -0,0 +1,207 @@
//! Session configuration and thread-header orchestration for `ChatWidget`.
use super::*;
impl ChatWidget {
fn on_session_configured_with_display_and_fork_parent_title(
&mut self,
session: ThreadSessionState,
display: SessionConfiguredDisplay,
fork_parent_title: Option<String>,
) {
self.transcript.reset_copy_history();
let history_metadata = session.message_history.unwrap_or_default();
self.bottom_pane.set_history_metadata(
session.thread_id,
history_metadata.log_id,
history_metadata.entry_count,
);
self.set_skills(/*skills*/ None);
self.session_network_proxy = session.network_proxy.clone();
let previous_thread_id = self.thread_id;
self.thread_id = Some(session.thread_id);
if previous_thread_id != self.thread_id {
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
}
self.refresh_plan_mode_nudge();
self.turn_lifecycle.reset_thread();
self.thread_name = session.thread_name.clone();
self.current_goal_status_indicator = None;
self.current_goal_status = None;
self.update_collaboration_mode_indicator();
self.forked_from = session.forked_from_id;
self.current_rollout_path = session.rollout_path.clone();
self.current_cwd = Some(session.cwd.to_path_buf());
self.config.cwd = session.cwd.clone();
self.effective_service_tier = session.service_tier.clone();
if let Err(err) = self
.config
.permissions
.approval_policy
.set(session.approval_policy.to_core())
{
tracing::warn!(%err, "failed to sync approval_policy from SessionConfigured");
self.config.permissions.approval_policy =
Constrained::allow_only(session.approval_policy.to_core());
}
let permission_sync = self
.config
.permissions
.set_permission_profile_with_active_profile(
session.permission_profile.clone(),
session.active_permission_profile.clone(),
);
if let Err(err) = permission_sync {
tracing::warn!(%err, "failed to sync permissions from SessionConfigured");
self.config.permissions.permission_profile =
Constrained::allow_only(session.permission_profile.clone());
self.config.permissions.active_permission_profile =
session.active_permission_profile.clone();
}
self.config.approvals_reviewer = session.approvals_reviewer;
self.status_line_project_root_name_cache = None;
let forked_from_id = session.forked_from_id;
let model_for_header = session.model.clone();
self.session_header.set_model(&model_for_header);
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
Some(model_for_header.clone()),
Some(session.reasoning_effort),
/*developer_instructions*/ None,
);
if let Some(mask) = self.active_collaboration_mask.as_mut() {
mask.model = Some(model_for_header.clone());
mask.reasoning_effort = Some(session.reasoning_effort);
}
self.refresh_model_display();
self.refresh_status_surfaces();
self.sync_service_tier_commands();
self.sync_personality_command_enabled();
self.sync_plugins_command_enabled();
self.sync_goal_command_enabled();
self.refresh_plugin_mentions();
if display == SessionConfiguredDisplay::Normal {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status = self
.should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref());
let session_info_cell = history_cell::new_session_info(
&self.config,
&model_for_header,
&session,
self.show_welcome_banner,
startup_tooltip_override,
self.plan_type,
show_fast_status,
);
self.apply_session_info_cell(session_info_cell);
} else if self
.transcript
.active_cell
.as_ref()
.is_some_and(|cell| cell.as_any().is::<history_cell::SessionHeaderHistoryCell>())
{
self.transcript.active_cell = None;
self.bump_active_cell_revision();
}
self.transcript.saw_copy_source_this_turn = false;
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
if self.connectors_enabled() {
self.prefetch_connectors();
}
if let Some(user_message) = self.initial_user_message.take() {
if self.suppress_initial_user_message_submit {
self.initial_user_message = Some(user_message);
} else {
self.submit_user_message(user_message);
}
}
if display == SessionConfiguredDisplay::Normal
&& let Some(forked_from_id) = forked_from_id
{
self.emit_forked_thread_event(forked_from_id, fork_parent_title);
}
if !self.suppress_session_configured_redraw {
self.request_redraw();
}
}
pub(crate) fn handle_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.clone();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::Normal,
fork_parent_title,
);
}
pub(crate) fn handle_thread_session_quiet(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::Quiet,
/*fork_parent_title*/ None,
);
}
pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.clone();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::SideConversation,
fork_parent_title,
);
}
pub(super) fn emit_forked_thread_event(
&mut self,
forked_from_id: ThreadId,
fork_parent_title: Option<String>,
) {
let forked_from_id_text = forked_from_id.to_string();
let line: Line<'static> = if let Some(name) = fork_parent_title
&& !name.trim().is_empty()
{
vec![
"".dim(),
"Thread forked from ".into(),
name.cyan(),
" (".into(),
forked_from_id_text.cyan(),
")".into(),
]
.into()
} else {
vec![
"".dim(),
"Thread forked from ".into(),
forked_from_id_text.cyan(),
]
.into()
};
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
PlainHistoryCell::new(vec![line]),
)));
}
pub(super) fn on_thread_name_updated(
&mut self,
thread_id: ThreadId,
thread_name: Option<String>,
) {
if self.thread_id == Some(thread_id) {
if let Some(name) = thread_name.as_deref() {
let cell = Self::rename_confirmation_cell(name, self.thread_id);
self.add_boxed_history(Box::new(cell));
}
self.thread_name = thread_name;
self.refresh_status_surfaces();
self.request_redraw();
self.maybe_send_next_queued_input();
}
}
pub(super) fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.bottom_pane.set_skills(skills);
}
}