mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
efdcbba053
commit
3c3e18c222
+9
-1332
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user